Repository: LnYo-Cly/ai4j Branch: main Commit: a5934b569eef Files: 1275 Total size: 5.4 MB Directory structure: gitextract_x29x44af/ ├── .editorconfig ├── .github/ │ └── workflows/ │ ├── docs-build.yml │ └── docs-pages.yml ├── .gitignore ├── LICENSE ├── README-EN.md ├── README.md ├── ai4j/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/ │ │ │ └── github/ │ │ │ └── lnyocly/ │ │ │ └── ai4j/ │ │ │ ├── agentflow/ │ │ │ │ ├── AgentFlow.java │ │ │ │ ├── AgentFlowConfig.java │ │ │ │ ├── AgentFlowException.java │ │ │ │ ├── AgentFlowType.java │ │ │ │ ├── AgentFlowUsage.java │ │ │ │ ├── chat/ │ │ │ │ │ ├── AgentFlowChatEvent.java │ │ │ │ │ ├── AgentFlowChatListener.java │ │ │ │ │ ├── AgentFlowChatRequest.java │ │ │ │ │ ├── AgentFlowChatResponse.java │ │ │ │ │ ├── AgentFlowChatService.java │ │ │ │ │ ├── CozeAgentFlowChatService.java │ │ │ │ │ └── DifyAgentFlowChatService.java │ │ │ │ ├── support/ │ │ │ │ │ └── AgentFlowSupport.java │ │ │ │ ├── trace/ │ │ │ │ │ ├── AgentFlowTraceContext.java │ │ │ │ │ └── AgentFlowTraceListener.java │ │ │ │ └── workflow/ │ │ │ │ ├── AgentFlowWorkflowEvent.java │ │ │ │ ├── AgentFlowWorkflowListener.java │ │ │ │ ├── AgentFlowWorkflowRequest.java │ │ │ │ ├── AgentFlowWorkflowResponse.java │ │ │ │ ├── AgentFlowWorkflowService.java │ │ │ │ ├── CozeAgentFlowWorkflowService.java │ │ │ │ ├── DifyAgentFlowWorkflowService.java │ │ │ │ └── N8nAgentFlowWorkflowService.java │ │ │ ├── annotation/ │ │ │ │ ├── FunctionCall.java │ │ │ │ ├── FunctionParameter.java │ │ │ │ └── FunctionRequest.java │ │ │ ├── auth/ │ │ │ │ └── BearerTokenUtils.java │ │ │ ├── config/ │ │ │ │ ├── AiPlatform.java │ │ │ │ ├── BaichuanConfig.java │ │ │ │ ├── DashScopeConfig.java │ │ │ │ ├── DeepSeekConfig.java │ │ │ │ ├── DoubaoConfig.java │ │ │ │ ├── HunyuanConfig.java │ │ │ │ ├── JinaConfig.java │ │ │ │ ├── LingyiConfig.java │ │ │ │ ├── McpConfig.java │ │ │ │ ├── MilvusConfig.java │ │ │ │ ├── MinimaxConfig.java │ │ │ │ ├── MoonshotConfig.java │ │ │ │ ├── OkHttpConfig.java │ │ │ │ ├── OllamaConfig.java │ │ │ │ ├── OpenAiConfig.java │ │ │ │ ├── PgVectorConfig.java │ │ │ │ ├── PineconeConfig.java │ │ │ │ ├── QdrantConfig.java │ │ │ │ └── ZhipuConfig.java │ │ │ ├── constant/ │ │ │ │ └── Constants.java │ │ │ ├── convert/ │ │ │ │ ├── audio/ │ │ │ │ │ ├── AudioParameterConvert.java │ │ │ │ │ └── AudioResultConvert.java │ │ │ │ ├── chat/ │ │ │ │ │ ├── ParameterConvert.java │ │ │ │ │ └── ResultConvert.java │ │ │ │ └── embedding/ │ │ │ │ ├── EmbeddingParameterConvert.java │ │ │ │ └── EmbeddingResultConvert.java │ │ │ ├── document/ │ │ │ │ ├── RecursiveCharacterTextSplitter.java │ │ │ │ └── TikaUtil.java │ │ │ ├── exception/ │ │ │ │ ├── Ai4jException.java │ │ │ │ ├── CommonException.java │ │ │ │ ├── chain/ │ │ │ │ │ ├── AbstractErrorHandler.java │ │ │ │ │ ├── ErrorHandler.java │ │ │ │ │ ├── IErrorHandler.java │ │ │ │ │ └── impl/ │ │ │ │ │ ├── HunyuanErrorHandler.java │ │ │ │ │ ├── OpenAiErrorHandler.java │ │ │ │ │ └── UnknownErrorHandler.java │ │ │ │ └── error/ │ │ │ │ ├── Error.java │ │ │ │ ├── HunyuanError.java │ │ │ │ └── OpenAiError.java │ │ │ ├── interceptor/ │ │ │ │ ├── ContentTypeInterceptor.java │ │ │ │ └── ErrorInterceptor.java │ │ │ ├── listener/ │ │ │ │ ├── AbstractManagedStreamListener.java │ │ │ │ ├── ImageSseListener.java │ │ │ │ ├── ManagedStreamListener.java │ │ │ │ ├── RealtimeListener.java │ │ │ │ ├── ResponseSseListener.java │ │ │ │ ├── SseListener.java │ │ │ │ ├── StreamExecutionOptions.java │ │ │ │ └── StreamExecutionSupport.java │ │ │ ├── mcp/ │ │ │ │ ├── annotation/ │ │ │ │ │ ├── McpParameter.java │ │ │ │ │ ├── McpPrompt.java │ │ │ │ │ ├── McpPromptParameter.java │ │ │ │ │ ├── McpResource.java │ │ │ │ │ ├── McpResourceParameter.java │ │ │ │ │ ├── McpService.java │ │ │ │ │ └── McpTool.java │ │ │ │ ├── client/ │ │ │ │ │ ├── McpClient.java │ │ │ │ │ └── McpClientResponseSupport.java │ │ │ │ ├── config/ │ │ │ │ │ ├── FileMcpConfigSource.java │ │ │ │ │ ├── McpConfigIO.java │ │ │ │ │ ├── McpConfigManager.java │ │ │ │ │ ├── McpConfigSource.java │ │ │ │ │ └── McpServerConfig.java │ │ │ │ ├── entity/ │ │ │ │ │ ├── McpError.java │ │ │ │ │ ├── McpInitializeResponse.java │ │ │ │ │ ├── McpMessage.java │ │ │ │ │ ├── McpNotification.java │ │ │ │ │ ├── McpPrompt.java │ │ │ │ │ ├── McpPromptResult.java │ │ │ │ │ ├── McpRequest.java │ │ │ │ │ ├── McpResource.java │ │ │ │ │ ├── McpResourceContent.java │ │ │ │ │ ├── McpResponse.java │ │ │ │ │ ├── McpRoot.java │ │ │ │ │ ├── McpSamplingRequest.java │ │ │ │ │ ├── McpSamplingResult.java │ │ │ │ │ ├── McpServerInfo.java │ │ │ │ │ ├── McpServerReference.java │ │ │ │ │ ├── McpTool.java │ │ │ │ │ ├── McpToolDefinition.java │ │ │ │ │ └── McpToolResult.java │ │ │ │ ├── gateway/ │ │ │ │ │ ├── McpGateway.java │ │ │ │ │ ├── McpGatewayClientFactory.java │ │ │ │ │ ├── McpGatewayConfigSourceBinding.java │ │ │ │ │ ├── McpGatewayKeySupport.java │ │ │ │ │ └── McpGatewayToolRegistry.java │ │ │ │ ├── server/ │ │ │ │ │ ├── McpHttpServerSupport.java │ │ │ │ │ ├── McpServer.java │ │ │ │ │ ├── McpServerEngine.java │ │ │ │ │ ├── McpServerFactory.java │ │ │ │ │ ├── McpServerSessionState.java │ │ │ │ │ ├── McpServerSessionSupport.java │ │ │ │ │ ├── SseMcpServer.java │ │ │ │ │ ├── StdioMcpServer.java │ │ │ │ │ └── StreamableHttpMcpServer.java │ │ │ │ ├── transport/ │ │ │ │ │ ├── McpTransport.java │ │ │ │ │ ├── McpTransportFactory.java │ │ │ │ │ ├── McpTransportSupport.java │ │ │ │ │ ├── SseTransport.java │ │ │ │ │ ├── StdioTransport.java │ │ │ │ │ ├── StreamableHttpTransport.java │ │ │ │ │ └── TransportConfig.java │ │ │ │ └── util/ │ │ │ │ ├── McpMessageCodec.java │ │ │ │ ├── McpPromptAdapter.java │ │ │ │ ├── McpResourceAdapter.java │ │ │ │ ├── McpToolAdapter.java │ │ │ │ ├── McpToolConversionSupport.java │ │ │ │ └── McpTypeSupport.java │ │ │ ├── memory/ │ │ │ │ ├── ChatMemory.java │ │ │ │ ├── ChatMemoryItem.java │ │ │ │ ├── ChatMemoryPolicy.java │ │ │ │ ├── ChatMemorySnapshot.java │ │ │ │ ├── ChatMemorySummarizer.java │ │ │ │ ├── ChatMemorySummaryRequest.java │ │ │ │ ├── InMemoryChatMemory.java │ │ │ │ ├── JdbcChatMemory.java │ │ │ │ ├── JdbcChatMemoryConfig.java │ │ │ │ ├── MessageWindowChatMemoryPolicy.java │ │ │ │ ├── SummaryChatMemoryPolicy.java │ │ │ │ ├── SummaryChatMemoryPolicyConfig.java │ │ │ │ └── UnboundedChatMemoryPolicy.java │ │ │ ├── network/ │ │ │ │ ├── ConnectionPoolProvider.java │ │ │ │ ├── DispatcherProvider.java │ │ │ │ ├── OkHttpUtil.java │ │ │ │ ├── UrlUtils.java │ │ │ │ └── impl/ │ │ │ │ ├── DefaultConnectionPoolProvider.java │ │ │ │ └── DefaultDispatcherProvider.java │ │ │ ├── platform/ │ │ │ │ ├── baichuan/ │ │ │ │ │ └── chat/ │ │ │ │ │ ├── BaichuanChatService.java │ │ │ │ │ └── entity/ │ │ │ │ │ ├── BaichuanChatCompletion.java │ │ │ │ │ └── BaichuanChatCompletionResponse.java │ │ │ │ ├── dashscope/ │ │ │ │ │ ├── DashScopeChatService.java │ │ │ │ │ ├── entity/ │ │ │ │ │ │ └── DashScopeResult.java │ │ │ │ │ ├── response/ │ │ │ │ │ │ └── DashScopeResponsesService.java │ │ │ │ │ └── util/ │ │ │ │ │ └── MessageUtil.java │ │ │ │ ├── deepseek/ │ │ │ │ │ └── chat/ │ │ │ │ │ ├── DeepSeekChatService.java │ │ │ │ │ └── entity/ │ │ │ │ │ ├── DeepSeekChatCompletion.java │ │ │ │ │ └── DeepSeekChatCompletionResponse.java │ │ │ │ ├── doubao/ │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── DoubaoChatService.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── DoubaoChatCompletion.java │ │ │ │ │ │ └── DoubaoChatCompletionResponse.java │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── DoubaoImageService.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ └── DoubaoImageGenerationRequest.java │ │ │ │ │ ├── rerank/ │ │ │ │ │ │ └── DoubaoRerankService.java │ │ │ │ │ └── response/ │ │ │ │ │ └── DoubaoResponsesService.java │ │ │ │ ├── hunyuan/ │ │ │ │ │ ├── HunyuanConstant.java │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── HunyuanChatService.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── HunyuanChatCompletion.java │ │ │ │ │ │ └── HunyuanChatCompletionResponse.java │ │ │ │ │ └── support/ │ │ │ │ │ └── HunyuanJsonUtil.java │ │ │ │ ├── jina/ │ │ │ │ │ └── rerank/ │ │ │ │ │ └── JinaRerankService.java │ │ │ │ ├── lingyi/ │ │ │ │ │ └── chat/ │ │ │ │ │ ├── LingyiChatService.java │ │ │ │ │ └── entity/ │ │ │ │ │ ├── LingyiChatCompletion.java │ │ │ │ │ └── LingyiChatCompletionResponse.java │ │ │ │ ├── minimax/ │ │ │ │ │ └── chat/ │ │ │ │ │ ├── MinimaxChatService.java │ │ │ │ │ └── entity/ │ │ │ │ │ ├── MinimaxChatCompletion.java │ │ │ │ │ └── MinimaxChatCompletionResponse.java │ │ │ │ ├── moonshot/ │ │ │ │ │ └── chat/ │ │ │ │ │ ├── MoonshotChatService.java │ │ │ │ │ └── entity/ │ │ │ │ │ ├── MoonshotChatCompletion.java │ │ │ │ │ └── MoonshotChatCompletionResponse.java │ │ │ │ ├── ollama/ │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── OllamaAiChatService.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── OllamaChatCompletion.java │ │ │ │ │ │ ├── OllamaChatCompletionResponse.java │ │ │ │ │ │ ├── OllamaMessage.java │ │ │ │ │ │ └── OllamaOptions.java │ │ │ │ │ ├── embedding/ │ │ │ │ │ │ ├── OllamaEmbeddingService.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── OllamaEmbedding.java │ │ │ │ │ │ └── OllamaEmbeddingResponse.java │ │ │ │ │ └── rerank/ │ │ │ │ │ └── OllamaRerankService.java │ │ │ │ ├── openai/ │ │ │ │ │ ├── audio/ │ │ │ │ │ │ ├── OpenAiAudioService.java │ │ │ │ │ │ ├── entity/ │ │ │ │ │ │ │ ├── Segment.java │ │ │ │ │ │ │ ├── TextToSpeech.java │ │ │ │ │ │ │ ├── Transcription.java │ │ │ │ │ │ │ ├── TranscriptionResponse.java │ │ │ │ │ │ │ ├── Translation.java │ │ │ │ │ │ │ ├── TranslationResponse.java │ │ │ │ │ │ │ └── Word.java │ │ │ │ │ │ └── enums/ │ │ │ │ │ │ ├── AudioEnum.java │ │ │ │ │ │ └── WhisperEnum.java │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── OpenAiChatService.java │ │ │ │ │ │ ├── entity/ │ │ │ │ │ │ │ ├── ChatCompletion.java │ │ │ │ │ │ │ ├── ChatCompletionResponse.java │ │ │ │ │ │ │ ├── ChatMessage.java │ │ │ │ │ │ │ ├── Choice.java │ │ │ │ │ │ │ ├── Content.java │ │ │ │ │ │ │ └── StreamOptions.java │ │ │ │ │ │ ├── enums/ │ │ │ │ │ │ │ └── ChatMessageType.java │ │ │ │ │ │ └── serializer/ │ │ │ │ │ │ └── ContentDeserializer.java │ │ │ │ │ ├── embedding/ │ │ │ │ │ │ ├── OpenAiEmbeddingService.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── Embedding.java │ │ │ │ │ │ ├── EmbeddingObject.java │ │ │ │ │ │ └── EmbeddingResponse.java │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── OpenAiImageService.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── ImageData.java │ │ │ │ │ │ ├── ImageGeneration.java │ │ │ │ │ │ ├── ImageGenerationResponse.java │ │ │ │ │ │ ├── ImageStreamError.java │ │ │ │ │ │ ├── ImageStreamEvent.java │ │ │ │ │ │ ├── ImageUsage.java │ │ │ │ │ │ └── ImageUsageDetails.java │ │ │ │ │ ├── realtime/ │ │ │ │ │ │ ├── OpenAiRealtimeService.java │ │ │ │ │ │ ├── RealtimeConstant.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── ConversationCreated.java │ │ │ │ │ │ ├── Session.java │ │ │ │ │ │ ├── SessionCreated.java │ │ │ │ │ │ └── SessionUpdated.java │ │ │ │ │ ├── response/ │ │ │ │ │ │ ├── OpenAiResponsesService.java │ │ │ │ │ │ ├── ResponseEventParser.java │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── ImagePixelLimit.java │ │ │ │ │ │ ├── Response.java │ │ │ │ │ │ ├── ResponseContentPart.java │ │ │ │ │ │ ├── ResponseContextEdit.java │ │ │ │ │ │ ├── ResponseContextManagement.java │ │ │ │ │ │ ├── ResponseDeleteResponse.java │ │ │ │ │ │ ├── ResponseError.java │ │ │ │ │ │ ├── ResponseIncompleteDetails.java │ │ │ │ │ │ ├── ResponseItem.java │ │ │ │ │ │ ├── ResponseRequest.java │ │ │ │ │ │ ├── ResponseStreamEvent.java │ │ │ │ │ │ ├── ResponseSummary.java │ │ │ │ │ │ ├── ResponseToolUsage.java │ │ │ │ │ │ ├── ResponseToolUsageDetails.java │ │ │ │ │ │ ├── ResponseUsage.java │ │ │ │ │ │ ├── ResponseUsageDetails.java │ │ │ │ │ │ └── TranslationOptions.java │ │ │ │ │ ├── tool/ │ │ │ │ │ │ ├── Tool.java │ │ │ │ │ │ └── ToolCall.java │ │ │ │ │ └── usage/ │ │ │ │ │ └── Usage.java │ │ │ │ ├── standard/ │ │ │ │ │ └── rerank/ │ │ │ │ │ └── StandardRerankService.java │ │ │ │ └── zhipu/ │ │ │ │ └── chat/ │ │ │ │ ├── ZhipuChatService.java │ │ │ │ └── entity/ │ │ │ │ ├── ZhipuChatCompletion.java │ │ │ │ └── ZhipuChatCompletionResponse.java │ │ │ ├── rag/ │ │ │ │ ├── AbstractScoreFusionStrategy.java │ │ │ │ ├── Bm25Retriever.java │ │ │ │ ├── DbsfFusionStrategy.java │ │ │ │ ├── DefaultRagContextAssembler.java │ │ │ │ ├── DefaultRagService.java │ │ │ │ ├── DefaultTextTokenizer.java │ │ │ │ ├── DenseRetriever.java │ │ │ │ ├── FusionStrategy.java │ │ │ │ ├── HybridRetriever.java │ │ │ │ ├── ModelReranker.java │ │ │ │ ├── NoopReranker.java │ │ │ │ ├── RagChunk.java │ │ │ │ ├── RagCitation.java │ │ │ │ ├── RagContext.java │ │ │ │ ├── RagContextAssembler.java │ │ │ │ ├── RagDocument.java │ │ │ │ ├── RagEvaluation.java │ │ │ │ ├── RagEvaluator.java │ │ │ │ ├── RagHit.java │ │ │ │ ├── RagHitSupport.java │ │ │ │ ├── RagMetadataKeys.java │ │ │ │ ├── RagQuery.java │ │ │ │ ├── RagResult.java │ │ │ │ ├── RagScoreDetail.java │ │ │ │ ├── RagService.java │ │ │ │ ├── RagTrace.java │ │ │ │ ├── Reranker.java │ │ │ │ ├── Retriever.java │ │ │ │ ├── RrfFusionStrategy.java │ │ │ │ ├── RsfFusionStrategy.java │ │ │ │ ├── TextTokenizer.java │ │ │ │ └── ingestion/ │ │ │ │ ├── Chunker.java │ │ │ │ ├── DefaultMetadataEnricher.java │ │ │ │ ├── DocumentLoader.java │ │ │ │ ├── IngestionPipeline.java │ │ │ │ ├── IngestionRequest.java │ │ │ │ ├── IngestionResult.java │ │ │ │ ├── IngestionSource.java │ │ │ │ ├── LoadedDocument.java │ │ │ │ ├── LoadedDocumentProcessor.java │ │ │ │ ├── MetadataEnricher.java │ │ │ │ ├── OcrNoiseCleaningDocumentProcessor.java │ │ │ │ ├── OcrTextExtractingDocumentProcessor.java │ │ │ │ ├── OcrTextExtractor.java │ │ │ │ ├── RecursiveTextChunker.java │ │ │ │ ├── TextDocumentLoader.java │ │ │ │ ├── TikaDocumentLoader.java │ │ │ │ └── WhitespaceNormalizingDocumentProcessor.java │ │ │ ├── rerank/ │ │ │ │ └── entity/ │ │ │ │ ├── RerankDocument.java │ │ │ │ ├── RerankRequest.java │ │ │ │ ├── RerankResponse.java │ │ │ │ ├── RerankResult.java │ │ │ │ └── RerankUsage.java │ │ │ ├── service/ │ │ │ │ ├── AiConfig.java │ │ │ │ ├── Configuration.java │ │ │ │ ├── IAudioService.java │ │ │ │ ├── IChatService.java │ │ │ │ ├── IEmbeddingService.java │ │ │ │ ├── IImageService.java │ │ │ │ ├── IRealtimeService.java │ │ │ │ ├── IRerankService.java │ │ │ │ ├── IResponsesService.java │ │ │ │ ├── ModelType.java │ │ │ │ ├── PlatformType.java │ │ │ │ ├── factory/ │ │ │ │ │ ├── AiService.java │ │ │ │ │ ├── AiServiceFactory.java │ │ │ │ │ ├── AiServiceRegistration.java │ │ │ │ │ ├── AiServiceRegistry.java │ │ │ │ │ ├── DefaultAiServiceFactory.java │ │ │ │ │ ├── DefaultAiServiceRegistry.java │ │ │ │ │ └── FreeAiService.java │ │ │ │ └── spi/ │ │ │ │ └── ServiceLoaderUtil.java │ │ │ ├── skill/ │ │ │ │ ├── SkillDescriptor.java │ │ │ │ └── Skills.java │ │ │ ├── token/ │ │ │ │ └── TikTokensUtil.java │ │ │ ├── tool/ │ │ │ │ ├── BuiltInProcessRegistry.java │ │ │ │ ├── BuiltInToolContext.java │ │ │ │ ├── BuiltInToolExecutor.java │ │ │ │ ├── BuiltInTools.java │ │ │ │ ├── ResponseRequestToolResolver.java │ │ │ │ └── ToolUtil.java │ │ │ ├── tools/ │ │ │ │ ├── ApplyPatchFunction.java │ │ │ │ ├── BashFunction.java │ │ │ │ ├── QueryTrainInfoFunction.java │ │ │ │ ├── QueryWeatherFunction.java │ │ │ │ ├── ReadFileFunction.java │ │ │ │ └── WriteFileFunction.java │ │ │ ├── vector/ │ │ │ │ ├── VectorDataEntity.java │ │ │ │ ├── pinecone/ │ │ │ │ │ ├── PineconeDelete.java │ │ │ │ │ ├── PineconeInsert.java │ │ │ │ │ ├── PineconeInsertResponse.java │ │ │ │ │ ├── PineconeQuery.java │ │ │ │ │ ├── PineconeQueryResponse.java │ │ │ │ │ └── PineconeVectors.java │ │ │ │ ├── service/ │ │ │ │ │ └── PineconeService.java │ │ │ │ └── store/ │ │ │ │ ├── VectorDeleteRequest.java │ │ │ │ ├── VectorRecord.java │ │ │ │ ├── VectorSearchRequest.java │ │ │ │ ├── VectorSearchResult.java │ │ │ │ ├── VectorStore.java │ │ │ │ ├── VectorStoreCapabilities.java │ │ │ │ ├── VectorUpsertRequest.java │ │ │ │ ├── milvus/ │ │ │ │ │ └── MilvusVectorStore.java │ │ │ │ ├── pgvector/ │ │ │ │ │ └── PgVectorStore.java │ │ │ │ ├── pinecone/ │ │ │ │ │ └── PineconeVectorStore.java │ │ │ │ └── qdrant/ │ │ │ │ └── QdrantVectorStore.java │ │ │ └── websearch/ │ │ │ ├── ChatWithWebSearchEnhance.java │ │ │ └── searxng/ │ │ │ ├── SearXNGConfig.java │ │ │ ├── SearXNGRequest.java │ │ │ └── SearXNGResponse.java │ │ └── resources/ │ │ ├── META-INF/ │ │ │ └── services/ │ │ │ ├── io.github.lnyocly.ai4j.network.ConnectionPoolProvider │ │ │ └── io.github.lnyocly.ai4j.network.DispatcherProvider │ │ └── mcp-servers-config.json │ └── test/ │ └── java/ │ └── io/ │ └── github/ │ └── lnyocly/ │ ├── BaichuanTest.java │ ├── DashScopeTest.java │ ├── DeepSeekTest.java │ ├── DoubaoImageTest.java │ ├── DoubaoResponsesTest.java │ ├── DoubaoTest.java │ ├── HunyuanTest.java │ ├── LingyiTest.java │ ├── MinimaxTest.java │ ├── MoonshotTest.java │ ├── OllamaTest.java │ ├── OpenAiTest.java │ ├── OtherTest.java │ ├── ZhipuTest.java │ ├── ai4j/ │ │ ├── agentflow/ │ │ │ ├── AgentFlowTraceSupportTest.java │ │ │ ├── CozeAgentFlowServiceTest.java │ │ │ ├── DifyAgentFlowServiceTest.java │ │ │ └── N8nAgentFlowWorkflowServiceTest.java │ │ ├── function/ │ │ │ └── TestFunction.java │ │ ├── mcp/ │ │ │ ├── McpClientResponseSupportTest.java │ │ │ ├── McpGatewaySupportTest.java │ │ │ ├── McpServerTest.java │ │ │ ├── McpTypeSupportTest.java │ │ │ ├── TestMcpService.java │ │ │ ├── config/ │ │ │ │ └── McpConfigManagerTest.java │ │ │ ├── gateway/ │ │ │ │ └── McpGatewayConfigSourceTest.java │ │ │ ├── transport/ │ │ │ │ └── SseTransportTest.java │ │ │ └── util/ │ │ │ └── TestMcpService.java │ │ ├── memory/ │ │ │ ├── InMemoryChatMemoryTest.java │ │ │ ├── JdbcChatMemoryTest.java │ │ │ └── SummaryChatMemoryPolicyTest.java │ │ ├── platform/ │ │ │ ├── doubao/ │ │ │ │ └── rerank/ │ │ │ │ └── DoubaoRerankServiceTest.java │ │ │ ├── jina/ │ │ │ │ └── rerank/ │ │ │ │ └── JinaRerankServiceTest.java │ │ │ ├── minimax/ │ │ │ │ └── chat/ │ │ │ │ └── MinimaxChatServiceTest.java │ │ │ ├── ollama/ │ │ │ │ └── rerank/ │ │ │ │ └── OllamaRerankServiceTest.java │ │ │ └── openai/ │ │ │ ├── audio/ │ │ │ │ └── OpenAiAudioServiceTest.java │ │ │ ├── chat/ │ │ │ │ └── OpenAiChatServicePassThroughTest.java │ │ │ └── response/ │ │ │ └── ResponseRequestToolResolverTest.java │ │ ├── rag/ │ │ │ ├── Bm25RetrieverTest.java │ │ │ ├── DefaultRagServiceTest.java │ │ │ ├── DenseRetrieverTest.java │ │ │ ├── HybridRetrieverTest.java │ │ │ ├── IngestionPipelineTest.java │ │ │ ├── ModelRerankerTest.java │ │ │ └── RagEvaluatorTest.java │ │ ├── skill/ │ │ │ └── SkillsIChatServiceTest.java │ │ ├── tool/ │ │ │ └── BuiltInToolExecutorTest.java │ │ └── vector/ │ │ └── store/ │ │ ├── milvus/ │ │ │ └── MilvusVectorStoreTest.java │ │ ├── pinecone/ │ │ │ └── PineconeVectorStoreTest.java │ │ └── qdrant/ │ │ └── QdrantVectorStoreTest.java │ ├── interceptor/ │ │ └── ErrorInterceptorTest.java │ ├── listener/ │ │ ├── SseListenerTest.java │ │ └── StreamExecutionSupportTest.java │ └── service/ │ └── AiServiceRegistryTest.java ├── ai4j-agent/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── io/ │ │ └── github/ │ │ └── lnyocly/ │ │ └── ai4j/ │ │ └── agent/ │ │ ├── Agent.java │ │ ├── AgentBuilder.java │ │ ├── AgentContext.java │ │ ├── AgentOptions.java │ │ ├── AgentRequest.java │ │ ├── AgentResult.java │ │ ├── AgentRuntime.java │ │ ├── AgentSession.java │ │ ├── Agents.java │ │ ├── codeact/ │ │ │ ├── CodeActOptions.java │ │ │ ├── CodeExecutionRequest.java │ │ │ ├── CodeExecutionResult.java │ │ │ ├── CodeExecutor.java │ │ │ ├── GraalVmCodeExecutor.java │ │ │ └── NashornCodeExecutor.java │ │ ├── event/ │ │ │ ├── AgentEvent.java │ │ │ ├── AgentEventPublisher.java │ │ │ ├── AgentEventType.java │ │ │ └── AgentListener.java │ │ ├── flowgram/ │ │ │ ├── Ai4jFlowGramLlmNodeRunner.java │ │ │ ├── FlowGramLlmNodeRunner.java │ │ │ ├── FlowGramNodeExecutionContext.java │ │ │ ├── FlowGramNodeExecutionResult.java │ │ │ ├── FlowGramNodeExecutor.java │ │ │ ├── FlowGramRuntimeEvent.java │ │ │ ├── FlowGramRuntimeListener.java │ │ │ ├── FlowGramRuntimeService.java │ │ │ └── model/ │ │ │ ├── FlowGramEdgeSchema.java │ │ │ ├── FlowGramNodeSchema.java │ │ │ ├── FlowGramTaskCancelOutput.java │ │ │ ├── FlowGramTaskReportOutput.java │ │ │ ├── FlowGramTaskResultOutput.java │ │ │ ├── FlowGramTaskRunInput.java │ │ │ ├── FlowGramTaskRunOutput.java │ │ │ ├── FlowGramTaskValidateOutput.java │ │ │ └── FlowGramWorkflowSchema.java │ │ ├── memory/ │ │ │ ├── AgentMemory.java │ │ │ ├── InMemoryAgentMemory.java │ │ │ ├── JdbcAgentMemory.java │ │ │ ├── JdbcAgentMemoryConfig.java │ │ │ ├── MemoryCompressor.java │ │ │ ├── MemorySnapshot.java │ │ │ └── WindowedMemoryCompressor.java │ │ ├── model/ │ │ │ ├── AgentModelClient.java │ │ │ ├── AgentModelResult.java │ │ │ ├── AgentModelStreamListener.java │ │ │ ├── AgentPrompt.java │ │ │ ├── ChatModelClient.java │ │ │ └── ResponsesModelClient.java │ │ ├── runtime/ │ │ │ ├── AgentToolExecutionScope.java │ │ │ ├── BaseAgentRuntime.java │ │ │ ├── CodeActRuntime.java │ │ │ ├── DeepResearchRuntime.java │ │ │ ├── Planner.java │ │ │ └── ReActRuntime.java │ │ ├── subagent/ │ │ │ ├── HandoffContext.java │ │ │ ├── HandoffFailureAction.java │ │ │ ├── HandoffInputFilter.java │ │ │ ├── HandoffPolicy.java │ │ │ ├── StaticSubAgentRegistry.java │ │ │ ├── SubAgentDefinition.java │ │ │ ├── SubAgentRegistry.java │ │ │ ├── SubAgentSessionMode.java │ │ │ └── SubAgentToolExecutor.java │ │ ├── team/ │ │ │ ├── AgentTeam.java │ │ │ ├── AgentTeamAgentRuntime.java │ │ │ ├── AgentTeamBuilder.java │ │ │ ├── AgentTeamControl.java │ │ │ ├── AgentTeamEventHook.java │ │ │ ├── AgentTeamHook.java │ │ │ ├── AgentTeamMember.java │ │ │ ├── AgentTeamMemberResult.java │ │ │ ├── AgentTeamMemberSnapshot.java │ │ │ ├── AgentTeamMessage.java │ │ │ ├── AgentTeamMessageBus.java │ │ │ ├── AgentTeamOptions.java │ │ │ ├── AgentTeamPlan.java │ │ │ ├── AgentTeamPlanApproval.java │ │ │ ├── AgentTeamPlanParser.java │ │ │ ├── AgentTeamPlanner.java │ │ │ ├── AgentTeamResult.java │ │ │ ├── AgentTeamState.java │ │ │ ├── AgentTeamStateStore.java │ │ │ ├── AgentTeamSynthesizer.java │ │ │ ├── AgentTeamTask.java │ │ │ ├── AgentTeamTaskBoard.java │ │ │ ├── AgentTeamTaskState.java │ │ │ ├── AgentTeamTaskStatus.java │ │ │ ├── FileAgentTeamMessageBus.java │ │ │ ├── FileAgentTeamStateStore.java │ │ │ ├── InMemoryAgentTeamMessageBus.java │ │ │ ├── InMemoryAgentTeamStateStore.java │ │ │ ├── LlmAgentTeamPlanner.java │ │ │ ├── LlmAgentTeamSynthesizer.java │ │ │ └── tool/ │ │ │ ├── AgentTeamToolExecutor.java │ │ │ └── AgentTeamToolRegistry.java │ │ ├── tool/ │ │ │ ├── AgentToolCall.java │ │ │ ├── AgentToolCallSanitizer.java │ │ │ ├── AgentToolRegistry.java │ │ │ ├── AgentToolResult.java │ │ │ ├── CompositeToolRegistry.java │ │ │ ├── StaticToolRegistry.java │ │ │ ├── ToolExecutor.java │ │ │ ├── ToolUtilExecutor.java │ │ │ └── ToolUtilRegistry.java │ │ ├── trace/ │ │ │ ├── AbstractOpenTelemetryTraceExporter.java │ │ │ ├── AgentFlowTraceBridge.java │ │ │ ├── AgentTraceListener.java │ │ │ ├── CompositeTraceExporter.java │ │ │ ├── ConsoleTraceExporter.java │ │ │ ├── InMemoryTraceExporter.java │ │ │ ├── JsonlTraceExporter.java │ │ │ ├── LangfuseTraceExporter.java │ │ │ ├── OpenTelemetryTraceExporter.java │ │ │ ├── OpenTelemetryTraceSupport.java │ │ │ ├── TraceConfig.java │ │ │ ├── TraceExporter.java │ │ │ ├── TraceMasker.java │ │ │ ├── TraceMetrics.java │ │ │ ├── TracePricing.java │ │ │ ├── TracePricingResolver.java │ │ │ ├── TraceSpan.java │ │ │ ├── TraceSpanEvent.java │ │ │ ├── TraceSpanStatus.java │ │ │ └── TraceSpanType.java │ │ ├── util/ │ │ │ ├── AgentInputItem.java │ │ │ └── ResponseUtil.java │ │ └── workflow/ │ │ ├── AgentNode.java │ │ ├── AgentWorkflow.java │ │ ├── RuntimeAgentNode.java │ │ ├── SequentialWorkflow.java │ │ ├── StateCondition.java │ │ ├── StateGraphWorkflow.java │ │ ├── StateRouter.java │ │ ├── StateTransition.java │ │ ├── WorkflowAgent.java │ │ ├── WorkflowContext.java │ │ └── WorkflowResultAware.java │ └── test/ │ └── java/ │ └── io/ │ └── github/ │ └── lnyocly/ │ ├── agent/ │ │ ├── AgentFlowTraceBridgeTest.java │ │ ├── AgentMemoryTest.java │ │ ├── AgentRuntimeTest.java │ │ ├── AgentTeamAgentAdapterTest.java │ │ ├── AgentTeamPersistenceTest.java │ │ ├── AgentTeamProjectDeliveryExampleTest.java │ │ ├── AgentTeamTaskBoardTest.java │ │ ├── AgentTeamTest.java │ │ ├── AgentTeamUsageTest.java │ │ ├── AgentTraceListenerTest.java │ │ ├── AgentTraceUsageTest.java │ │ ├── AgentWorkflowTest.java │ │ ├── AgentWorkflowUsageTest.java │ │ ├── ChatModelClientTest.java │ │ ├── CodeActAgentUsageTest.java │ │ ├── CodeActPythonExecutorTest.java │ │ ├── CodeActRuntimeTest.java │ │ ├── CodeActRuntimeWithTraceTest.java │ │ ├── DoubaoAgentTeamBestPracticeTest.java │ │ ├── DoubaoAgentWorkflowTest.java │ │ ├── DoubaoProjectTeamAgentTeamsTest.java │ │ ├── FileAgentTeamStateStoreTest.java │ │ ├── HandoffPolicyTest.java │ │ ├── MinimaxAgentTeamTravelUsageTest.java │ │ ├── NashornCodeExecutorTest.java │ │ ├── ReActAgentUsageTest.java │ │ ├── ResponsesModelClientTest.java │ │ ├── StateGraphWorkflowTest.java │ │ ├── SubAgentParallelFallbackTest.java │ │ ├── SubAgentRuntimeTest.java │ │ ├── SubAgentUsageTest.java │ │ ├── ToolUtilExecutorRestrictionTest.java │ │ ├── UniversalAgentUsageTest.java │ │ ├── WeatherAgentWorkflowTest.java │ │ └── support/ │ │ └── ZhipuAgentTestSupport.java │ └── ai4j/ │ └── agent/ │ ├── flowgram/ │ │ └── FlowGramRuntimeServiceTest.java │ ├── memory/ │ │ └── JdbcAgentMemoryTest.java │ ├── model/ │ │ └── ChatModelClientTest.java │ └── trace/ │ └── LangfuseTraceExporterTest.java ├── ai4j-bom/ │ └── pom.xml ├── ai4j-cli/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/ │ │ │ └── github/ │ │ │ └── lnyocly/ │ │ │ └── ai4j/ │ │ │ ├── cli/ │ │ │ │ ├── Ai4jCli.java │ │ │ │ ├── Ai4jCliMain.java │ │ │ │ ├── ApprovalMode.java │ │ │ │ ├── CliProtocol.java │ │ │ │ ├── CliUiMode.java │ │ │ │ ├── SlashCommandController.java │ │ │ │ ├── acp/ │ │ │ │ │ ├── AcpCodingCliAgentFactory.java │ │ │ │ │ ├── AcpCommand.java │ │ │ │ │ ├── AcpJsonRpcServer.java │ │ │ │ │ ├── AcpSlashCommandSupport.java │ │ │ │ │ └── AcpToolApprovalDecorator.java │ │ │ │ ├── agent/ │ │ │ │ │ └── CliCodingAgentRegistry.java │ │ │ │ ├── command/ │ │ │ │ │ ├── CodeCommand.java │ │ │ │ │ ├── CodeCommandOptions.java │ │ │ │ │ ├── CodeCommandOptionsParser.java │ │ │ │ │ ├── CustomCommandRegistry.java │ │ │ │ │ └── CustomCommandTemplate.java │ │ │ │ ├── config/ │ │ │ │ │ └── CliWorkspaceConfig.java │ │ │ │ ├── factory/ │ │ │ │ │ ├── CodingCliAgentFactory.java │ │ │ │ │ ├── CodingCliTuiFactory.java │ │ │ │ │ ├── DefaultCodingCliAgentFactory.java │ │ │ │ │ └── DefaultCodingCliTuiFactory.java │ │ │ │ ├── mcp/ │ │ │ │ │ ├── CliMcpConfig.java │ │ │ │ │ ├── CliMcpConfigManager.java │ │ │ │ │ ├── CliMcpConnectionHandle.java │ │ │ │ │ ├── CliMcpRuntimeManager.java │ │ │ │ │ ├── CliMcpServerDefinition.java │ │ │ │ │ ├── CliMcpStatusSnapshot.java │ │ │ │ │ ├── CliResolvedMcpConfig.java │ │ │ │ │ └── CliResolvedMcpServer.java │ │ │ │ ├── provider/ │ │ │ │ │ ├── CliProviderConfigManager.java │ │ │ │ │ ├── CliProviderProfile.java │ │ │ │ │ ├── CliProvidersConfig.java │ │ │ │ │ └── CliResolvedProviderConfig.java │ │ │ │ ├── render/ │ │ │ │ │ ├── AssistantTranscriptRenderer.java │ │ │ │ │ ├── CliAnsi.java │ │ │ │ │ ├── CliDisplayWidth.java │ │ │ │ │ ├── CliThemeStyler.java │ │ │ │ │ ├── CodexStyleBlockFormatter.java │ │ │ │ │ ├── PatchSummaryFormatter.java │ │ │ │ │ └── TranscriptPrinter.java │ │ │ │ ├── runtime/ │ │ │ │ │ ├── AgentHandoffSessionEventSupport.java │ │ │ │ │ ├── AgentTeamMessageSessionEventSupport.java │ │ │ │ │ ├── AgentTeamSessionEventSupport.java │ │ │ │ │ ├── CliTeamStateManager.java │ │ │ │ │ ├── CliToolApprovalDecorator.java │ │ │ │ │ ├── CodingCliSessionRunner.java │ │ │ │ │ ├── CodingCliTuiSupport.java │ │ │ │ │ ├── CodingTaskSessionEventBridge.java │ │ │ │ │ ├── HeadlessCodingSessionRuntime.java │ │ │ │ │ ├── HeadlessTurnObserver.java │ │ │ │ │ └── TeamBoardRenderSupport.java │ │ │ │ ├── session/ │ │ │ │ │ ├── CodingSessionManager.java │ │ │ │ │ ├── CodingSessionStore.java │ │ │ │ │ ├── DefaultCodingSessionManager.java │ │ │ │ │ ├── FileCodingSessionStore.java │ │ │ │ │ ├── FileSessionEventStore.java │ │ │ │ │ ├── InMemoryCodingSessionStore.java │ │ │ │ │ ├── InMemorySessionEventStore.java │ │ │ │ │ ├── SessionEventStore.java │ │ │ │ │ └── StoredCodingSession.java │ │ │ │ └── shell/ │ │ │ │ ├── JlineCodeCommandRunner.java │ │ │ │ ├── JlineShellContext.java │ │ │ │ ├── JlineShellTerminalIO.java │ │ │ │ └── WindowsConsoleKeyPoller.java │ │ │ └── tui/ │ │ │ ├── AnsiTuiRuntime.java │ │ │ ├── AppendOnlyTuiRuntime.java │ │ │ ├── JlineTerminalIO.java │ │ │ ├── StreamsTerminalIO.java │ │ │ ├── TerminalIO.java │ │ │ ├── TuiAnsi.java │ │ │ ├── TuiAssistantPhase.java │ │ │ ├── TuiAssistantToolView.java │ │ │ ├── TuiAssistantViewModel.java │ │ │ ├── TuiConfig.java │ │ │ ├── TuiConfigManager.java │ │ │ ├── TuiInteractionState.java │ │ │ ├── TuiKeyStroke.java │ │ │ ├── TuiKeyType.java │ │ │ ├── TuiPaletteItem.java │ │ │ ├── TuiPanelId.java │ │ │ ├── TuiRenderContext.java │ │ │ ├── TuiRenderer.java │ │ │ ├── TuiRuntime.java │ │ │ ├── TuiScreenModel.java │ │ │ ├── TuiSessionView.java │ │ │ ├── TuiTheme.java │ │ │ ├── io/ │ │ │ │ ├── DefaultJlineTerminalIO.java │ │ │ │ └── DefaultStreamsTerminalIO.java │ │ │ └── runtime/ │ │ │ ├── DefaultAnsiTuiRuntime.java │ │ │ └── DefaultAppendOnlyTuiRuntime.java │ │ └── resources/ │ │ └── io/ │ │ └── github/ │ │ └── lnyocly/ │ │ └── ai4j/ │ │ └── tui/ │ │ └── themes/ │ │ ├── amber.json │ │ ├── default.json │ │ ├── github-dark.json │ │ ├── github-light.json │ │ ├── matrix.json │ │ └── ocean.json │ └── test/ │ └── java/ │ └── io/ │ └── github/ │ └── lnyocly/ │ └── ai4j/ │ ├── cli/ │ │ ├── Ai4jCliTest.java │ │ ├── AssistantTranscriptRendererTest.java │ │ ├── CliDisplayWidthTest.java │ │ ├── CliMcpConfigManagerTest.java │ │ ├── CliMcpRuntimeManagerTest.java │ │ ├── CliProviderConfigManagerTest.java │ │ ├── CliThemeStylerTest.java │ │ ├── CodeCommandOptionsParserTest.java │ │ ├── CodeCommandTest.java │ │ ├── CodexStyleBlockFormatterTest.java │ │ ├── DefaultCodingCliAgentFactoryTest.java │ │ ├── DefaultCodingSessionManagerTest.java │ │ ├── FileCodingSessionStoreTest.java │ │ ├── FileSessionEventStoreTest.java │ │ ├── JlineShellTerminalIOTest.java │ │ ├── PatchSummaryFormatterTest.java │ │ ├── SlashCommandControllerTest.java │ │ ├── TranscriptPrinterTest.java │ │ ├── acp/ │ │ │ ├── AcpCommandTest.java │ │ │ └── AcpSlashCommandSupportTest.java │ │ ├── agent/ │ │ │ └── CliCodingAgentRegistryTest.java │ │ └── runtime/ │ │ ├── AgentHandoffSessionEventSupportTest.java │ │ ├── AgentTeamMessageSessionEventSupportTest.java │ │ ├── AgentTeamSessionEventSupportTest.java │ │ ├── CodingTaskSessionEventBridgeTest.java │ │ └── HeadlessCodingSessionRuntimeTest.java │ └── tui/ │ ├── AnsiTuiRuntimeTest.java │ ├── AppendOnlyTuiRuntimeTest.java │ ├── StreamsTerminalIOTest.java │ ├── TuiConfigManagerTest.java │ ├── TuiInteractionStateTest.java │ └── TuiSessionViewTest.java ├── ai4j-coding/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── io/ │ │ └── github/ │ │ └── lnyocly/ │ │ └── ai4j/ │ │ └── coding/ │ │ ├── CodingAgent.java │ │ ├── CodingAgentBuilder.java │ │ ├── CodingAgentOptions.java │ │ ├── CodingAgentRequest.java │ │ ├── CodingAgentResult.java │ │ ├── CodingAgents.java │ │ ├── CodingSession.java │ │ ├── CodingSessionCheckpoint.java │ │ ├── CodingSessionCheckpointFormatter.java │ │ ├── CodingSessionCompactResult.java │ │ ├── CodingSessionScope.java │ │ ├── CodingSessionSnapshot.java │ │ ├── CodingSessionState.java │ │ ├── compact/ │ │ │ ├── CodingCompactionPreparation.java │ │ │ ├── CodingSessionCompactor.java │ │ │ ├── CodingToolResultMicroCompactResult.java │ │ │ └── CodingToolResultMicroCompactor.java │ │ ├── definition/ │ │ │ ├── BuiltInCodingAgentDefinitions.java │ │ │ ├── CodingAgentDefinition.java │ │ │ ├── CodingAgentDefinitionRegistry.java │ │ │ ├── CodingApprovalMode.java │ │ │ ├── CodingIsolationMode.java │ │ │ ├── CodingMemoryScope.java │ │ │ ├── CodingSessionMode.java │ │ │ ├── CompositeCodingAgentDefinitionRegistry.java │ │ │ └── StaticCodingAgentDefinitionRegistry.java │ │ ├── delegate/ │ │ │ ├── CodingDelegateRequest.java │ │ │ ├── CodingDelegateResult.java │ │ │ ├── CodingDelegateToolExecutor.java │ │ │ └── CodingDelegateToolRegistry.java │ │ ├── loop/ │ │ │ ├── CodingAgentLoopController.java │ │ │ ├── CodingContinuationPrompt.java │ │ │ ├── CodingLoopDecision.java │ │ │ ├── CodingLoopPolicy.java │ │ │ └── CodingStopReason.java │ │ ├── patch/ │ │ │ ├── ApplyPatchFileChange.java │ │ │ └── ApplyPatchResult.java │ │ ├── policy/ │ │ │ ├── CodingToolContextPolicy.java │ │ │ └── CodingToolPolicyResolver.java │ │ ├── process/ │ │ │ ├── BashProcessInfo.java │ │ │ ├── BashProcessLogChunk.java │ │ │ ├── BashProcessStatus.java │ │ │ ├── SessionProcessRegistry.java │ │ │ └── StoredProcessSnapshot.java │ │ ├── prompt/ │ │ │ └── CodingContextPromptAssembler.java │ │ ├── runtime/ │ │ │ ├── CodingRuntime.java │ │ │ ├── CodingRuntimeListener.java │ │ │ └── DefaultCodingRuntime.java │ │ ├── session/ │ │ │ ├── CodingSessionDescriptor.java │ │ │ ├── CodingSessionLink.java │ │ │ ├── CodingSessionLinkStore.java │ │ │ ├── InMemoryCodingSessionLinkStore.java │ │ │ ├── ManagedCodingSession.java │ │ │ ├── SessionEvent.java │ │ │ └── SessionEventType.java │ │ ├── shell/ │ │ │ ├── LocalShellCommandExecutor.java │ │ │ ├── ShellCommandExecutor.java │ │ │ ├── ShellCommandRequest.java │ │ │ ├── ShellCommandResult.java │ │ │ └── ShellCommandSupport.java │ │ ├── skill/ │ │ │ ├── CodingSkillDescriptor.java │ │ │ └── CodingSkillDiscovery.java │ │ ├── task/ │ │ │ ├── CodingTask.java │ │ │ ├── CodingTaskManager.java │ │ │ ├── CodingTaskProgress.java │ │ │ ├── CodingTaskStatus.java │ │ │ └── InMemoryCodingTaskManager.java │ │ ├── tool/ │ │ │ ├── ApplyPatchToolExecutor.java │ │ │ ├── BashToolExecutor.java │ │ │ ├── CodingToolNames.java │ │ │ ├── CodingToolRegistryFactory.java │ │ │ ├── ReadFileToolExecutor.java │ │ │ ├── RoutingToolExecutor.java │ │ │ ├── ToolExecutorDecorator.java │ │ │ └── WriteFileToolExecutor.java │ │ └── workspace/ │ │ ├── LocalWorkspaceFileService.java │ │ ├── WorkspaceContext.java │ │ ├── WorkspaceEntry.java │ │ ├── WorkspaceFileReadResult.java │ │ ├── WorkspaceFileService.java │ │ └── WorkspaceWriteResult.java │ └── test/ │ └── java/ │ └── io/ │ └── github/ │ └── lnyocly/ │ └── ai4j/ │ └── coding/ │ ├── ApplyPatchToolExecutorTest.java │ ├── BashToolExecutorTest.java │ ├── CodingAgentBuilderTest.java │ ├── CodingRuntimeTest.java │ ├── CodingSessionCheckpointFormatterTest.java │ ├── CodingSessionTest.java │ ├── CodingSkillSupportTest.java │ ├── LocalShellCommandExecutorTest.java │ ├── MinimaxCodingAgentTeamWorkspaceUsageTest.java │ ├── ReadFileToolExecutorTest.java │ ├── WriteFileToolExecutorTest.java │ ├── loop/ │ │ └── CodingAgentLoopControllerTest.java │ └── shell/ │ └── ShellCommandSupportTest.java ├── ai4j-flowgram-demo/ │ ├── README.md │ ├── backend-18080-run.log │ ├── backend-18080.log │ ├── pom.xml │ └── src/ │ └── main/ │ ├── java/ │ │ └── io/ │ │ └── github/ │ │ └── lnyocly/ │ │ └── ai4j/ │ │ └── flowgram/ │ │ └── demo/ │ │ ├── FlowGramDemoApplication.java │ │ └── FlowGramDemoMockController.java │ └── resources/ │ └── application.yml ├── ai4j-flowgram-spring-boot-starter/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/ │ │ │ └── github/ │ │ │ └── lnyocly/ │ │ │ └── ai4j/ │ │ │ └── flowgram/ │ │ │ └── springboot/ │ │ │ ├── adapter/ │ │ │ │ └── FlowGramProtocolAdapter.java │ │ │ ├── autoconfigure/ │ │ │ │ └── FlowGramAutoConfiguration.java │ │ │ ├── config/ │ │ │ │ └── FlowGramProperties.java │ │ │ ├── controller/ │ │ │ │ └── FlowGramTaskController.java │ │ │ ├── dto/ │ │ │ │ ├── FlowGramErrorResponse.java │ │ │ │ ├── FlowGramTaskCancelResponse.java │ │ │ │ ├── FlowGramTaskReportResponse.java │ │ │ │ ├── FlowGramTaskResultResponse.java │ │ │ │ ├── FlowGramTaskRunRequest.java │ │ │ │ ├── FlowGramTaskRunResponse.java │ │ │ │ ├── FlowGramTaskValidateRequest.java │ │ │ │ ├── FlowGramTaskValidateResponse.java │ │ │ │ └── FlowGramTraceView.java │ │ │ ├── exception/ │ │ │ │ ├── FlowGramAccessDeniedException.java │ │ │ │ ├── FlowGramApiException.java │ │ │ │ ├── FlowGramExceptionHandler.java │ │ │ │ └── FlowGramTaskNotFoundException.java │ │ │ ├── node/ │ │ │ │ ├── FlowGramCodeNodeExecutor.java │ │ │ │ ├── FlowGramHttpNodeExecutor.java │ │ │ │ ├── FlowGramKnowledgeRetrieveNodeExecutor.java │ │ │ │ ├── FlowGramNodeValueResolver.java │ │ │ │ ├── FlowGramToolNodeExecutor.java │ │ │ │ └── FlowGramVariableNodeExecutor.java │ │ │ ├── security/ │ │ │ │ ├── DefaultFlowGramAccessChecker.java │ │ │ │ ├── DefaultFlowGramCallerResolver.java │ │ │ │ ├── DefaultFlowGramTaskOwnershipStrategy.java │ │ │ │ ├── FlowGramAccessChecker.java │ │ │ │ ├── FlowGramAction.java │ │ │ │ ├── FlowGramCaller.java │ │ │ │ ├── FlowGramCallerResolver.java │ │ │ │ ├── FlowGramTaskOwnership.java │ │ │ │ └── FlowGramTaskOwnershipStrategy.java │ │ │ └── support/ │ │ │ ├── FlowGramRuntimeFacade.java │ │ │ ├── FlowGramRuntimeTraceCollector.java │ │ │ ├── FlowGramStoredTask.java │ │ │ ├── FlowGramTaskStore.java │ │ │ ├── FlowGramTraceResponseEnricher.java │ │ │ ├── InMemoryFlowGramTaskStore.java │ │ │ ├── JdbcFlowGramTaskStore.java │ │ │ └── RegistryBackedFlowGramModelClientResolver.java │ │ └── resources/ │ │ └── META-INF/ │ │ ├── spring/ │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ └── spring.factories │ └── test/ │ └── java/ │ └── io/ │ └── github/ │ └── lnyocly/ │ └── ai4j/ │ └── flowgram/ │ └── springboot/ │ ├── FlowGramJdbcTaskStoreAutoConfigurationTest.java │ ├── FlowGramRuntimeTraceCollectorTest.java │ ├── FlowGramTaskControllerIntegrationTest.java │ ├── node/ │ │ ├── FlowGramBuiltinNodeExecutorTest.java │ │ └── FlowGramKnowledgeRetrieveNodeExecutorTest.java │ └── support/ │ └── JdbcFlowGramTaskStoreTest.java ├── ai4j-flowgram-webapp-demo/ │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── README.zh_CN.md │ ├── index.html │ ├── package.json │ ├── rsbuild.config.ts │ ├── src/ │ │ ├── app.tsx │ │ ├── assets/ │ │ │ ├── icon-auto-layout.tsx │ │ │ ├── icon-cancel.tsx │ │ │ ├── icon-comment.tsx │ │ │ ├── icon-minimap.tsx │ │ │ ├── icon-mouse.tsx │ │ │ ├── icon-pad.tsx │ │ │ ├── icon-success.tsx │ │ │ ├── icon-switch-line.tsx │ │ │ └── icon-warning.tsx │ │ ├── components/ │ │ │ ├── add-node/ │ │ │ │ ├── index.tsx │ │ │ │ └── use-add-node.ts │ │ │ ├── base-node/ │ │ │ │ ├── index.tsx │ │ │ │ ├── node-wrapper.tsx │ │ │ │ ├── styles.tsx │ │ │ │ └── utils.ts │ │ │ ├── comment/ │ │ │ │ ├── components/ │ │ │ │ │ ├── blank-area.tsx │ │ │ │ │ ├── border-area.tsx │ │ │ │ │ ├── container.tsx │ │ │ │ │ ├── content-drag-area.tsx │ │ │ │ │ ├── drag-area.tsx │ │ │ │ │ ├── editor.tsx │ │ │ │ │ ├── index.css │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── more-button.tsx │ │ │ │ │ ├── render.tsx │ │ │ │ │ └── resize-area.tsx │ │ │ │ ├── constant.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── use-model.ts │ │ │ │ │ ├── use-overflow.ts │ │ │ │ │ ├── use-placeholder.ts │ │ │ │ │ └── use-size.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model.ts │ │ │ │ └── type.ts │ │ │ ├── group/ │ │ │ │ ├── color.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── background.tsx │ │ │ │ │ ├── color.tsx │ │ │ │ │ ├── header.tsx │ │ │ │ │ ├── icon-group.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── node-render.tsx │ │ │ │ │ ├── tips/ │ │ │ │ │ │ ├── global-store.ts │ │ │ │ │ │ ├── icon-close.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── is-mac-os.ts │ │ │ │ │ │ ├── style.ts │ │ │ │ │ │ └── use-control.ts │ │ │ │ │ ├── title.tsx │ │ │ │ │ ├── tools.tsx │ │ │ │ │ └── ungroup.tsx │ │ │ │ ├── constant.ts │ │ │ │ ├── index.css │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── line-add-button/ │ │ │ │ ├── button.tsx │ │ │ │ ├── index.less │ │ │ │ ├── index.tsx │ │ │ │ └── use-visible.ts │ │ │ ├── node-menu/ │ │ │ │ └── index.tsx │ │ │ ├── node-panel/ │ │ │ │ ├── index.less │ │ │ │ ├── index.tsx │ │ │ │ ├── node-list.tsx │ │ │ │ └── node-placeholder.tsx │ │ │ ├── problem-panel/ │ │ │ │ ├── index.ts │ │ │ │ ├── problem-panel.tsx │ │ │ │ └── use-watch-validate.ts │ │ │ ├── selector-box-popover/ │ │ │ │ └── index.tsx │ │ │ ├── sidebar/ │ │ │ │ ├── index.tsx │ │ │ │ ├── node-form-panel.tsx │ │ │ │ └── sidebar-node-renderer.tsx │ │ │ ├── testrun/ │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── use-fields.ts │ │ │ │ │ ├── use-form-meta.ts │ │ │ │ │ └── use-sync-default.ts │ │ │ │ ├── json-value-editor/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── node-status-bar/ │ │ │ │ │ ├── group/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── header/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── render/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── viewer/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── testrun-button/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── testrun-form/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── type.ts │ │ │ │ ├── testrun-json-input/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── testrun-panel/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── test-run-panel.tsx │ │ │ │ └── trace-panel/ │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ └── tools/ │ │ │ ├── auto-layout.tsx │ │ │ ├── comment.tsx │ │ │ ├── download.tsx │ │ │ ├── fit-view.tsx │ │ │ ├── index.tsx │ │ │ ├── interactive.tsx │ │ │ ├── minimap-switch.tsx │ │ │ ├── minimap.tsx │ │ │ ├── mouse-pad-selector.less │ │ │ ├── mouse-pad-selector.tsx │ │ │ ├── readonly.tsx │ │ │ ├── save.tsx │ │ │ ├── styles.tsx │ │ │ ├── switch-line.tsx │ │ │ └── zoom-select.tsx │ │ ├── context/ │ │ │ ├── index.ts │ │ │ ├── node-render-context.ts │ │ │ └── sidebar-context.ts │ │ ├── data/ │ │ │ └── workflow-templates.ts │ │ ├── editor.tsx │ │ ├── form-components/ │ │ │ ├── feedback.tsx │ │ │ ├── form-content/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── form-header/ │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.tsx │ │ │ │ ├── title-input.tsx │ │ │ │ └── utils.tsx │ │ │ ├── form-inputs/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── form-item/ │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ ├── use-editor-props.tsx │ │ │ ├── use-is-sidebar.ts │ │ │ ├── use-node-render-context.ts │ │ │ └── use-port-click.ts │ │ ├── index.ts │ │ ├── initial-data.ts │ │ ├── nodes/ │ │ │ ├── block-end/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── block-start/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── break/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── code/ │ │ │ │ ├── components/ │ │ │ │ │ ├── code.tsx │ │ │ │ │ ├── inputs.tsx │ │ │ │ │ └── outputs.tsx │ │ │ │ ├── form-meta.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── types.tsx │ │ │ ├── comment/ │ │ │ │ └── index.tsx │ │ │ ├── condition/ │ │ │ │ ├── condition-inputs/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── constants.ts │ │ │ ├── continue/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── default-form-meta.tsx │ │ │ ├── end/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── group/ │ │ │ │ └── index.tsx │ │ │ ├── http/ │ │ │ │ ├── components/ │ │ │ │ │ ├── api.tsx │ │ │ │ │ ├── body.tsx │ │ │ │ │ ├── headers.tsx │ │ │ │ │ ├── params.tsx │ │ │ │ │ └── timeout.tsx │ │ │ │ ├── form-meta.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── types.tsx │ │ │ ├── index.ts │ │ │ ├── knowledge/ │ │ │ │ └── index.tsx │ │ │ ├── llm/ │ │ │ │ └── index.ts │ │ │ ├── loop/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── start/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── tool/ │ │ │ │ └── index.tsx │ │ │ └── variable/ │ │ │ ├── form-meta.tsx │ │ │ ├── index.tsx │ │ │ ├── output-schema.ts │ │ │ └── types.tsx │ │ ├── plugins/ │ │ │ ├── context-menu-plugin/ │ │ │ │ ├── context-menu-layer.tsx │ │ │ │ ├── context-menu-plugin.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── panel-manager-plugin/ │ │ │ │ ├── constants.ts │ │ │ │ ├── hooks.ts │ │ │ │ └── index.tsx │ │ │ ├── runtime-plugin/ │ │ │ │ ├── client/ │ │ │ │ │ ├── base-client.ts │ │ │ │ │ ├── browser-client/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── server-client/ │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── create-runtime-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ ├── runtime-service/ │ │ │ │ │ └── index.ts │ │ │ │ ├── trace.ts │ │ │ │ └── type.ts │ │ │ └── variable-panel-plugin/ │ │ │ ├── components/ │ │ │ │ ├── full-variable-list.tsx │ │ │ │ ├── global-variable-editor.tsx │ │ │ │ ├── index.module.less │ │ │ │ └── variable-panel.tsx │ │ │ ├── index.ts │ │ │ ├── variable-panel-layer.tsx │ │ │ └── variable-panel-plugin.ts │ │ ├── services/ │ │ │ ├── custom-service.ts │ │ │ ├── index.ts │ │ │ └── validate-service.ts │ │ ├── shortcuts/ │ │ │ ├── collapse/ │ │ │ │ └── index.ts │ │ │ ├── constants.ts │ │ │ ├── copy/ │ │ │ │ └── index.ts │ │ │ ├── delete/ │ │ │ │ └── index.ts │ │ │ ├── expand/ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── paste/ │ │ │ │ ├── index.ts │ │ │ │ ├── traverse.ts │ │ │ │ └── unique-workflow.ts │ │ │ ├── select-all/ │ │ │ │ └── index.ts │ │ │ ├── shortcuts.ts │ │ │ ├── type.ts │ │ │ ├── zoom-in/ │ │ │ │ └── index.ts │ │ │ └── zoom-out/ │ │ │ └── index.ts │ │ ├── styles/ │ │ │ └── index.css │ │ ├── type.d.ts │ │ ├── typings/ │ │ │ ├── index.ts │ │ │ ├── json-schema.ts │ │ │ └── node.ts │ │ ├── utils/ │ │ │ ├── backend-workflow.ts │ │ │ ├── can-contain-node.ts │ │ │ ├── index.ts │ │ │ ├── on-drag-line-end.ts │ │ │ └── toggle-loop-expanded.ts │ │ └── workbench/ │ │ ├── runtime-hooks.ts │ │ ├── workbench-shell.tsx │ │ ├── workbench-sidebar.tsx │ │ └── workbench-toolbar.tsx │ └── tsconfig.json ├── ai4j-spring-boot-starter/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/ │ │ │ └── github/ │ │ │ └── lnyocly/ │ │ │ └── ai4j/ │ │ │ ├── AgentFlowProperties.java │ │ │ ├── AgentFlowRegistry.java │ │ │ ├── AiConfigAutoConfiguration.java │ │ │ ├── AiConfigProperties.java │ │ │ ├── AiPlatformProperties.java │ │ │ ├── BaichuanConfigProperties.java │ │ │ ├── DashScopeConfigProperties.java │ │ │ ├── DeepSeekConfigProperties.java │ │ │ ├── DoubaoConfigProperties.java │ │ │ ├── HunyuanConfigProperties.java │ │ │ ├── JinaConfigProperties.java │ │ │ ├── LingyiConfigProperties.java │ │ │ ├── MilvusConfigProperties.java │ │ │ ├── MinimaxConfigProperties.java │ │ │ ├── MoonshotConfigProperties.java │ │ │ ├── OkHttpConfigProperties.java │ │ │ ├── OllamaConfigProperties.java │ │ │ ├── OpenAiConfigProperties.java │ │ │ ├── PgVectorConfigProperties.java │ │ │ ├── PineconeConfigProperties.java │ │ │ ├── QdrantConfigProperties.java │ │ │ ├── SearXNGConfigProperties.java │ │ │ └── ZhipuConfigProperties.java │ │ └── resources/ │ │ └── META-INF/ │ │ ├── spring/ │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ └── spring.factories │ └── test/ │ └── java/ │ └── io/ │ └── github/ │ └── lnyocly/ │ └── ai4j/ │ └── AgentFlowAutoConfigurationTest.java ├── docs-site/ │ ├── .gitignore │ ├── README.md │ ├── docusaurus.config.ts │ ├── i18n/ │ │ └── zh-Hans/ │ │ ├── code.json │ │ ├── docusaurus-plugin-content-docs/ │ │ │ ├── current/ │ │ │ │ ├── agent/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ ├── agent-teams.md │ │ │ │ │ ├── codeact-custom-sandbox.md │ │ │ │ │ ├── codeact-runtime.md │ │ │ │ │ ├── coding-agent-cli.md │ │ │ │ │ ├── coding-agent-command-reference.md │ │ │ │ │ ├── custom-agent-development.md │ │ │ │ │ ├── memory-management.md │ │ │ │ │ ├── model-client-selection.md │ │ │ │ │ ├── multi-provider-profiles.md │ │ │ │ │ ├── overview.md │ │ │ │ │ ├── provider-config-examples.md │ │ │ │ │ ├── reference-core-classes.md │ │ │ │ │ ├── runtime-implementations.md │ │ │ │ │ ├── subagent-handoff-policy.md │ │ │ │ │ ├── system-prompt-vs-instructions.md │ │ │ │ │ ├── trace-observability.md │ │ │ │ │ ├── weather-workflow-cookbook.md │ │ │ │ │ └── workflow-stategraph.md │ │ │ │ ├── ai-basics/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── _category_.json │ │ │ │ │ │ ├── multimodal.md │ │ │ │ │ │ ├── non-stream.md │ │ │ │ │ │ ├── stream.md │ │ │ │ │ │ └── tool-calling.md │ │ │ │ │ ├── enhancements/ │ │ │ │ │ │ ├── _category_.json │ │ │ │ │ │ ├── pinecone-rag-workflow.md │ │ │ │ │ │ ├── searxng-enhancement.md │ │ │ │ │ │ └── spi-http-stack.md │ │ │ │ │ ├── overview.md │ │ │ │ │ ├── platform-adaptation.md │ │ │ │ │ ├── responses/ │ │ │ │ │ │ ├── _category_.json │ │ │ │ │ │ ├── chat-vs-responses.md │ │ │ │ │ │ ├── non-stream.md │ │ │ │ │ │ └── stream-events.md │ │ │ │ │ └── services/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ ├── audio.md │ │ │ │ │ ├── embedding.md │ │ │ │ │ ├── image-generation.md │ │ │ │ │ └── realtime.md │ │ │ │ ├── core-sdk/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ ├── agentflow-protocol-mapping.md │ │ │ │ │ ├── agentflow.md │ │ │ │ │ ├── audio.md │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── multimodal.md │ │ │ │ │ │ ├── non-stream.md │ │ │ │ │ │ ├── stream.md │ │ │ │ │ │ └── tool-calling.md │ │ │ │ │ ├── embedding.md │ │ │ │ │ ├── image-generation.md │ │ │ │ │ ├── overview.md │ │ │ │ │ ├── pinecone-rag-workflow.md │ │ │ │ │ ├── platform-service-matrix.md │ │ │ │ │ ├── realtime.md │ │ │ │ │ ├── responses/ │ │ │ │ │ │ ├── chat-vs-responses.md │ │ │ │ │ │ ├── non-stream.md │ │ │ │ │ │ └── stream-events.md │ │ │ │ │ ├── searxng-enhancement.md │ │ │ │ │ └── spi-http-stack.md │ │ │ │ ├── deploy/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ └── cloudflare-pages.md │ │ │ │ ├── getting-started/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ ├── chat-and-responses-guide.md │ │ │ │ │ ├── coding-agent-cli-quickstart.md │ │ │ │ │ ├── installation.md │ │ │ │ │ ├── multimodal-and-function-call.md │ │ │ │ │ ├── platforms-and-service-matrix.md │ │ │ │ │ ├── quickstart-ollama.md │ │ │ │ │ ├── quickstart-openai-jdk8.md │ │ │ │ │ ├── quickstart-springboot.md │ │ │ │ │ └── troubleshooting.md │ │ │ │ ├── guides/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ ├── blog-migration-map.md │ │ │ │ │ ├── deepseek-stream-search-rag.md │ │ │ │ │ ├── pinecone-vector-workflow.md │ │ │ │ │ ├── rag-legal-assistant.md │ │ │ │ │ ├── searxng-web-search.md │ │ │ │ │ └── spi-dispatcher-connectionpool.md │ │ │ │ ├── intro.md │ │ │ │ └── mcp/ │ │ │ │ ├── _category_.json │ │ │ │ ├── build-your-mcp-server.md │ │ │ │ ├── client-integration.md │ │ │ │ ├── gateway-management.md │ │ │ │ ├── mcp-agent-end-to-end.md │ │ │ │ ├── mysql-dynamic-datasource.md │ │ │ │ ├── overview.md │ │ │ │ ├── third-party-mcp-integration.md │ │ │ │ ├── tool-exposure-semantics.md │ │ │ │ └── transport-types.md │ │ │ └── current.json │ │ └── docusaurus-theme-classic/ │ │ ├── footer.json │ │ └── navbar.json │ ├── package.json │ ├── scripts/ │ │ └── generate_agent_teams_api_docs.py │ ├── sidebars.ts │ ├── src/ │ │ ├── components/ │ │ │ └── HomepageFeatures/ │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── css/ │ │ │ └── custom.css │ │ ├── pages/ │ │ │ ├── index.module.css │ │ │ └── index.tsx │ │ └── theme/ │ │ └── NotFound/ │ │ └── Content/ │ │ └── index.tsx │ ├── static/ │ │ ├── .nojekyll │ │ ├── install.ps1 │ │ └── install.sh │ └── tsconfig.json └── pom.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 [*.md] trim_trailing_whitespace = false ================================================ FILE: .github/workflows/docs-build.yml ================================================ name: docs-build on: pull_request: paths: - 'docs-site/**' - '.github/workflows/docs-build.yml' push: branches: [main, dev] paths: - 'docs-site/**' - '.github/workflows/docs-build.yml' jobs: build: runs-on: ubuntu-latest defaults: run: working-directory: docs-site steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: docs-site/package-lock.json - name: Install dependencies run: npm ci - name: Build docs run: npm run build ================================================ FILE: .github/workflows/docs-pages.yml ================================================ name: docs-pages on: push: branches: [main] paths: - 'docs-site/**' - '.github/workflows/docs-pages.yml' workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: docs-pages cancel-in-progress: true jobs: build: runs-on: ubuntu-latest defaults: run: working-directory: docs-site steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: docs-site/package-lock.json - name: Configure Pages uses: actions/configure-pages@v5 - name: Install dependencies run: npm ci - name: Build docs env: GITHUB_PAGES: 'true' run: npm run build - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: docs-site/build deploy: runs-on: ubuntu-latest needs: build environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ ### IntelliJ IDEA ### .idea/modules.xml .idea/jarRepositories.xml .idea/compiler.xml .idea/libraries/ *.iws *.iml *.ipr ### Eclipse ### .apt_generated .classpath .factorypath .project .settings .springBeans .sts4-cache ### NetBeans ### /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ build/ !**/src/main/**/build/ !**/src/test/**/build/ ### VS Code ### .vscode/ ### Local tooling ### .claude/ AGENTS.md node_modules/ dist/ .rsbuild/ .turbo/ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* ### Scratch / temp ### *.class .tmp*/ .tmp* tmp/ tmp-*/ tmp-*.png tmp-*.txt tmp-*.log **/.flattened-pom.xml ### Mac OS ### .DS_Store /.idea/ /ai4j/.gitignore /ai4j/src/main/resources/新建文本文档 (2).txt /ai4j-spring-boot-stater/.gitignore .ai4j/ .docs/ docs/ APIResponse.md AGENT.md ai4j-release-package.log javadoc-ai4j.log ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README-EN.md ================================================

ai4j banner

Maven Central Docs License JDK 8+ Agentic Enabled MCP Supported RAG Built-in CLI TUI ACP Built-in

# ai4j A Java AI Agentic development toolkit for JDK 8+, combining foundational AI capabilities with higher-level agent development capabilities. It covers multi-provider model access, unified I/O, Tool Calling, MCP, RAG, unified `VectorStore`, ChatMemory, agent runtime, coding agent, CLI / TUI / ACP, FlowGram integration, and integration with published AgentFlow endpoints such as Dify, Coze, and n8n, helping Java applications grow from basic model integration to more complete agentic application development. This repository has evolved into a multi-module SDK. In addition to the core `ai4j` module, it now provides `ai4j-agent`, `ai4j-coding`, `ai4j-cli`, `ai4j-spring-boot-starter`, `ai4j-flowgram-spring-boot-starter`, and `ai4j-bom`. If you only need the basic LLM integration layer, start with `ai4j`. If you need agent runtime, coding agent, CLI / ACP, Spring Boot, or FlowGram integration, add the corresponding modules. ## Positioning Compared with Common Java AI Options | Option | Java baseline | Application style | Primary focus | | --- | --- | --- | --- | | `ai4j` | `JDK 8+` | Plain Java / Spring | Unified model access, Tool / MCP / RAG, agent runtime, coding agent, CLI / TUI / ACP | | `Spring AI` | `Java 17+` | `Spring Boot 3.x` | Spring-native AI integration, model access, Tool Calling, MCP, and RAG | | `Spring AI Alibaba` | `Java 17+` | `Spring Boot 3.x` | Spring and Alibaba Cloud AI ecosystem integration | | `LangChain4j` | `Java 17+` | Plain Java / Spring / Quarkus and more | General Java abstractions for LLM, agent, and RAG integration, plus AI Services | ## Supported platforms + OpenAi + Jina (Rerank / Jina-compatible Rerank) + Zhipu + DeepSeek + Moonshot + Tencent Hunyuan + Lingyi AI + Ollama + MiniMax + Baichuan ## Supported services + Chat Completions(streaming and non-streaming) + Responses + Embedding + Rerank + Audio + Image + Realtime ## Supported AgentFlow / hosted workflow platforms + Dify (chat / workflow) + Coze (chat / workflow) + n8n (webhook workflow) ## Features + Supports Spring and ordinary Java applications. Supports applications above Java 8. + Multi-platform and multi-service. + Provides `AgentFlow` support for integrating published Agent / Workflow endpoints from Dify, Coze, and n8n. + Provides `ai4j-agent` as the general agent runtime, with ReAct, subagents, agent teams, memory, tracing, and tool loop support. + Built-in Coding Agent CLI / TUI with interactive repository sessions, provider profiles, workspace model override, and session/process management. + Provides `ai4j-coding` as the coding agent runtime, with workspace-aware tools, outer loop, checkpoint compaction, subagent, and team collaboration support. + Provides `ai4j-flowgram-spring-boot-starter` for integrating FlowGram workflows and trace in Spring Boot applications. + Provides `ai4j-bom` for version alignment across multiple ai4j modules. + Unified input and output. + Unified error handling. + Supports streaming output. Supports streaming output of function call parameters. + Easily use Tool Calls. + Supports simultaneous calls of multiple functions (Zhipu does not support this). + Supports stream_options, and directly obtains statistical token usage through streaming output. + Supports RAG. Built-in vector database support: Pinecone. + Uses Tika to read files. + Token statistics`TikTokensUtil.java` ## Tutorial documents + [Quick access to Spring Boot, access to streaming and non-streaming and function calls.](http://t.csdnimg.cn/iuIAW) + [Quick access to open source large models such as qwen2.5 and llama3.1 on the Ollama platform in Java.](https://blog.csdn.net/qq_35650513/article/details/142408092?spm=1001.2014.3001.5501) + [Build a legal AI assistant in Java and quickly implement RAG applications.](https://blog.csdn.net/qq_35650513/article/details/142568177?fromshare=blogdetail&sharetype=blogdetail&sharerId=142568177&sharerefer=PC&sharesource=qq_35650513&sharefrom=from_link) ## Coding Agent CLI / TUI AI4J now includes `ai4j-cli`, which can be used directly as a local coding agent. Current capabilities include: + one-shot and persistent sessions + CLI and TUI interaction modes + provider profile persistence + workspace-level model override + subagent and agent team collaboration + session persistence, resume, fork, history, tree, events, replay + team board, team messages, and team resume for collaboration visibility + process management and buffered logs ### Install ```bash curl -fsSL https://lnyo-cly.github.io/ai4j/install.sh | sh ``` ```powershell irm https://lnyo-cly.github.io/ai4j/install.ps1 | iex ``` The installer downloads `ai4j-cli` from Maven Central and creates the `ai4j` command. Java 8+ must already be installed on the machine. ### one-shot example ```powershell ai4j code ` --provider openai ` --protocol responses ` --model gpt-5-mini ` --prompt "Read README and summarize the project structure" ``` ### interactive CLI example ```powershell ai4j code ` --provider zhipu ` --protocol chat ` --model glm-4.7 ` --base-url https://open.bigmodel.cn/api/coding/paas/v4 ` --workspace . ``` ### TUI example ```powershell ai4j tui ` --provider zhipu ` --protocol chat ` --model glm-4.7 ` --base-url https://open.bigmodel.cn/api/coding/paas/v4 ` --workspace . ``` ### ACP example ```powershell ai4j acp ` --provider openai ` --protocol responses ` --model gpt-5-mini ` --workspace . ``` ### Build from source (optional) ```powershell mvn -pl ai4j-cli -am -DskipTests package ``` Artifact: ```text ai4j-cli/target/ai4j-cli--jar-with-dependencies.jar ``` If you want to run the locally built artifact directly: ```powershell java -jar .\ai4j-cli\target\ai4j-cli--jar-with-dependencies.jar code --help ``` ### Current protocol rules The CLI currently exposes only two protocol families: + `chat` + `responses` If `--protocol` is omitted, the CLI resolves a default locally from provider/baseUrl: + `openai` + official OpenAI host -> `responses` + `openai` + custom compatible `baseUrl` -> `chat` + `doubao` / `dashscope` -> `responses` + other providers -> `chat` Notes: + `auto` is no longer exposed to users + legacy `auto` values in existing config files are normalized to explicit protocols on load ### provider profile locations + global config: `~/.ai4j/providers.json` + workspace config: `/.ai4j/workspace.json` Recommended workflow: + keep reusable long-term runtime profiles in the global config + let each workspace reference one `activeProfile` + use workspace `modelOverride` for temporary model switching ### Common commands + `/providers` + `/provider` + `/provider use ` + `/provider save ` + `/provider add --provider [--protocol ] [--model ] [--base-url ] [--api-key ]` + `/provider edit [--provider ] [--protocol ] [--model |--clear-model] [--base-url |--clear-base-url] [--api-key |--clear-api-key]` + `/provider default ` + `/provider remove ` + `/model` + `/model ` + `/model reset` + `/stream [on|off]` + `/processes` + `/process status|follow|logs|write|stop ...` + `/resume ` / `/load ` / `/fork ...` ### Documentation entry points + [Coding Agent CLI Quickstart](docs-site/docs/getting-started/coding-agent-cli-quickstart.md) + [Coding Agent CLI and TUI](docs-site/docs/agent/coding-agent-cli.md) + [Multi-Provider Profiles](docs-site/docs/agent/multi-provider-profiles.md) + [Coding Agent Command Reference](docs-site/docs/agent/coding-agent-command-reference.md) + [Provider Configuration Examples](docs-site/docs/agent/provider-config-examples.md) ## Other support + [[Low-cost transit platform] Low-cost ApiKey - Limited-time special offer 0.7:1 - Supports the latest o1 model.](https://api.trovebox.online/) # Quick start ## Import ### Module selection + Use `ai4j` for the core LLM / Tool Call / MCP / RAG capabilities + Use `ai4j-agent` for the general agent runtime + Use `ai4j-coding` for coding agent, workspace tools, and outer loop + Use `ai4j-cli` for the local CLI / TUI / ACP host + Use `ai4j-spring-boot-starter` for Spring Boot auto-configuration + Use `ai4j-flowgram-spring-boot-starter` for FlowGram workflow integration + Use `ai4j-bom` when you want version alignment across multiple modules ### Gradle ```groovy implementation platform("io.github.lnyo-cly:ai4j-bom:${project.version}") implementation "io.github.lnyo-cly:ai4j" implementation "io.github.lnyo-cly:ai4j-agent" ``` ```groovy implementation group: 'io.github.lnyo-cly', name: 'ai4j', version: '${project.version}' ``` ```groovy implementation group: 'io.github.lnyo-cly', name: 'ai4j-spring-boot-starter', version: '${project.version}' ``` ### Maven ```xml io.github.lnyo-cly ai4j-bom ${project.version} pom import ``` ```xml io.github.lnyo-cly ai4j-agent io.github.lnyo-cly ai4j-coding ``` ```xml io.github.lnyo-cly ai4j ${project.version} ``` ```xml io.github.lnyo-cly ai4j-spring-boot-starter ${project.version} ``` ## Obtain AI service instance ### Obtaining without Spring ```java public void test_init(){ OpenAiConfig openAiConfig = new OpenAiConfig(); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1",10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); chatService = aiService.getChatService(PlatformType.getPlatform("OPENAI")); } ``` ### Obtaining with Spring ```yml # Domestic access usually requires a proxy by default. ai: openai: api-key: "api-key" okhttp: proxy-port: 10809 proxy-url: "127.0.0.1" zhipu: api-key: "xxx" #other... ``` ```java // Inject Ai service @Autowired private AiService aiService; // Obtain the required service instance IChatService chatService = aiService.getChatService(PlatformType.OPENAI); IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); // ...... ``` ## Chat service ### Synchronous request call ```java public void test_chat() throws Exception { // Obtain chat service instance IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // Build request parameters ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); // Send dialogue request ChatCompletionResponse response = chatService.chatCompletion(chatCompletion); System.out.println(response); } ``` ### Streaming call ```java public void test_chat_stream() throws Exception { // Obtain chat service instance IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // Construct request parameters ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("查询北京明天的天气")) .functions("queryWeather") .build(); // Construct listener SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // Display function parameters. Default is not to display. sseListener.setShowToolArgs(true); // Send SSE request chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println(sseListener.getOutput()); } ``` ### Image recognition ```java public void test_chat_image() throws Exception { // Obtain chat service instance IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // Build request parameters ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("图片中有什么东西", "https://cn.bing.com/images/search?view=detailV2&ccid=r0OnuYkv&id=9A07DE578F6ED50DB59DFEA5C675AC71845A6FC9&thid=OIP.r0OnuYkvsbqBrYk3kUT53AHaKX&mediaurl=https%3a%2f%2fimg.zcool.cn%2fcommunity%2f0104c15cd45b49a80121416816f1ec.jpg%401280w_1l_2o_100sh.jpg&exph=1792&expw=1280&q=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87&simid=607987191780608963&FORM=IRPRST&ck=12127C1696CF374CB9D0F09AE99AFE69&selectedIndex=2&itb=0&qpvt=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87")) .build(); // Send dialogue request ChatCompletionResponse response = chatService.chatCompletion(chatCompletion); System.out.println(response); } ``` ### Function call ```java public void test_chat_tool_call() throws Exception { // Obtain chat service instance IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // Build request parameters ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("今天北京天气怎么样")) .functions("queryWeather") .build(); // Send dialogue request ChatCompletionResponse response = chatService.chatCompletion(chatCompletion); System.out.println(response); } ``` #### Define function ```java @FunctionCall(name = "queryWeather", description = "查询目标地点的天气预报") public class QueryWeatherFunction implements Function { @Data @FunctionRequest public static class Request{ @FunctionParameter(description = "需要查询天气的目标位置, 可以是城市中文名、城市拼音/英文名、省市名称组合、IP 地址、经纬度") private String location; @FunctionParameter(description = "需要查询未来天气的天数, 最多15日") private int days = 15; @FunctionParameter(description = "预报的天气类型,daily表示预报多天天气、hourly表示预测当天24天气、now为当前天气实况") private Type type; } public enum Type{ daily, hourly, now } @Override public String apply(Request request) { final String key = ""; String url = String.format("https://api.seniverse.com/v3/weather/%s.json?key=%s&location=%s&days=%d", request.type.name(), key, request.location, request.days); OkHttpClient client = new OkHttpClient(); okhttp3.Request http = new okhttp3.Request.Builder() .url(url) .get() .build(); try (Response response = client.newCall(http).execute()) { if (response.isSuccessful()) { // 解析响应体 return response.body() != null ? response.body().string() : ""; } else { return "获取天气失败 当前天气未知"; } } catch (Exception e) { // 处理异常 e.printStackTrace(); return "获取天气失败 当前天气未知"; } } } ``` ## Embedding service ```java public void test_embed() throws Exception { // Obtain embedding service instance IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); // Build request parameters Embedding embeddingReq = Embedding.builder().input("1+1").build(); // Send embedding request EmbeddingResponse embeddingResp = embeddingService.embedding(embeddingReq); System.out.println(embeddingResp); } ``` ## RAG ### Configure vector database ```yml ai: vector: pinecone: url: "" key: "" ``` ### Obtain instance ```java @Autowired private PineconeService pineconeService; ``` ### Insert into vector database ```java public void test_insert_vector_store() throws Exception { // Obtain embedding service instance IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); // Read file content using Tika String fileContent = TikaUtil.parseFile(new File("D:\\data\\test\\test.txt")); // Split text content RecursiveCharacterTextSplitter recursiveCharacterTextSplitter = new RecursiveCharacterTextSplitter(1000, 200); List contentList = recursiveCharacterTextSplitter.splitText(fileContent); // Convert to vector Embedding build = Embedding.builder() .input(contentList) .model("text-embedding-3-small") .build(); EmbeddingResponse embedding = embeddingService.embedding(build); List> vectors = embedding.getData().stream().map(EmbeddingObject::getEmbedding).collect(Collectors.toList()); VertorDataEntity vertorDataEntity = new VertorDataEntity(); vertorDataEntity.setVector(vectors); vertorDataEntity.setContent(contentList); // Vector storage Integer count = pineconeService.insert(vertorDataEntity, "userId"); } ``` ### Query from vector database ```java public void test_query_vector_store() throws Exception { // // Obtain embedding service instance IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); // Build the question to be queried and convert it to a vector Embedding build = Embedding.builder() .input("question") .model("text-embedding-3-small") .build(); EmbeddingResponse embedding = embeddingService.embedding(build); List question = embedding.getData().get(0).getEmbedding(); // Build the query object for the vector database PineconeQuery pineconeQueryReq = PineconeQuery.builder() .namespace("userId") .vector(question) .build(); String result = pineconeService.query(pineconeQueryReq, " "); // Carry the result and have a conversation with the chat service. // ...... } ``` ### Delete data from vector database ```java public void test_delete_vector_store() throws Exception { // Build parameters PineconeDelete pineconeDelete = PineconeDelete.builder() .deleteAll(true) .namespace("userId") .build(); // Delete Boolean res = pineconeService.delete(pineconeDelete); } ``` # Contribute to ai4j You are welcome to provide suggestions, report issues, or contribute code to ai4j. You can contribute to ai4j in the following ways: ## Issue feedback Please use the GitHub Issue page to report issues. Describe as specifically as possible how to reproduce your issue, including detailed information such as the operating system, Java version, and any relevant log traces. ## PR 1. Fork this repository and create your branch. 2. Write your code and test it. 3. Ensure that your code conforms to the existing style. 4. Write clear log information when submitting. For small changes, a single line of information is sufficient, but for larger changes, there should be a detailed description. 5. Complete the pull request form and ensure that changes are made on the `dev` branch and link to the issue that your PR addresses. # Support If you find this project helpful to you, please give it a star⭐。 ================================================ FILE: README.md ================================================

ai4j banner

Maven Central Docs License JDK 8+ Agentic Enabled MCP Supported RAG Built-in CLI TUI ACP Built-in

# ai4j 一款面向 JDK8+ 的 Java AI Agentic 开发套件,既提供统一的大模型调用与常用 AI 基座能力,也提供更完善的智能体式 Agent 开发能力。 覆盖多平台模型接入、统一输入输出、Tool Call、MCP、RAG、统一 `VectorStore`、ChatMemory、Agent Runtime、Coding Agent、CLI / TUI / ACP、FlowGram 集成,以及 Dify / Coze / n8n 等已发布 AgentFlow 端点接入能力,帮助 Java 应用从基础模型接入扩展到更完整的 agentic 应用开发。 当前仓库已经演进为多模块 SDK,除核心 `ai4j` 外,还提供 `ai4j-agent`、`ai4j-coding`、`ai4j-cli`、`ai4j-spring-boot-starter`、`ai4j-flowgram-spring-boot-starter`、`ai4j-bom`。如果只需要基础大模型调用,优先引入 `ai4j`;如果需要 Agent、Coding Agent、CLI / ACP、Spring Boot 或 FlowGram 集成,再按模块引入对应能力。 ## 适用场景与常见方案对比 | 方案 | Java 基线 | 应用形态 | 能力侧重点 | | --- | --- | --- | --- | | `ai4j` | `JDK8+` | 普通 Java / Spring | 统一大模型接入、Tool / MCP / RAG、Agent Runtime、Coding Agent、CLI / TUI / ACP | | `Spring AI` | `Java 17+` | `Spring Boot 3.x` | Spring 原生 AI 集成、模型访问、Tool Calling、MCP、RAG | | `Spring AI Alibaba` | `Java 17+` | `Spring Boot 3.x` | Spring 与阿里云 AI 生态整合 | | `LangChain4j` | `Java 17+` | 普通 Java / Spring / Quarkus 等 | 通用 Java LLM / Agent / RAG 抽象、AI Services、多框架集成 | ## 支持的平台 + OpenAi(包含与OpenAi请求格式相同/兼容的平台) + Jina(Rerank / Jina-compatible Rerank) + Zhipu(智谱) + DeepSeek(深度求索) + Moonshot(月之暗面) + Hunyuan(腾讯混元) + Lingyi(零一万物) + Ollama + MiniMax + Baichuan ## 支持的服务 + Chat Completions(流式与非流式) + Responses + Embedding + Rerank + Audio + Image + Realtime ## 已适配的 AgentFlow / 工作流平台 + Dify(Chat / Workflow) + Coze(Chat / Workflow) + n8n(Webhook Workflow) ## 特性 + 支持MCP服务,内置MCP网关,支持建立动态MCP数据源。 + 支持Spring以及普通Java应用、支持Java 8以上的应用 + 多平台、多服务 + 提供 `AgentFlow` 能力,可直接接入 Dify、Coze、n8n 等已发布 Agent / Workflow 端点 + 提供 `ai4j-agent` 通用 Agent 运行时,支持 ReAct、subagent、agent teams、memory、trace 与 tool loop + 内置 Coding Agent CLI / TUI,支持本地代码仓交互式会话、provider profile、workspace model override、session/process 管理 + 提供 `ai4j-coding` Coding Agent 运行时,支持 workspace tools、outer loop、checkpoint compaction、subagent 与 team 协作 + 提供 `ai4j-flowgram-spring-boot-starter`,便于在 Spring Boot 中接入 FlowGram 工作流与 trace + 提供 `ai4j-bom`,便于多模块项目统一版本管理 + 统一的输入输出 + 统一的错误处理 + 支持SPI机制,可自定义Dispatcher和ConnectPool + 支持服务增强,例如增加websearch服务 + 支持流式输出。支持函数调用参数流式输出. + 简洁的多模态调用方式,例如vision识图 + 轻松使用Tool Calls + 支持多个函数同时调用(智谱不支持) + 支持stream_options,流式输出直接获取统计token usage + 内置 `ChatMemory`,支持基础多轮会话上下文维护,可同时适配 Chat / Responses + 支持RAG,内置统一 `VectorStore` 抽象,当前支持: Pinecone、Qdrant、pgvector、Milvus + 内置 `IngestionPipeline`,统一串联 `DocumentLoader -> Chunker -> MetadataEnricher -> Embedding -> VectorStore.upsert` + 内置 `DenseRetriever`、`Bm25Retriever`、`HybridRetriever`,可按语义检索、关键词检索、混合检索方式组合知识库召回 + `HybridRetriever` 支持 `RrfFusionStrategy`、`RsfFusionStrategy`、`DbsfFusionStrategy`,默认使用 RRF;融合排序与 `Reranker` 语义精排解耦 + 支持统一 `IRerankService`,当前可接 Jina / Jina-compatible、Ollama、Doubao(方舟知识库重排);可通过 `ModelReranker` 无缝接入 RAG 精排 + RAG 运行时可直接拿到 `rank/retrieverSource/retrievalScore/fusionScore/rerankScore/scoreDetails/trace`,并可通过 `RagEvaluator` 计算 `Precision@K/Recall@K/F1@K/MRR/NDCG` + 使用Tika读取文件 + Token统计`TikTokensUtil.java` ## 官方文档站 + 在线文档站:`https://lnyo-cly.github.io/ai4j/` + 文档站源码位于 `docs-site/` + 适合直接使用者的入口:`docs-site/docs/coding-agent/` + 适合 SDK 接入的入口:`docs-site/docs/getting-started/` 与 `docs-site/docs/ai-basics/` + 适合协议与扩展集成的入口:`docs-site/docs/mcp/`、`docs-site/docs/agent/` 推荐阅读顺序: + `docs-site/docs/intro.md` + `docs-site/docs/getting-started/installation.md` + `docs-site/docs/coding-agent/overview.md` + `docs-site/docs/ai-basics/overview.md` + `docs-site/docs/mcp/overview.md` 基础会话上下文新增入口: + `docs-site/docs/ai-basics/chat/chat-memory.md` + `docs-site/docs/ai-basics/services/rerank.md` + `docs-site/docs/ai-basics/rag/ingestion-pipeline.md` 本地运行文档站: ```powershell cd .\docs-site npm install npm run start ``` ```powershell cd .\docs-site npm run build ``` ## 更新日志 + [2026-03-28] 修复 Coding Agent ACP 流式场景下纯空白 chunk 被 runtime 过滤的问题;ACP 保持透传原始 delta,不做 chunk 聚合;补充 CLI/文档中的流式语义说明 + [2026-03-26] 新增 Coding Agent CLI / TUI 文档与能力说明,覆盖交互式会话、provider profile、workspace model override、命令参考与配置样例 + [2025-08-19] 修复传递有验证参数的sse-url时,key丢失问题 + [2025-08-08] OpenAi: max_tokens字段现已废弃,推荐使用max_completion_tokens(GPT-5已经不支持max_tokens字段) + [2025-08-08] 支持MCP协议,支持STDIO,SSE,Streamable HTTP; 支持MCP Server与MCP Client; 支持MCP网关; 支持自定义MCP数据源; 支持MCP自动重连 + [2025-06-23] 修复ollama的流式错误;修复ollama函数调用的错误;修复moonshot请求时错误;修复ollama embedding错误;修复思考无内容;修复日志冲突;新增自定义异常方法。 + [2025-02-28] 新增对Ollama平台的embedding接口的支持。 + [2025-02-17] 新增对DeepSeek平台推理模型的适配。 + [2025-02-12] 为Ollama平台添加Authorization + [2025-02-11] 实现自定义的Jackson序列化,解决OpenAi已经无法通过Json String来直接实现多模态接口的问题。 + [2024-12-12] 使用装饰器模式增强Chat服务,支持SearXNG网络搜索增强,无需模型支持内置搜索以及function_call。 + [2024-10-17] 支持SPI机制,可自定义Dispatcher和ConnectPool。新增百川Baichuan平台Chat接口支持。 + [2024-10-16] 增加MiniMax平台Chat接口对接 + [2024-10-15] 增加realtime服务 + [2024-10-12] 修复早期遗忘的小bug; 修复错误拦截器导致的音频字节流异常错误问题; 增加OpenAi Audio服务。 + [2024-10-10] 增强对SSE输出的获取,新加入`currData`属性,记录当前消息的整个对象。而原先的`currStr`为当前消息的content内容,保留不变。 + [2024-09-26] 修复有关Pinecone向量数据库的一些问题。发布0.6.3版本 + [2024-09-20] 增加对Ollama平台的支持,并修复一些bug。发布0.6.2版本 + [2024-09-19] 增加错误处理链,统一处理为openai错误类型; 修复部分情况下URL拼接问题,修复拦截器中response重复调用而导致的关闭问题。发布0.5.3版本 + [2024-09-12] 修复上个问题OpenAi参数导致错误的遗漏,发布0.5.2版本 + [2024-09-12] 修复SpringBoot 2.6以下导致OkHttp变为3.14版本的报错问题;修复OpenAi参数`parallel_tool_calls`在tools为null时的异常问题。发布0.5.1版本。 + [2024-09-09] 新增零一万物大模型支持、发布0.5.0版本。 + [2024-09-02] 新增腾讯混元Hunyuan平台支持(注意:所需apiKey 属于SecretId与SecretKey的拼接,格式为 {SecretId}.{SecretKey}),发布0.4.0版本。 + [2024-08-30] 新增对Moonshot(Kimi)平台的支持,增加`OkHttpUtil.java`实现忽略SSL证书的校验。 + [2024-08-29] 新增对DeepSeek平台的支持、新增stream_options可以直接统计usage、新增错误拦截器`ErrorInterceptor.java`、发布0.3.0版本。 + [2024-08-29] 修改SseListener以兼容智谱函数调用。 + [2024-08-28] 添加token统计、添加智谱AI的Chat服务、优化函数调用可以支持多轮多函数。 + [2024-08-17] 增强SseListener监听器功能。发布0.2.0版本。 ## 教程文档 + [快速接入SpringBoot、接入流式与非流式以及函数调用](http://t.csdnimg.cn/iuIAW) + [Java快速接入qwen2.5、llama3.1等Ollama平台开源大模型](https://blog.csdn.net/qq_35650513/article/details/142408092?spm=1001.2014.3001.5501) + [Java搭建法律AI助手,快速实现RAG应用](https://blog.csdn.net/qq_35650513/article/details/142568177?fromshare=blogdetail&sharetype=blogdetail&sharerId=142568177&sharerefer=PC&sharesource=qq_35650513&sharefrom=from_link) + [大模型不支持联网搜索?为Deepseek、Qwen、llama等本地模型添加网络搜索](https://blog.csdn.net/qq_35650513/article/details/144572824) + [java快速接入mcp以及结合mysql动态管理](https://blog.csdn.net/qq_35650513/article/details/150532784?fromshare=blogdetail&sharetype=blogdetail&sharerId=150532784&sharerefer=PC&sharesource=qq_35650513&sharefrom=from_link) ## Coding Agent CLI / TUI AI4J 目前已经内置 `ai4j-cli`,可以直接作为本地 coding agent 使用,支持: + one-shot 与持续会话 + CLI / TUI 两种交互模式 + provider profile 持久化 + workspace 级 model override + subagent 与 agent teams 协作 + session 持久化、resume、fork、history、tree、events、replay + team board、team messages、team resume 等协作观测能力 + process 管理与日志查看 ### 安装 ```bash curl -fsSL https://lnyo-cly.github.io/ai4j/install.sh | sh ``` ```powershell irm https://lnyo-cly.github.io/ai4j/install.ps1 | iex ``` 安装脚本会从 Maven Central 下载 `ai4j-cli` 并生成 `ai4j` 命令,前提是本机已经安装 Java 8+。 ### one-shot 示例 ```powershell ai4j code ` --provider openai ` --protocol responses ` --model gpt-5-mini ` --prompt "Read README and summarize the project structure" ``` ### 交互式 CLI 示例 ```powershell ai4j code ` --provider zhipu ` --protocol chat ` --model glm-4.7 ` --base-url https://open.bigmodel.cn/api/coding/paas/v4 ` --workspace . ``` ### TUI 示例 ```powershell ai4j tui ` --provider zhipu ` --protocol chat ` --model glm-4.7 ` --base-url https://open.bigmodel.cn/api/coding/paas/v4 ` --workspace . ``` ### ACP 示例 ```powershell ai4j acp ` --provider openai ` --protocol responses ` --model gpt-5-mini ` --workspace . ``` ### 源码构建(可选) ```powershell mvn -pl ai4j-cli -am -DskipTests package ``` 产物示例: ```text ai4j-cli/target/ai4j-cli--jar-with-dependencies.jar ``` 如果你需要直接运行本地构建产物: ```powershell java -jar .\ai4j-cli\target\ai4j-cli--jar-with-dependencies.jar code --help ``` ### 当前协议规则 当前 CLI 对用户只暴露两种协议: + `chat` + `responses` 如果省略 `--protocol`,会按 provider/baseUrl 在本地推导默认值: + `openai` + 官方 OpenAI host -> `responses` + `openai` + 自定义兼容 `baseUrl` -> `chat` + `doubao` / `dashscope` -> `responses` + 其他 provider -> `chat` 注意: + 不再对用户暴露 `auto` + 旧配置中的 `auto` 会在读取时自动归一化为显式协议 ### provider profile 配置位置 + 全局配置:`~/.ai4j/providers.json` + 工作区配置:`/.ai4j/workspace.json` 推荐工作流: + 全局保存长期可复用 profile + workspace 只引用当前 activeProfile + 临时切模型时使用 workspace 的 `modelOverride` `workspace.json` 也可以显式挂载额外 skill 目录: ```json { "activeProfile": "openai-main", "modelOverride": "gpt-5-mini", "enabledMcpServers": ["fetch"], "skillDirectories": [ ".ai4j/skills", "C:/skills/team", "../shared-skills" ] } ``` skill 发现规则: + 默认扫描 `/.ai4j/skills` + 默认扫描 `~/.ai4j/skills` + `skillDirectories` 中的相对路径按 workspace 根目录解析 + 进入 CLI 后可用 `/skills` 查看当前发现到的 skill + 可用 `/skills ` 查看某个 skill 的路径、来源、描述和扫描 roots,不打印 `SKILL.md` 正文 ### `/stream`、`Esc` 与状态提示 当前 `/stream` 的语义是“当前 CLI 会话里的模型请求是否启用 `stream`”,不是单纯的 transcript 渲染开关: + 作用域是当前 CLI 会话 + `/stream on|off` 会切换请求级 `stream=true|false`,并立即重建当前 session runtime + `on` 时 provider 响应按增量到达,assistant 文本也按增量呈现 + `off` 时等待完整响应后再输出整理后的完成块 + 流式 event 粒度由上游 provider/SSE 决定,不保证“一个 event = 一个 token” + 如果通过 ACP/IDE 接入,宿主应按收到的 chunk 顺序渲染,并保留换行与空白 当前交互壳层里: + `Esc` 在活跃 turn 中断当前任务;空闲时关闭 palette 或清空输入 + 状态栏会显示 `Thinking`、`Connecting`、`Responding`、`Working`、`Retrying` + 一段时间没有新进展会升级为 `Waiting` + 更久没有新进展会显示 `Stalled`,并提示 `press Esc to interrupt` ### 常用命令 + `/providers` + `/provider` + `/provider use ` + `/provider save ` + `/provider add --provider [--protocol ] [--model ] [--base-url ] [--api-key ]` + `/provider edit [--provider ] [--protocol ] [--model |--clear-model] [--base-url |--clear-base-url] [--api-key |--clear-api-key]` + `/provider default ` + `/provider remove ` + `/model` + `/model ` + `/model reset` + `/skills` + `/skills ` + `/stream [on|off]` + `/processes` + `/process status|follow|logs|write|stop ...` + `/resume ` / `/load ` / `/fork ...` ### 文档入口 + [Coding Agent 总览](docs-site/docs/coding-agent/overview.md) + [Coding Agent 快速开始](docs-site/docs/coding-agent/quickstart.md) + [CLI / TUI 使用指南](docs-site/docs/coding-agent/cli-and-tui.md) + [会话、流式与进程](docs-site/docs/coding-agent/session-runtime.md) + [配置体系](docs-site/docs/coding-agent/configuration.md) + [Tools 与审批机制](docs-site/docs/coding-agent/tools-and-approvals.md) + [Skills 使用与组织](docs-site/docs/coding-agent/skills.md) + [MCP 对接](docs-site/docs/coding-agent/mcp-integration.md) + [ACP 集成](docs-site/docs/coding-agent/acp-integration.md) + [TUI 定制与主题](docs-site/docs/coding-agent/tui-customization.md) + [命令参考](docs-site/docs/coding-agent/command-reference.md) ## 其它支持 + [[低价中转平台] 低价ApiKey—限时特惠 ](https://api.trovebox.online/) + [[在线平台] 每日白嫖额度-所有模型均可使用 ](https://chat.trovebox.online/) # 快速开始 ## 导入 ### 模块选型 + 只需要基础 LLM / Tool Call / MCP / RAG 能力:引入 `ai4j` + 需要通用 Agent 运行时:引入 `ai4j-agent` + 需要 Coding Agent、workspace tools、outer loop:引入 `ai4j-coding` + 需要本地 CLI / TUI / ACP 宿主:引入 `ai4j-cli` + 需要 Spring Boot 自动配置:引入 `ai4j-spring-boot-starter` + 需要 FlowGram 工作流集成:引入 `ai4j-flowgram-spring-boot-starter` + 同时引入多个模块:建议额外引入 `ai4j-bom` ### Gradle ```groovy implementation platform("io.github.lnyo-cly:ai4j-bom:${project.version}") implementation "io.github.lnyo-cly:ai4j" implementation "io.github.lnyo-cly:ai4j-agent" ``` ```groovy implementation group: 'io.github.lnyo-cly', name: 'ai4j', version: '${project.version}' ``` ```groovy implementation group: 'io.github.lnyo-cly', name: 'ai4j-spring-boot-starter', version: '${project.version}' ``` ### Maven ```xml io.github.lnyo-cly ai4j-bom ${project.version} pom import ``` ```xml io.github.lnyo-cly ai4j-agent io.github.lnyo-cly ai4j-coding ``` ```xml io.github.lnyo-cly ai4j ${project.version} ``` ```xml io.github.lnyo-cly ai4j-spring-boot-starter ${project.version} ``` ## 获取AI服务实例 ### 非Spring获取 ```java public void test_init(){ OpenAiConfig openAiConfig = new OpenAiConfig(); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1",10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); chatService = aiService.getChatService(PlatformType.getPlatform("OPENAI")); } ``` ### Spring获取 ```yml # 国内访问默认需要代理 ai: openai: api-key: "api-key" okhttp: proxy-port: 10809 proxy-url: "127.0.0.1" zhipu: api-key: "xxx" #other... ``` ```java // 注入Ai服务 @Autowired private AiService aiService; // 获取需要的服务实例 IChatService chatService = aiService.getChatService(PlatformType.OPENAI); IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); // ...... ``` ## Chat服务 ### 同步请求调用 ```java public void test_chat() throws Exception { // 获取chat服务实例 IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // 构建请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); // 发送对话请求 ChatCompletionResponse response = chatService.chatCompletion(chatCompletion); System.out.println(response); } ``` ### 流式调用 ```java public void test_chat_stream() throws Exception { // 获取chat服务实例 IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("查询北京明天的天气")) .functions("queryWeather") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println(sseListener.getOutput()); } ``` ### 图片识别 ```java public void test_chat_image() throws Exception { // 获取chat服务实例 IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // 构建请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("图片中有什么东西", "https://cn.bing.com/images/search?view=detailV2&ccid=r0OnuYkv&id=9A07DE578F6ED50DB59DFEA5C675AC71845A6FC9&thid=OIP.r0OnuYkvsbqBrYk3kUT53AHaKX&mediaurl=https%3a%2f%2fimg.zcool.cn%2fcommunity%2f0104c15cd45b49a80121416816f1ec.jpg%401280w_1l_2o_100sh.jpg&exph=1792&expw=1280&q=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87&simid=607987191780608963&FORM=IRPRST&ck=12127C1696CF374CB9D0F09AE99AFE69&selectedIndex=2&itb=0&qpvt=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87")) .build(); // 发送对话请求 ChatCompletionResponse response = chatService.chatCompletion(chatCompletion); System.out.println(response); } ``` ### 函数调用 ```java public void test_chat_tool_call() throws Exception { // 获取chat服务实例 IChatService chatService = aiService.getChatService(PlatformType.OPENAI); // 构建请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("今天北京天气怎么样")) .functions("queryWeather") .build(); // 发送对话请求 ChatCompletionResponse response = chatService.chatCompletion(chatCompletion); System.out.println(response); } ``` ### 内置 ChatMemory 如果你只是做基础多轮对话,不想自己每轮维护完整上下文,可以直接使用 `ChatMemory`: ```java IChatService chatService = aiService.getChatService(PlatformType.OPENAI); ChatMemory memory = new InMemoryChatMemory(new MessageWindowChatMemoryPolicy(12)); memory.addSystem("你是一个简洁的 Java 助手"); memory.addUser("请用三点介绍 AI4J"); ChatCompletion request = ChatCompletion.builder() .model("gpt-4o-mini") .messages(memory.toChatMessages()) .build(); ChatCompletionResponse response = chatService.chatCompletion(request); String answer = response.getChoices().get(0).getMessage().getContent().getText(); memory.addAssistant(answer); ``` 同一份 `memory` 也可以直接给 `Responses`: ```java IResponsesService responsesService = aiService.getResponsesService(PlatformType.DOUBAO); ResponseRequest request = ResponseRequest.builder() .model("doubao-seed-1-8-251228") .input(memory.toResponsesInput()) .build(); ``` #### 定义函数 ```java @FunctionCall(name = "queryWeather", description = "查询目标地点的天气预报") public class QueryWeatherFunction implements Function { @Data @FunctionRequest public static class Request{ @FunctionParameter(description = "需要查询天气的目标位置, 可以是城市中文名、城市拼音/英文名、省市名称组合、IP 地址、经纬度") private String location; @FunctionParameter(description = "需要查询未来天气的天数, 最多15日") private int days = 15; @FunctionParameter(description = "预报的天气类型,daily表示预报多天天气、hourly表示预测当天24天气、now为当前天气实况") private Type type; } public enum Type{ daily, hourly, now } @Override public String apply(Request request) { final String key = ""; String url = String.format("https://api.seniverse.com/v3/weather/%s.json?key=%s&location=%s&days=%d", request.type.name(), key, request.location, request.days); OkHttpClient client = new OkHttpClient(); okhttp3.Request http = new okhttp3.Request.Builder() .url(url) .get() .build(); try (Response response = client.newCall(http).execute()) { if (response.isSuccessful()) { // 解析响应体 return response.body() != null ? response.body().string() : ""; } else { return "获取天气失败 当前天气未知"; } } catch (Exception e) { // 处理异常 e.printStackTrace(); return "获取天气失败 当前天气未知"; } } } ``` ## Embedding服务 ```java public void test_embed() throws Exception { // 获取embedding服务实例 IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); // 构建请求参数 Embedding embeddingReq = Embedding.builder().input("1+1").build(); // 发送embedding请求 EmbeddingResponse embeddingResp = embeddingService.embedding(embeddingReq); System.out.println(embeddingResp); } ``` ## Rerank服务 ### 直接调用统一重排服务 ```java IRerankService rerankService = aiService.getRerankService(PlatformType.JINA); RerankRequest request = RerankRequest.builder() .model("jina-reranker-v2-base-multilingual") .query("哪段最适合回答 Java 8 为什么仍然常见") .documents(Arrays.asList( RerankDocument.builder().id("doc-1").text("Java 8 仍是很多传统系统的默认运行时").build(), RerankDocument.builder().id("doc-2").text("AI4J 提供统一 Chat、Responses 和 RAG 接口").build(), RerankDocument.builder().id("doc-3").text("历史中间件和升级成本让很多企业延后 JDK 升级").build() )) .topN(2) .build(); RerankResponse response = rerankService.rerank(request); System.out.println(response.getResults()); ``` ### 作为 RAG 精排器接入 ```java Reranker reranker = aiService.getModelReranker( PlatformType.JINA, "jina-reranker-v2-base-multilingual", 5, "优先保留制度原文、版本说明和编号明确的片段" ); ``` ## RAG ### 推荐:使用统一 IngestionPipeline 入库 ```java VectorStore vectorStore = aiService.getQdrantVectorStore(); IngestionPipeline ingestionPipeline = aiService.getIngestionPipeline( PlatformType.OPENAI, vectorStore ); IngestionResult ingestResult = ingestionPipeline.ingest(IngestionRequest.builder() .dataset("kb_docs") .embeddingModel("text-embedding-3-small") .document(RagDocument.builder() .sourceName("员工手册") .sourcePath("/docs/employee-handbook.md") .tenant("acme") .biz("hr") .version("2026.03") .build()) .source(IngestionSource.text("第一章 假期政策。第二章 报销政策。")) .build()); System.out.println(ingestResult.getUpsertedCount()); ``` 如果你已经走 Pinecone,也可以直接: ```java IngestionPipeline ingestionPipeline = aiService.getPineconeIngestionPipeline(PlatformType.OPENAI); ``` 推荐主线是: 1. `IngestionPipeline` 负责文档入库 2. `VectorStore` 负责底层向量存储 3. `DenseRetriever / HybridRetriever / ModelReranker / RagService` 负责查询阶段 完整说明见: + `docs-site/docs/ai-basics/rag/ingestion-pipeline.md` + `docs-site/docs/ai-basics/rag/overview.md` ### 配置向量数据库 ```yml ai: vector: pinecone: host: "" key: "" ``` ### 推荐:Pinecone 也走统一 `VectorStore + IngestionPipeline` ```java VectorStore vectorStore = aiService.getPineconeVectorStore(); IngestionPipeline ingestionPipeline = aiService.getPineconeIngestionPipeline(PlatformType.OPENAI); IngestionResult ingestResult = ingestionPipeline.ingest(IngestionRequest.builder() .dataset("tenant_a_hr_v202603") .embeddingModel("text-embedding-3-small") .document(RagDocument.builder() .sourceName("员工手册") .sourcePath("/docs/employee-handbook.pdf") .tenant("tenant_a") .biz("hr") .version("2026.03") .build()) .source(IngestionSource.file(new File("D:/data/employee-handbook.pdf"))) .build()); System.out.println("upserted=" + ingestResult.getUpsertedCount()); ``` ### 查询阶段:直接走统一 `RagService` ```java RagService ragService = aiService.getRagService( PlatformType.OPENAI, vectorStore ); RagQuery ragQuery = RagQuery.builder() .query("年假如何计算") .dataset("tenant_a_hr_v202603") .embeddingModel("text-embedding-3-small") .topK(5) .build(); RagResult ragResult = ragService.search(ragQuery); System.out.println(ragResult.getContext()); System.out.println(ragResult.getCitations()); ``` ### 如果需要更高精度,再接 Rerank ```java Reranker reranker = aiService.getModelReranker( PlatformType.JINA, "jina-reranker-v2-base-multilingual", 5, "优先制度原文、章节标题和编号明确的片段" ); RagService ragService = new DefaultRagService( new DenseRetriever( aiService.getEmbeddingService(PlatformType.OPENAI), vectorStore ), reranker, new DefaultRagContextAssembler() ); ``` ### 什么时候还需要直接用已废弃的 `PineconeService`(Deprecated) `PineconeService` 目前在文档层已视为 Deprecated。只有在你明确需要 Pinecone 特有的底层控制时,才建议继续直接用: + namespace 级底层操作 + 兼容旧项目里已经写死的 `PineconeQuery / PineconeDelete` + 你就是在做 Pinecone 专用封装,而不是面向统一 RAG 抽象开发 ## 内置联网 ### SearXNG #### 配置 ```java // 非spring应用 SearXNGConfig searXNGConfig = new SearXNGConfig(); searXNGConfig.setUrl("http://127.0.0.1:8080/search"); Configuration configuration = new Configuration(); configuration.setSearXNGConfig(searXNGConfig); ``` ```YML # spring应用 ai: websearch: searxng: url: http://127.0.0.1:8080/search ``` #### 使用 ```java // ... webEnhance = aiService.webSearchEnhance(chatService); // ... @Test public void test_chatCompletions_common_websearch_enhance() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen2.5:7b") .message(ChatMessage.withUser("鸡你太美是什么梗")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = webEnhance.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } ``` # 为AI4J提供贡献 欢迎您对AI4J提出建议、报告问题或贡献代码。您可以按照以下的方式为AI4J提供贡献: ## 问题反馈 请使用GitHub Issue页面报告问题。尽可能具体地说明如何重现您的问题,包括操作系统、Java版本和任何相关日志跟踪等详细信息。 ## PR 1. Fork 本仓库并创建您的分支(建议命名:feature/功能名、fix/问题名 或 docs/文档优化)。 2. 编写代码或修改内容(如更新文档),并完成测试(确保功能正常或文档无误)。 3. 确保您的代码符合现有的样式。 4. 提交时编写清晰的日志信息。对于小的改动,单行信息就可以了,但较大的改动应该有详细的描述。 5. 完成拉取请求表单,确保在`dev`分支进行改动,链接到您的 PR 解决的问题。 # 支持 如果您觉得这个项目对您有帮助,请点一个star⭐。 # Buy Me a Coffee 您的支持是我更新的最大的动力。 ![新图片](https://cdn.jsdelivr.net/gh/lnyo-cly/blogImg/pics/新图片.jpg) # 贡献者 # ⭐️ Star History Star History Chart ================================================ FILE: ai4j/pom.xml ================================================ 4.0.0 io.github.lnyo-cly ai4j jar 2.3.0 ai4j ai4j 核心 Java SDK,提供统一大模型接入、Tool Call、RAG 与 MCP 能力。 Core Java SDK for unified LLM access, tool calling, RAG, and MCP integration. 8 8 UTF-8 true 20.3.4 ${graalvm.version} 15.6 The Apache License, Version 2.0 https://www.apache.org/licenses/LICENSE-2.0.txt GitHub https://github.com/LnYo-Cly/ai4j/issues https://github.com/LnYo-Cly/ai4j LnYo-Cly LnYo-Cly lnyocly@gmail.com https://github.com/LnYo-Cly/ai4j +8 https://github.com/LnYo-Cly/ai4j scm:git:https://github.com/LnYo-Cly/ai4j.git scm:git:https://github.com/LnYo-Cly/ai4j.git org.slf4j slf4j-simple 1.7.30 org.apache.tika tika-core 2.9.2 org.apache.tika tika-parsers-standard-package 2.8.0 com.knuddels jtokkit 1.1.0 org.projectlombok lombok 1.18.30 org.slf4j slf4j-api 1.7.30 org.reflections reflections 0.10.2 junit junit 4.13.2 test com.h2database h2 2.2.224 test com.squareup.okhttp3 mockwebserver 4.12.0 test com.alibaba.fastjson2 fastjson2 2.0.43 com.fasterxml.jackson.core jackson-annotations 2.16.0 com.fasterxml.jackson.core jackson-databind 2.14.2 com.squareup.okhttp3 okhttp 4.12.0 com.squareup.okhttp3 okhttp-sse 4.12.0 com.squareup.okhttp3 logging-interceptor 4.12.0 com.google.guava guava 33.0.0-jre org.apache.commons commons-lang3 3.12.0 com.auth0 java-jwt 4.4.0 cn.hutool hutool-core 5.8.38 com.alibaba dashscope-sdk-java compile 2.19.0 org.jetbrains annotations 13.0 org.graalvm.sdk graal-sdk ${graalsdk.version} ${project.name}-${project.version} org.apache.maven.plugins maven-surefire-plugin 2.12.4 ${skipTests} org.apache.maven.plugins maven-compiler-plugin 3.8.1 1.8 1.8 UTF8 -parameters true org.projectlombok lombok 1.18.30 nashorn-runtime [15,) org.openjdk.nashorn nashorn-core ${nashorn.version} runtime graalpy-runtime [17,) 24.1.2 org.graalvm.python python-community ${graalsdk.version} pom runtime release org.apache.maven.plugins maven-source-plugin 3.3.1 attach-sources jar-no-fork org.apache.maven.plugins maven-javadoc-plugin 3.6.3 attach-javadocs jar none false Author a Author: Description a Description: Date a Date: org.apache.maven.plugins maven-gpg-plugin 1.6 D:\Develop\DevelopEnv\GnuPG\bin\gpg.exe cly sign-artifacts verify sign org.sonatype.central central-publishing-maven-plugin 0.4.0 true LnYo-Cly true ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlow.java ================================================ package io.github.lnyocly.ai4j.agentflow; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatService; import io.github.lnyocly.ai4j.agentflow.chat.CozeAgentFlowChatService; import io.github.lnyocly.ai4j.agentflow.chat.DifyAgentFlowChatService; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowService; import io.github.lnyocly.ai4j.agentflow.workflow.CozeAgentFlowWorkflowService; import io.github.lnyocly.ai4j.agentflow.workflow.DifyAgentFlowWorkflowService; import io.github.lnyocly.ai4j.agentflow.workflow.N8nAgentFlowWorkflowService; import io.github.lnyocly.ai4j.service.Configuration; public class AgentFlow { private final Configuration configuration; private final AgentFlowConfig config; public AgentFlow(Configuration configuration, AgentFlowConfig config) { if (configuration == null) { throw new IllegalArgumentException("configuration is required"); } if (config == null) { throw new IllegalArgumentException("agentFlowConfig is required"); } this.configuration = configuration; this.config = config; } public Configuration getConfiguration() { return configuration; } public AgentFlowConfig getConfig() { return config; } public AgentFlowChatService chat() { if (config.getType() == AgentFlowType.DIFY) { return new DifyAgentFlowChatService(configuration, config); } if (config.getType() == AgentFlowType.COZE) { return new CozeAgentFlowChatService(configuration, config); } throw new IllegalArgumentException("Chat is not supported for agent flow type: " + config.getType()); } public AgentFlowWorkflowService workflow() { if (config.getType() == AgentFlowType.DIFY) { return new DifyAgentFlowWorkflowService(configuration, config); } if (config.getType() == AgentFlowType.COZE) { return new CozeAgentFlowWorkflowService(configuration, config); } if (config.getType() == AgentFlowType.N8N) { return new N8nAgentFlowWorkflowService(configuration, config); } throw new IllegalArgumentException("Workflow is not supported for agent flow type: " + config.getType()); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowConfig.java ================================================ package io.github.lnyocly.ai4j.agentflow; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.NonNull; import java.util.Collections; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowConfig { @NonNull private AgentFlowType type; private String baseUrl; private String webhookUrl; private String apiKey; private String botId; private String workflowId; private String appId; private String userId; private String conversationId; @Builder.Default private Long pollIntervalMillis = 1_000L; @Builder.Default private Long pollTimeoutMillis = 60_000L; @Builder.Default private Map headers = Collections.emptyMap(); @Builder.Default private List traceListeners = Collections.emptyList(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowException.java ================================================ package io.github.lnyocly.ai4j.agentflow; public class AgentFlowException extends RuntimeException { public AgentFlowException(String message) { super(message); } public AgentFlowException(String message, Throwable cause) { super(message, cause); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowType.java ================================================ package io.github.lnyocly.ai4j.agentflow; public enum AgentFlowType { DIFY, COZE, N8N } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowUsage.java ================================================ package io.github.lnyocly.ai4j.agentflow; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowUsage { private Integer inputTokens; private Integer outputTokens; private Integer totalTokens; private Object raw; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatEvent.java ================================================ package io.github.lnyocly.ai4j.agentflow.chat; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowChatEvent { private String type; private String contentDelta; private String conversationId; private String messageId; private String taskId; private boolean done; private AgentFlowUsage usage; private Object raw; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatListener.java ================================================ package io.github.lnyocly.ai4j.agentflow.chat; public interface AgentFlowChatListener { void onEvent(AgentFlowChatEvent event); default void onOpen() { } default void onError(Throwable throwable) { } default void onComplete(AgentFlowChatResponse response) { } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatRequest.java ================================================ package io.github.lnyocly.ai4j.agentflow.chat; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.NonNull; import java.util.Collections; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowChatRequest { @NonNull private String prompt; @Builder.Default private Map inputs = Collections.emptyMap(); private String userId; private String conversationId; @Builder.Default private Map metadata = Collections.emptyMap(); @Builder.Default private Map extraBody = Collections.emptyMap(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatResponse.java ================================================ package io.github.lnyocly.ai4j.agentflow.chat; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowChatResponse { private String content; private String conversationId; private String messageId; private String taskId; private AgentFlowUsage usage; private Object raw; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatService.java ================================================ package io.github.lnyocly.ai4j.agentflow.chat; public interface AgentFlowChatService { AgentFlowChatResponse chat(AgentFlowChatRequest request) throws Exception; void chatStream(AgentFlowChatRequest request, AgentFlowChatListener listener) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/CozeAgentFlowChatService.java ================================================ package io.github.lnyocly.ai4j.agentflow.chat; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.AgentFlowConfig; import io.github.lnyocly.ai4j.agentflow.AgentFlowException; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.Request; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; public class CozeAgentFlowChatService extends AgentFlowSupport implements AgentFlowChatService { public CozeAgentFlowChatService(Configuration configuration, AgentFlowConfig agentFlowConfig) { super(configuration, agentFlowConfig); } @Override public AgentFlowChatResponse chat(AgentFlowChatRequest request) throws Exception { AgentFlowTraceContext traceContext = startTrace("chat", false, request); try { JSONObject createResponse = executeObject(buildCreateRequest(request, false)); assertCozeSuccess(createResponse); JSONObject createData = createResponse.getJSONObject("data"); String chatId = createData == null ? null : createData.getString("id"); String conversationId = firstNonBlank( createData == null ? null : createData.getString("conversation_id"), defaultConversationId(request.getConversationId()) ); if (isBlank(chatId)) { throw new AgentFlowException("Coze chat id is missing"); } JSONObject chatData = pollChat(conversationId, chatId); String status = chatData.getString("status"); if (!"completed".equals(status)) { throw new AgentFlowException("Coze chat finished with status: " + status); } JSONObject messageResponse = executeObject(buildMessageListRequest(conversationId, chatId)); assertCozeSuccess(messageResponse); JSONArray messages = messageResponse.getJSONArray("data"); StringBuilder content = new StringBuilder(); String messageId = null; if (messages != null) { for (int i = messages.size() - 1; i >= 0; i--) { JSONObject message = messages.getJSONObject(i); if (message == null) { continue; } if (!"assistant".equals(message.getString("role"))) { continue; } String messageContent = message.getString("content"); if (!isBlank(messageContent)) { if (content.length() > 0) { content.insert(0, "\n"); } content.insert(0, messageContent); if (messageId == null) { messageId = message.getString("id"); } } } } Map raw = new LinkedHashMap(); raw.put("chat", chatData); raw.put("messages", messages); AgentFlowChatResponse chatResponse = AgentFlowChatResponse.builder() .content(content.toString()) .conversationId(conversationId) .messageId(messageId) .taskId(chatId) .usage(usageFromCoze(chatData.getJSONObject("usage"))) .raw(raw) .build(); traceComplete(traceContext, chatResponse); return chatResponse; } catch (Exception ex) { traceError(traceContext, ex); throw ex; } } @Override public void chatStream(AgentFlowChatRequest request, final AgentFlowChatListener listener) throws Exception { if (listener == null) { throw new IllegalArgumentException("listener is required"); } final AgentFlowTraceContext traceContext = startTrace("chat", true, request); Request httpRequest = buildCreateRequest(request, true); final CountDownLatch latch = new CountDownLatch(1); final AtomicReference failure = new AtomicReference(); final AtomicReference chatIdRef = new AtomicReference(); final AtomicReference conversationIdRef = new AtomicReference(); final AtomicReference messageIdRef = new AtomicReference(); final AtomicReference usageRef = new AtomicReference(); final AtomicReference completionRef = new AtomicReference(); final StringBuilder content = new StringBuilder(); final AtomicBoolean closed = new AtomicBoolean(false); final AtomicBoolean sawDelta = new AtomicBoolean(false); eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { listener.onOpen(); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { try { String eventType = type; JSONObject payload = parseObjectOrNull(data); if ("done".equals(eventType)) { AgentFlowChatEvent event = AgentFlowChatEvent.builder() .type(eventType) .conversationId(conversationIdRef.get()) .messageId(messageIdRef.get()) .taskId(chatIdRef.get()) .done(true) .usage(usageRef.get()) .raw(data) .build(); listener.onEvent(event); traceEvent(traceContext, event); AgentFlowChatResponse responsePayload = AgentFlowChatResponse.builder() .content(content.toString()) .conversationId(conversationIdRef.get()) .messageId(messageIdRef.get()) .taskId(chatIdRef.get()) .usage(usageRef.get()) .raw(data) .build(); completionRef.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); closed.set(true); eventSource.cancel(); latch.countDown(); return; } if ("error".equals(eventType)) { throw new AgentFlowException("Coze stream error: " + data); } String delta = null; if (eventType != null && eventType.startsWith("conversation.chat.")) { conversationIdRef.set(firstNonBlank( payload == null ? null : payload.getString("conversation_id"), conversationIdRef.get() )); chatIdRef.set(firstNonBlank( payload == null ? null : payload.getString("id"), chatIdRef.get() )); AgentFlowUsage usage = payload == null ? null : usageFromCoze(payload.getJSONObject("usage")); if (usage != null) { usageRef.set(usage); } if ("conversation.chat.failed".equals(eventType) || "conversation.chat.requires_action".equals(eventType)) { throw new AgentFlowException("Coze chat status event: " + eventType); } } else if (eventType != null && eventType.startsWith("conversation.message.")) { conversationIdRef.set(firstNonBlank( payload == null ? null : payload.getString("conversation_id"), conversationIdRef.get() )); chatIdRef.set(firstNonBlank( payload == null ? null : payload.getString("chat_id"), chatIdRef.get() )); messageIdRef.set(firstNonBlank( payload == null ? null : payload.getString("id"), messageIdRef.get() )); String contentValue = payload == null ? null : payload.getString("content"); if ("conversation.message.delta".equals(eventType)) { delta = contentValue; if (!isBlank(delta)) { content.append(delta); sawDelta.set(true); } } else if ("conversation.message.completed".equals(eventType)) { if (!sawDelta.get() && !isBlank(contentValue)) { content.append(contentValue); } } } AgentFlowChatEvent event = AgentFlowChatEvent.builder() .type(eventType) .contentDelta(delta) .conversationId(conversationIdRef.get()) .messageId(messageIdRef.get()) .taskId(chatIdRef.get()) .usage(usageRef.get()) .raw(payload == null ? data : payload) .build(); listener.onEvent(event); traceEvent(traceContext, event); } catch (Throwable ex) { failure.set(ex); traceError(traceContext, ex); listener.onError(ex); closed.set(true); eventSource.cancel(); latch.countDown(); } } @Override public void onClosed(@NotNull EventSource eventSource) { if (closed.compareAndSet(false, true)) { AgentFlowChatResponse responsePayload = completionRef.get(); if (responsePayload == null) { responsePayload = AgentFlowChatResponse.builder() .content(content.toString()) .conversationId(conversationIdRef.get()) .messageId(messageIdRef.get()) .taskId(chatIdRef.get()) .usage(usageRef.get()) .build(); completionRef.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); } latch.countDown(); } } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { Throwable error = t; if (error == null && response != null) { error = new AgentFlowException("Coze stream failed: HTTP " + response.code()); } if (error == null) { error = new AgentFlowException("Coze stream failed"); } failure.set(error); traceError(traceContext, error); listener.onError(error); closed.set(true); latch.countDown(); } }); if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) { throw new AgentFlowException("Coze stream timed out"); } if (failure.get() != null) { if (failure.get() instanceof Exception) { throw (Exception) failure.get(); } throw new AgentFlowException("Coze stream failed", failure.get()); } } private JSONObject pollChat(String conversationId, String chatId) throws Exception { long deadline = System.currentTimeMillis() + pollTimeoutMillis(); while (true) { String url = appendQuery( joinedUrl(requireBaseUrl(), "v3/chat/retrieve"), query(conversationId, chatId) ); JSONObject retrieveResponse = executeObject(jsonRequestBuilder(url).get().build()); assertCozeSuccess(retrieveResponse); JSONObject chatData = retrieveResponse.getJSONObject("data"); String status = chatData == null ? null : chatData.getString("status"); if ("completed".equals(status) || "failed".equals(status) || "canceled".equals(status) || "requires_action".equals(status)) { return chatData == null ? new JSONObject() : chatData; } if (System.currentTimeMillis() >= deadline) { throw new AgentFlowException("Coze chat poll timed out"); } sleep(pollIntervalMillis()); } } private Request buildCreateRequest(AgentFlowChatRequest request, boolean stream) { String conversationId = defaultConversationId(request.getConversationId()); String url = appendQuery( joinedUrl(requireBaseUrl(), "v3/chat"), queryConversation(conversationId) ); return jsonRequestBuilder(url).post(jsonBody(buildCreateBody(request, stream))).build(); } private JSONObject buildCreateBody(AgentFlowChatRequest request, boolean stream) { JSONObject body = new JSONObject(); body.put("bot_id", requireBotId()); body.put("user_id", defaultUserId(request.getUserId())); body.put("stream", stream); body.put("additional_messages", Collections.singletonList(userMessage(request.getPrompt()))); if (request.getInputs() != null && !request.getInputs().isEmpty()) { body.put("parameters", request.getInputs()); } if (request.getMetadata() != null && !request.getMetadata().isEmpty()) { body.put("meta_data", toStringMap(request.getMetadata())); } if (!stream && !body.containsKey("auto_save_history")) { body.put("auto_save_history", true); } if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) { body.putAll(request.getExtraBody()); } return body; } private Request buildMessageListRequest(String conversationId, String chatId) { String url = appendQuery( joinedUrl(requireBaseUrl(), "v1/conversation/message/list"), queryConversation(conversationId) ); JSONObject body = new JSONObject(); body.put("conversation_id", conversationId); body.put("chat_id", chatId); body.put("order", "asc"); body.put("limit", 50); if (!isBlank(agentFlowConfig.getBotId())) { body.put("bot_id", agentFlowConfig.getBotId()); } return jsonRequestBuilder(url).post(jsonBody(body)).build(); } private JSONObject userMessage(String prompt) { JSONObject message = new JSONObject(); message.put("role", "user"); message.put("type", "question"); message.put("content", prompt); message.put("content_type", "text"); return message; } private JSONObject parseObjectOrNull(String data) { if (isBlank(data)) { return null; } try { Object parsed = JSON.parse(data); return parsed instanceof JSONObject ? (JSONObject) parsed : null; } catch (Exception ex) { return null; } } private Map queryConversation(String conversationId) { Map values = new LinkedHashMap(); if (!isBlank(conversationId)) { values.put("conversation_id", conversationId); } return values; } private Map query(String conversationId, String chatId) { Map values = queryConversation(conversationId); if (!isBlank(chatId)) { values.put("chat_id", chatId); } return values; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/DifyAgentFlowChatService.java ================================================ package io.github.lnyocly.ai4j.agentflow.chat; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.AgentFlowConfig; import io.github.lnyocly.ai4j.agentflow.AgentFlowException; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.Request; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; public class DifyAgentFlowChatService extends AgentFlowSupport implements AgentFlowChatService { public DifyAgentFlowChatService(Configuration configuration, AgentFlowConfig agentFlowConfig) { super(configuration, agentFlowConfig); } @Override public AgentFlowChatResponse chat(AgentFlowChatRequest request) throws Exception { AgentFlowTraceContext traceContext = startTrace("chat", false, request); try { JSONObject body = buildRequestBody(request, "blocking"); String url = joinedUrl(requireBaseUrl(), "v1/chat-messages"); JSONObject response = executeObject(jsonRequestBuilder(url).post(jsonBody(body)).build()); AgentFlowChatResponse chatResponse = mapBlockingResponse(response); traceComplete(traceContext, chatResponse); return chatResponse; } catch (Exception ex) { traceError(traceContext, ex); throw ex; } } @Override public void chatStream(AgentFlowChatRequest request, final AgentFlowChatListener listener) throws Exception { if (listener == null) { throw new IllegalArgumentException("listener is required"); } final AgentFlowTraceContext traceContext = startTrace("chat", true, request); JSONObject body = buildRequestBody(request, "streaming"); String url = joinedUrl(requireBaseUrl(), "v1/chat-messages"); Request httpRequest = jsonRequestBuilder(url).post(jsonBody(body)).build(); final CountDownLatch latch = new CountDownLatch(1); final AtomicReference failure = new AtomicReference(); final AtomicReference completion = new AtomicReference(); final AtomicReference conversationIdRef = new AtomicReference(); final AtomicReference messageIdRef = new AtomicReference(); final AtomicReference taskIdRef = new AtomicReference(); final AtomicReference usageRef = new AtomicReference(); final StringBuilder content = new StringBuilder(); final AtomicBoolean closed = new AtomicBoolean(false); eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { listener.onOpen(); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { try { JSONObject payload = parseObjectOrNull(data); String eventType = firstNonBlank(type, payload == null ? null : payload.getString("event")); if (isBlank(eventType) || "ping".equals(eventType)) { return; } String conversationId = payload == null ? null : payload.getString("conversation_id"); String messageId = payload == null ? null : firstNonBlank(payload.getString("message_id"), payload.getString("id")); String taskId = payload == null ? null : payload.getString("task_id"); if (!isBlank(conversationId)) { conversationIdRef.set(conversationId); } if (!isBlank(messageId)) { messageIdRef.set(messageId); } if (!isBlank(taskId)) { taskIdRef.set(taskId); } AgentFlowUsage usage = payload == null ? null : usageFromDify(metadataUsage(payload)); if (usage != null) { usageRef.set(usage); } String delta = null; if ("message".equals(eventType) || "agent_message".equals(eventType)) { delta = payload == null ? null : payload.getString("answer"); if (!isBlank(delta)) { content.append(delta); } } boolean done = "message_end".equals(eventType); AgentFlowChatEvent event = AgentFlowChatEvent.builder() .type(eventType) .contentDelta(delta) .conversationId(conversationIdRef.get()) .messageId(messageIdRef.get()) .taskId(taskIdRef.get()) .done(done) .usage(usageRef.get()) .raw(payload == null ? data : payload) .build(); listener.onEvent(event); traceEvent(traceContext, event); if ("error".equals(eventType)) { throw new AgentFlowException("Dify stream error: " + (payload == null ? data : payload.toJSONString())); } if (done) { AgentFlowChatResponse responsePayload = AgentFlowChatResponse.builder() .content(content.toString()) .conversationId(conversationIdRef.get()) .messageId(messageIdRef.get()) .taskId(taskIdRef.get()) .usage(usageRef.get()) .raw(payload) .build(); completion.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); closed.set(true); eventSource.cancel(); latch.countDown(); } } catch (Throwable ex) { failure.set(ex); traceError(traceContext, ex); listener.onError(ex); closed.set(true); eventSource.cancel(); latch.countDown(); } } @Override public void onClosed(@NotNull EventSource eventSource) { if (closed.compareAndSet(false, true)) { AgentFlowChatResponse responsePayload = completion.get(); if (responsePayload == null) { responsePayload = AgentFlowChatResponse.builder() .content(content.toString()) .conversationId(conversationIdRef.get()) .messageId(messageIdRef.get()) .taskId(taskIdRef.get()) .usage(usageRef.get()) .build(); completion.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); } latch.countDown(); } } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { Throwable error = t; if (error == null && response != null) { error = new AgentFlowException("Dify stream failed: HTTP " + response.code()); } if (error == null) { error = new AgentFlowException("Dify stream failed"); } failure.set(error); traceError(traceContext, error); listener.onError(error); closed.set(true); latch.countDown(); } }); if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) { throw new AgentFlowException("Dify stream timed out"); } if (failure.get() != null) { if (failure.get() instanceof Exception) { throw (Exception) failure.get(); } throw new AgentFlowException("Dify stream failed", failure.get()); } } private JSONObject buildRequestBody(AgentFlowChatRequest request, String responseMode) { JSONObject body = new JSONObject(); body.put("query", request.getPrompt()); body.put("inputs", request.getInputs() == null ? Collections.emptyMap() : request.getInputs()); body.put("user", defaultUserId(request.getUserId())); body.put("response_mode", responseMode); String conversationId = defaultConversationId(request.getConversationId()); if (!isBlank(conversationId)) { body.put("conversation_id", conversationId); } if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) { body.putAll(request.getExtraBody()); } return body; } private AgentFlowChatResponse mapBlockingResponse(JSONObject response) { return AgentFlowChatResponse.builder() .content(firstNonBlank(response.getString("answer"), response.getString("message"))) .conversationId(response.getString("conversation_id")) .messageId(firstNonBlank(response.getString("message_id"), response.getString("id"))) .taskId(response.getString("task_id")) .usage(usageFromDify(metadataUsage(response))) .raw(response) .build(); } private JSONObject metadataUsage(JSONObject payload) { JSONObject metadata = payload == null ? null : payload.getJSONObject("metadata"); return metadata == null ? null : metadata.getJSONObject("usage"); } private JSONObject parseObjectOrNull(String data) { if (isBlank(data)) { return null; } try { Object parsed = JSON.parse(data); return parsed instanceof JSONObject ? (JSONObject) parsed : null; } catch (Exception ex) { return null; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/support/AgentFlowSupport.java ================================================ package io.github.lnyocly.ai4j.agentflow.support; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.AgentFlowConfig; import io.github.lnyocly.ai4j.agentflow.AgentFlowException; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.network.UrlUtils; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; import okhttp3.sse.EventSource; import java.io.IOException; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public abstract class AgentFlowSupport { protected static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); protected final Configuration configuration; protected final AgentFlowConfig agentFlowConfig; protected final OkHttpClient okHttpClient; protected final EventSource.Factory eventSourceFactory; protected AgentFlowSupport(Configuration configuration, AgentFlowConfig agentFlowConfig) { if (configuration == null) { throw new IllegalArgumentException("configuration is required"); } if (configuration.getOkHttpClient() == null) { throw new IllegalArgumentException("OkHttpClient configuration is required"); } if (agentFlowConfig == null) { throw new IllegalArgumentException("agentFlowConfig is required"); } this.configuration = configuration; this.agentFlowConfig = agentFlowConfig; this.okHttpClient = configuration.getOkHttpClient(); this.eventSourceFactory = configuration.createRequestFactory(); } protected String defaultUserId(String requestUserId) { if (!isBlank(requestUserId)) { return requestUserId; } if (!isBlank(agentFlowConfig.getUserId())) { return agentFlowConfig.getUserId(); } return "default-user"; } protected String defaultConversationId(String requestConversationId) { if (!isBlank(requestConversationId)) { return requestConversationId; } return agentFlowConfig.getConversationId(); } protected String requireBaseUrl() { if (isBlank(agentFlowConfig.getBaseUrl())) { throw new IllegalArgumentException("baseUrl is required"); } return agentFlowConfig.getBaseUrl(); } protected String requireWebhookUrl() { if (isBlank(agentFlowConfig.getWebhookUrl())) { throw new IllegalArgumentException("webhookUrl is required"); } return agentFlowConfig.getWebhookUrl(); } protected String requireApiKey() { if (isBlank(agentFlowConfig.getApiKey())) { throw new IllegalArgumentException("apiKey is required"); } return agentFlowConfig.getApiKey(); } protected String requireBotId() { if (isBlank(agentFlowConfig.getBotId())) { throw new IllegalArgumentException("botId is required"); } return agentFlowConfig.getBotId(); } protected String requireWorkflowId(String requestWorkflowId) { if (!isBlank(requestWorkflowId)) { return requestWorkflowId; } if (!isBlank(agentFlowConfig.getWorkflowId())) { return agentFlowConfig.getWorkflowId(); } throw new IllegalArgumentException("workflowId is required"); } protected String joinedUrl(String baseUrl, String path) { return UrlUtils.concatUrl(baseUrl, path); } protected String appendQuery(String url, Map queryParameters) { HttpUrl parsed = HttpUrl.parse(url); if (parsed == null) { throw new IllegalArgumentException("Invalid URL: " + url); } HttpUrl.Builder builder = parsed.newBuilder(); if (queryParameters != null) { for (Map.Entry entry : queryParameters.entrySet()) { if (!isBlank(entry.getValue())) { builder.addQueryParameter(entry.getKey(), entry.getValue()); } } } return builder.build().toString(); } protected RequestBody jsonBody(Object body) { return RequestBody.create(JSON.toJSONString(body), JSON_MEDIA_TYPE); } protected Request.Builder jsonRequestBuilder(String url) { Request.Builder builder = new Request.Builder().url(url); builder.header("Content-Type", Constants.APPLICATION_JSON); if (!isBlank(agentFlowConfig.getApiKey())) { builder.header("Authorization", "Bearer " + agentFlowConfig.getApiKey()); } if (agentFlowConfig.getHeaders() != null) { for (Map.Entry entry : agentFlowConfig.getHeaders().entrySet()) { if (!isBlank(entry.getKey()) && entry.getValue() != null) { builder.header(entry.getKey(), entry.getValue()); } } } return builder; } protected String execute(Request request) throws IOException { try (Response response = okHttpClient.newCall(request).execute()) { return readResponse(request, response); } } protected JSONObject executeObject(Request request) throws IOException { String body = execute(request); if (isBlank(body)) { return new JSONObject(); } Object parsed = JSON.parse(body); if (parsed instanceof JSONObject) { return (JSONObject) parsed; } throw new AgentFlowException("Expected JSON object response but got: " + body); } protected Object parseJsonOrText(String body) { if (isBlank(body)) { return null; } try { return JSON.parse(body); } catch (Exception ex) { return body; } } protected String readResponse(Request request, Response response) throws IOException { ResponseBody body = response.body(); String content = body == null ? "" : body.string(); if (!response.isSuccessful()) { throw new AgentFlowException("HTTP " + response.code() + " calling " + request.url() + ": " + abbreviate(content)); } return content; } protected void assertCozeSuccess(JSONObject response) { Integer code = response == null ? null : response.getInteger("code"); if (code != null && code.intValue() != 0) { throw new AgentFlowException("Coze request failed: code=" + code + ", msg=" + response.getString("msg")); } } protected Map mutableMap(Map source) { if (source == null || source.isEmpty()) { return new LinkedHashMap(); } return new LinkedHashMap(source); } protected Map toStringMap(Map source) { if (source == null || source.isEmpty()) { return Collections.emptyMap(); } Map result = new LinkedHashMap(); for (Map.Entry entry : source.entrySet()) { if (entry.getKey() != null && entry.getValue() != null) { result.put(entry.getKey(), String.valueOf(entry.getValue())); } } return result; } protected String extractText(Object value) { if (value == null) { return null; } if (value instanceof String) { return (String) value; } if (value instanceof JSONObject) { JSONObject jsonObject = (JSONObject) value; String direct = firstNonBlank( jsonObject.getString("answer"), jsonObject.getString("output"), jsonObject.getString("text"), jsonObject.getString("content"), jsonObject.getString("result"), jsonObject.getString("message") ); if (!isBlank(direct)) { return direct; } if (jsonObject.size() == 1) { Map.Entry entry = jsonObject.entrySet().iterator().next(); return extractText(entry.getValue()); } return JSON.toJSONString(jsonObject); } return String.valueOf(value); } protected AgentFlowUsage usageFromDify(JSONObject usage) { if (usage == null || usage.isEmpty()) { return null; } return AgentFlowUsage.builder() .inputTokens(usage.getInteger("prompt_tokens")) .outputTokens(usage.getInteger("completion_tokens")) .totalTokens(usage.getInteger("total_tokens")) .raw(usage) .build(); } protected AgentFlowUsage usageFromCoze(JSONObject usage) { if (usage == null || usage.isEmpty()) { return null; } return AgentFlowUsage.builder() .inputTokens(firstNonNullInteger(usage.getInteger("input_tokens"), usage.getInteger("input_count"))) .outputTokens(firstNonNullInteger(usage.getInteger("output_tokens"), usage.getInteger("output_count"))) .totalTokens(usage.getInteger("token_count")) .raw(usage) .build(); } protected Integer firstNonNullInteger(Integer first, Integer second) { return first != null ? first : second; } protected String firstNonBlank(String first, String second) { return firstNonBlank(first, second, null, null, null, null); } protected String firstNonBlank(String first, String second, String third, String fourth, String fifth, String sixth) { String[] values = new String[]{first, second, third, fourth, fifth, sixth}; for (String value : values) { if (!isBlank(value)) { return value; } } return null; } protected long pollIntervalMillis() { Long value = agentFlowConfig.getPollIntervalMillis(); return value == null || value.longValue() <= 0L ? 1_000L : value.longValue(); } protected long pollTimeoutMillis() { Long value = agentFlowConfig.getPollTimeoutMillis(); return value == null || value.longValue() <= 0L ? 60_000L : value.longValue(); } protected void sleep(long millis) throws InterruptedException { if (millis > 0L) { Thread.sleep(millis); } } protected String abbreviate(String value) { if (value == null) { return null; } if (value.length() <= 500) { return value; } return value.substring(0, 500) + "..."; } protected boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } protected AgentFlowTraceContext startTrace(String operation, boolean streaming, Object request) { AgentFlowTraceContext context = AgentFlowTraceContext.builder() .executionId(UUID.randomUUID().toString()) .type(agentFlowConfig.getType()) .operation(operation) .streaming(streaming) .startedAt(System.currentTimeMillis()) .baseUrl(agentFlowConfig.getBaseUrl()) .webhookUrl(agentFlowConfig.getWebhookUrl()) .botId(agentFlowConfig.getBotId()) .workflowId(agentFlowConfig.getWorkflowId()) .appId(agentFlowConfig.getAppId()) .configuredUserId(agentFlowConfig.getUserId()) .configuredConversationId(agentFlowConfig.getConversationId()) .request(request) .build(); notifyTraceStart(context); return context; } protected void traceEvent(AgentFlowTraceContext context, Object event) { List listeners = traceListeners(); if (context == null || event == null || listeners.isEmpty()) { return; } for (AgentFlowTraceListener listener : listeners) { if (listener == null) { continue; } try { listener.onEvent(context, event); } catch (Throwable ignored) { // Trace listeners must never break the primary AgentFlow call path. } } } protected void traceComplete(AgentFlowTraceContext context, Object response) { List listeners = traceListeners(); if (context == null || listeners.isEmpty()) { return; } for (AgentFlowTraceListener listener : listeners) { if (listener == null) { continue; } try { listener.onComplete(context, response); } catch (Throwable ignored) { // Trace listeners must never break the primary AgentFlow call path. } } } protected void traceError(AgentFlowTraceContext context, Throwable throwable) { List listeners = traceListeners(); if (context == null || throwable == null || listeners.isEmpty()) { return; } for (AgentFlowTraceListener listener : listeners) { if (listener == null) { continue; } try { listener.onError(context, throwable); } catch (Throwable ignored) { // Trace listeners must never break the primary AgentFlow call path. } } } private void notifyTraceStart(AgentFlowTraceContext context) { List listeners = traceListeners(); if (context == null || listeners.isEmpty()) { return; } for (AgentFlowTraceListener listener : listeners) { if (listener == null) { continue; } try { listener.onStart(context); } catch (Throwable ignored) { // Trace listeners must never break the primary AgentFlow call path. } } } private List traceListeners() { List listeners = agentFlowConfig.getTraceListeners(); return listeners == null ? Collections.emptyList() : listeners; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/trace/AgentFlowTraceContext.java ================================================ package io.github.lnyocly.ai4j.agentflow.trace; import io.github.lnyocly.ai4j.agentflow.AgentFlowType; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowTraceContext { private String executionId; private AgentFlowType type; private String operation; private boolean streaming; private long startedAt; private String baseUrl; private String webhookUrl; private String botId; private String workflowId; private String appId; private String configuredUserId; private String configuredConversationId; private Object request; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/trace/AgentFlowTraceListener.java ================================================ package io.github.lnyocly.ai4j.agentflow.trace; public interface AgentFlowTraceListener { default void onStart(AgentFlowTraceContext context) { } default void onEvent(AgentFlowTraceContext context, Object event) { } default void onComplete(AgentFlowTraceContext context, Object response) { } default void onError(AgentFlowTraceContext context, Throwable throwable) { } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowEvent.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowWorkflowEvent { private String type; private String status; private String outputText; @Builder.Default private Map outputs = Collections.emptyMap(); private String taskId; private String workflowRunId; private boolean done; private AgentFlowUsage usage; private Object raw; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowListener.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; public interface AgentFlowWorkflowListener { void onEvent(AgentFlowWorkflowEvent event); default void onOpen() { } default void onError(Throwable throwable) { } default void onComplete(AgentFlowWorkflowResponse response) { } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowRequest.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowWorkflowRequest { @Builder.Default private Map inputs = Collections.emptyMap(); private String userId; private String workflowId; @Builder.Default private Map metadata = Collections.emptyMap(); @Builder.Default private Map extraBody = Collections.emptyMap(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowResponse.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class AgentFlowWorkflowResponse { private String status; private String outputText; @Builder.Default private Map outputs = Collections.emptyMap(); private String taskId; private String workflowRunId; private AgentFlowUsage usage; private Object raw; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowService.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; public interface AgentFlowWorkflowService { AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception; void runStream(AgentFlowWorkflowRequest request, AgentFlowWorkflowListener listener) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/CozeAgentFlowWorkflowService.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.AgentFlowConfig; import io.github.lnyocly.ai4j.agentflow.AgentFlowException; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.Request; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; public class CozeAgentFlowWorkflowService extends AgentFlowSupport implements AgentFlowWorkflowService { public CozeAgentFlowWorkflowService(Configuration configuration, AgentFlowConfig agentFlowConfig) { super(configuration, agentFlowConfig); } @Override public AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception { AgentFlowTraceContext traceContext = startTrace("workflow", false, request); try { JSONObject response = executeObject(buildRunRequest(request, false)); assertCozeSuccess(response); Object dataValue = response.get("data"); Object parsedData = parseWorkflowData(dataValue); Map outputs = parsedData instanceof JSONObject ? new LinkedHashMap((JSONObject) parsedData) : Collections.emptyMap(); AgentFlowWorkflowResponse workflowResponse = AgentFlowWorkflowResponse.builder() .status("completed") .outputText(extractText(parsedData)) .outputs(outputs) .workflowRunId(response.getString("execute_id")) .usage(usageFromCoze(response.getJSONObject("usage"))) .raw(response) .build(); traceComplete(traceContext, workflowResponse); return workflowResponse; } catch (Exception ex) { traceError(traceContext, ex); throw ex; } } @Override public void runStream(AgentFlowWorkflowRequest request, final AgentFlowWorkflowListener listener) throws Exception { if (listener == null) { throw new IllegalArgumentException("listener is required"); } final AgentFlowTraceContext traceContext = startTrace("workflow", true, request); Request httpRequest = buildRunRequest(request, true); final CountDownLatch latch = new CountDownLatch(1); final AtomicReference failure = new AtomicReference(); final AtomicReference completion = new AtomicReference(); final AtomicReference usageRef = new AtomicReference(); final StringBuilder content = new StringBuilder(); final AtomicBoolean closed = new AtomicBoolean(false); eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { listener.onOpen(); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { try { String eventType = type; JSONObject payload = parseObjectOrNull(data); boolean done = false; String outputText = null; if ("Message".equals(eventType)) { outputText = payload == null ? null : payload.getString("content"); if (!isBlank(outputText)) { content.append(outputText); } AgentFlowUsage usage = payload == null ? null : usageFromCoze(payload.getJSONObject("usage")); if (usage != null) { usageRef.set(usage); } } else if ("Interrupt".equals(eventType)) { throw new AgentFlowException("Coze workflow interrupted: " + data); } else if ("Error".equals(eventType)) { throw new AgentFlowException("Coze workflow stream error: " + data); } else if ("Done".equals(eventType)) { done = true; } AgentFlowWorkflowEvent event = AgentFlowWorkflowEvent.builder() .type(eventType) .status(done ? "completed" : null) .outputText(outputText) .done(done) .usage(usageRef.get()) .raw(payload == null ? data : payload) .build(); listener.onEvent(event); traceEvent(traceContext, event); if (done) { AgentFlowWorkflowResponse responsePayload = AgentFlowWorkflowResponse.builder() .status("completed") .outputText(content.toString()) .usage(usageRef.get()) .raw(payload == null ? data : payload) .build(); completion.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); closed.set(true); eventSource.cancel(); latch.countDown(); } } catch (Throwable ex) { failure.set(ex); traceError(traceContext, ex); listener.onError(ex); closed.set(true); eventSource.cancel(); latch.countDown(); } } @Override public void onClosed(@NotNull EventSource eventSource) { if (closed.compareAndSet(false, true)) { AgentFlowWorkflowResponse responsePayload = completion.get(); if (responsePayload == null) { responsePayload = AgentFlowWorkflowResponse.builder() .status("completed") .outputText(content.toString()) .usage(usageRef.get()) .build(); completion.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); } latch.countDown(); } } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { Throwable error = t; if (error == null && response != null) { error = new AgentFlowException("Coze workflow stream failed: HTTP " + response.code()); } if (error == null) { error = new AgentFlowException("Coze workflow stream failed"); } failure.set(error); traceError(traceContext, error); listener.onError(error); closed.set(true); latch.countDown(); } }); if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) { throw new AgentFlowException("Coze workflow stream timed out"); } if (failure.get() != null) { if (failure.get() instanceof Exception) { throw (Exception) failure.get(); } throw new AgentFlowException("Coze workflow stream failed", failure.get()); } } private Request buildRunRequest(AgentFlowWorkflowRequest request, boolean stream) { String path = stream ? "v1/workflow/stream_run" : "v1/workflow/run"; String url = joinedUrl(requireBaseUrl(), path); return jsonRequestBuilder(url).post(jsonBody(buildRequestBody(request))).build(); } private JSONObject buildRequestBody(AgentFlowWorkflowRequest request) { JSONObject body = new JSONObject(); body.put("workflow_id", requireWorkflowId(request.getWorkflowId())); body.put("parameters", request.getInputs() == null ? Collections.emptyMap() : request.getInputs()); if (!isBlank(agentFlowConfig.getBotId())) { body.put("bot_id", agentFlowConfig.getBotId()); } if (!isBlank(agentFlowConfig.getAppId())) { body.put("app_id", agentFlowConfig.getAppId()); } if (request.getMetadata() != null && !request.getMetadata().isEmpty()) { body.put("ext", toStringMap(request.getMetadata())); } if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) { body.putAll(request.getExtraBody()); } return body; } private Object parseWorkflowData(Object dataValue) { if (dataValue == null) { return null; } if (!(dataValue instanceof String)) { return dataValue; } String text = (String) dataValue; if (isBlank(text)) { return null; } try { return JSON.parse(text); } catch (Exception ex) { return text; } } private JSONObject parseObjectOrNull(String data) { if (isBlank(data)) { return null; } try { Object parsed = JSON.parse(data); return parsed instanceof JSONObject ? (JSONObject) parsed : null; } catch (Exception ex) { return null; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/DifyAgentFlowWorkflowService.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.AgentFlowConfig; import io.github.lnyocly.ai4j.agentflow.AgentFlowException; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.Request; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; public class DifyAgentFlowWorkflowService extends AgentFlowSupport implements AgentFlowWorkflowService { public DifyAgentFlowWorkflowService(Configuration configuration, AgentFlowConfig agentFlowConfig) { super(configuration, agentFlowConfig); } @Override public AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception { AgentFlowTraceContext traceContext = startTrace("workflow", false, request); try { JSONObject body = buildRequestBody(request, "blocking"); String url = joinedUrl(requireBaseUrl(), "v1/workflows/run"); JSONObject response = executeObject(jsonRequestBuilder(url).post(jsonBody(body)).build()); AgentFlowWorkflowResponse workflowResponse = mapWorkflowResponse(response); traceComplete(traceContext, workflowResponse); return workflowResponse; } catch (Exception ex) { traceError(traceContext, ex); throw ex; } } @Override public void runStream(AgentFlowWorkflowRequest request, final AgentFlowWorkflowListener listener) throws Exception { if (listener == null) { throw new IllegalArgumentException("listener is required"); } final AgentFlowTraceContext traceContext = startTrace("workflow", true, request); JSONObject body = buildRequestBody(request, "streaming"); String url = joinedUrl(requireBaseUrl(), "v1/workflows/run"); Request httpRequest = jsonRequestBuilder(url).post(jsonBody(body)).build(); final CountDownLatch latch = new CountDownLatch(1); final AtomicReference failure = new AtomicReference(); final AtomicReference completion = new AtomicReference(); final AtomicReference taskIdRef = new AtomicReference(); final AtomicReference workflowRunIdRef = new AtomicReference(); final AtomicReference usageRef = new AtomicReference(); final AtomicReference> outputsRef = new AtomicReference>(Collections.emptyMap()); final StringBuilder content = new StringBuilder(); final AtomicBoolean closed = new AtomicBoolean(false); eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { listener.onOpen(); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { try { JSONObject payload = parseObjectOrNull(data); String eventType = firstNonBlank(type, payload == null ? null : payload.getString("event")); if (isBlank(eventType) || "ping".equals(eventType)) { return; } if (payload != null) { taskIdRef.set(firstNonBlank(payload.getString("task_id"), taskIdRef.get())); workflowRunIdRef.set(firstNonBlank(payload.getString("workflow_run_id"), workflowRunIdRef.get())); } String outputText = null; String status = null; Map outputs = outputsRef.get(); boolean done = false; if ("workflow_finished".equals(eventType)) { done = true; JSONObject dataObject = payload == null ? null : payload.getJSONObject("data"); status = dataObject == null ? null : dataObject.getString("status"); JSONObject outputObject = dataObject == null ? null : dataObject.getJSONObject("outputs"); outputs = outputObject == null ? Collections.emptyMap() : new LinkedHashMap(outputObject); outputsRef.set(outputs); outputText = extractText(outputObject); if (!isBlank(outputText)) { content.setLength(0); content.append(outputText); } AgentFlowUsage usage = usageFromDify(dataObject == null ? null : dataObject.getJSONObject("usage")); if (usage != null) { usageRef.set(usage); } } else if ("message".equals(eventType) || "text_chunk".equals(eventType)) { outputText = payload == null ? null : firstNonBlank(payload.getString("answer"), payload.getString("text")); if (!isBlank(outputText)) { content.append(outputText); } } else if ("error".equals(eventType)) { throw new AgentFlowException("Dify workflow stream error: " + (payload == null ? data : payload.toJSONString())); } AgentFlowWorkflowEvent event = AgentFlowWorkflowEvent.builder() .type(eventType) .status(status) .outputText(outputText) .outputs(outputs) .taskId(taskIdRef.get()) .workflowRunId(workflowRunIdRef.get()) .done(done) .usage(usageRef.get()) .raw(payload == null ? data : payload) .build(); listener.onEvent(event); traceEvent(traceContext, event); if (done) { AgentFlowWorkflowResponse responsePayload = AgentFlowWorkflowResponse.builder() .status(status) .outputText(content.toString()) .outputs(outputsRef.get()) .taskId(taskIdRef.get()) .workflowRunId(workflowRunIdRef.get()) .usage(usageRef.get()) .raw(payload) .build(); completion.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); closed.set(true); eventSource.cancel(); latch.countDown(); } } catch (Throwable ex) { failure.set(ex); traceError(traceContext, ex); listener.onError(ex); closed.set(true); eventSource.cancel(); latch.countDown(); } } @Override public void onClosed(@NotNull EventSource eventSource) { if (closed.compareAndSet(false, true)) { AgentFlowWorkflowResponse responsePayload = completion.get(); if (responsePayload == null) { responsePayload = AgentFlowWorkflowResponse.builder() .outputText(content.toString()) .outputs(outputsRef.get()) .taskId(taskIdRef.get()) .workflowRunId(workflowRunIdRef.get()) .usage(usageRef.get()) .build(); completion.set(responsePayload); listener.onComplete(responsePayload); traceComplete(traceContext, responsePayload); } latch.countDown(); } } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { Throwable error = t; if (error == null && response != null) { error = new AgentFlowException("Dify workflow stream failed: HTTP " + response.code()); } if (error == null) { error = new AgentFlowException("Dify workflow stream failed"); } failure.set(error); traceError(traceContext, error); listener.onError(error); closed.set(true); latch.countDown(); } }); if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) { throw new AgentFlowException("Dify workflow stream timed out"); } if (failure.get() != null) { if (failure.get() instanceof Exception) { throw (Exception) failure.get(); } throw new AgentFlowException("Dify workflow stream failed", failure.get()); } } private JSONObject buildRequestBody(AgentFlowWorkflowRequest request, String responseMode) { JSONObject body = new JSONObject(); body.put("inputs", request.getInputs() == null ? Collections.emptyMap() : request.getInputs()); body.put("user", defaultUserId(request.getUserId())); body.put("response_mode", responseMode); if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) { body.putAll(request.getExtraBody()); } return body; } private AgentFlowWorkflowResponse mapWorkflowResponse(JSONObject response) { JSONObject data = response.getJSONObject("data"); JSONObject outputs = data == null ? null : data.getJSONObject("outputs"); Map outputMap = outputs == null ? Collections.emptyMap() : new LinkedHashMap(outputs); return AgentFlowWorkflowResponse.builder() .status(data == null ? null : data.getString("status")) .outputText(extractText(outputs)) .outputs(outputMap) .taskId(response.getString("task_id")) .workflowRunId(firstNonBlank(response.getString("workflow_run_id"), data == null ? null : data.getString("id"))) .usage(usageFromDify(data == null ? null : data.getJSONObject("usage"))) .raw(response) .build(); } private JSONObject parseObjectOrNull(String data) { if (isBlank(data)) { return null; } try { Object parsed = JSON.parse(data); return parsed instanceof JSONObject ? (JSONObject) parsed : null; } catch (Exception ex) { return null; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/N8nAgentFlowWorkflowService.java ================================================ package io.github.lnyocly.ai4j.agentflow.workflow; import io.github.lnyocly.ai4j.agentflow.AgentFlowConfig; import io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.Request; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; public class N8nAgentFlowWorkflowService extends AgentFlowSupport implements AgentFlowWorkflowService { public N8nAgentFlowWorkflowService(Configuration configuration, AgentFlowConfig agentFlowConfig) { super(configuration, agentFlowConfig); } @Override @SuppressWarnings("unchecked") public AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception { AgentFlowTraceContext traceContext = startTrace("workflow", false, request); try { Map payload = new LinkedHashMap(); if (request.getInputs() != null) { payload.putAll(request.getInputs()); } if (request.getMetadata() != null && !request.getMetadata().isEmpty()) { payload.put("_metadata", request.getMetadata()); } if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) { payload.putAll(request.getExtraBody()); } Request httpRequest = jsonRequestBuilder(requireWebhookUrl()).post(jsonBody(payload)).build(); String responseBody = execute(httpRequest); Object raw = parseJsonOrText(responseBody); Map outputs = raw instanceof Map ? new LinkedHashMap((Map) raw) : Collections.emptyMap(); AgentFlowWorkflowResponse workflowResponse = AgentFlowWorkflowResponse.builder() .status("completed") .outputText(extractText(raw)) .outputs(outputs) .raw(raw) .build(); traceComplete(traceContext, workflowResponse); return workflowResponse; } catch (Exception ex) { traceError(traceContext, ex); throw ex; } } @Override public void runStream(AgentFlowWorkflowRequest request, AgentFlowWorkflowListener listener) { throw new UnsupportedOperationException("n8n workflow streaming is not supported yet"); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/annotation/FunctionCall.java ================================================ package io.github.lnyocly.ai4j.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Author cly * @Description TODO * @Date 2024/8/12 15:50 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface FunctionCall { String name(); String description(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/annotation/FunctionParameter.java ================================================ package io.github.lnyocly.ai4j.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Author cly * @Description TODO * @Date 2024/8/12 15:55 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface FunctionParameter { String description(); boolean required() default true; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/annotation/FunctionRequest.java ================================================ package io.github.lnyocly.ai4j.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Author cly * @Description TODO * @Date 2024/8/12 15:55 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface FunctionRequest { String description() default ""; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/auth/BearerTokenUtils.java ================================================ package io.github.lnyocly.ai4j.auth; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.xml.bind.DatatypeConverter; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.TimeZone; import java.util.concurrent.TimeUnit; public class BearerTokenUtils { // 过期时间;默认24小时 private static final long EXPIRE_MILLIS = 24 * 60 * 60 * 1000L; // 缓存服务 public static Cache cache = CacheBuilder.newBuilder() .initialCapacity(100) .expireAfterWrite(EXPIRE_MILLIS - (60 * 1000L), TimeUnit.MILLISECONDS) .build(); /** * 对 API Key 进行签名 * 新版机制中平台颁发的 API Key 同时包含 “用户标识 id” 和 “签名密钥 secret”,即格式为 {id}.{secret} * * @param apiKey 智谱APIkey * @return Token */ public static String getToken(String apiKey) { // 分割APIKEY String[] args = apiKey.split("\\."); if (args.length != 2) { throw new IllegalArgumentException("API Key 格式错误"); } String id = args[0]; String secret = args[1]; // 缓存Token String token = cache.getIfPresent(apiKey); if (null != token) return token; // 创建Token Algorithm algorithm = Algorithm.HMAC256(secret.getBytes(StandardCharsets.UTF_8)); Map payload = new HashMap<>(); payload.put("api_key", id); payload.put("exp", System.currentTimeMillis() + EXPIRE_MILLIS); payload.put("timestamp", System.currentTimeMillis()); Map headerClaims = new HashMap<>(); headerClaims.put("alg", "HS256"); headerClaims.put("sign_type", "SIGN"); token = JWT.create().withPayload(payload).withHeader(headerClaims).sign(algorithm); cache.put(id, token); return token; } /** * apiKey 属于SecretId与SecretKey的拼接,格式为 {SecretId}.{SecretKey} * * @param apiKey 腾讯混元APIkey * @return */ public static String getAuthorization(String apiKey, String action, String payloadJson) throws Exception { String[] args = apiKey.split("\\."); if (args.length != 2) { throw new IllegalArgumentException("API Key 格式错误"); } String id = args[0]; String key = args[1]; String algorithm = "TC3-HMAC-SHA256"; String service = "hunyuan"; String host = "hunyuan.tencentcloudapi.com"; String timestamp = String.valueOf(System.currentTimeMillis() / 1000); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); // 注意时区,否则容易出错 sdf.setTimeZone(TimeZone.getTimeZone("UTC")); String date = sdf.format(new Date(Long.valueOf(timestamp + "000"))); // ************* 步骤 1:拼接规范请求串 ************* String httpRequestMethod = "POST"; String canonicalUri = "/"; String canonicalQueryString = ""; String canonicalHeaders = "content-type:application/json; charset=utf-8\n" + "host:" + host + "\n" + "x-tc-action:" + action.toLowerCase() + "\n"; String signedHeaders = "content-type;host;x-tc-action"; String hashedRequestPayload = sha256Hex(payloadJson); String canonicalRequest = httpRequestMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestPayload; // ************* 步骤 2:拼接待签名字符串 ************* String credentialScope = date + "/" + service + "/" + "tc3_request"; String hashedCanonicalRequest = sha256Hex(canonicalRequest); String stringToSign = algorithm + "\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; // ************* 步骤 3:计算签名 ************* byte[] secretDate = hmac256(("TC3" + key).getBytes(StandardCharsets.UTF_8), date); byte[] secretService = hmac256(secretDate, service); byte[] secretSigning = hmac256(secretService, "tc3_request"); String signature = DatatypeConverter.printHexBinary(hmac256(secretSigning, stringToSign)).toLowerCase(); // ************* 步骤 4:拼接 Authorization ************* String authorization = algorithm + " " + "Credential=" + id + "/" + credentialScope + ", " + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature; return authorization; } private static byte[] hmac256(byte[] key, String msg) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm()); mac.init(secretKeySpec); return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8)); } private static String sha256Hex(String s) throws Exception { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] d = md.digest(s.getBytes(StandardCharsets.UTF_8)); return DatatypeConverter.printHexBinary(d).toLowerCase(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/AiPlatform.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.Data; @Data public class AiPlatform { private String id; private String platform; private String apiHost; private String apiKey; private String chatCompletionUrl; private String embeddingUrl; private String speechUrl; private String transcriptionUrl; private String translationUrl; private String realtimeUrl; private String rerankApiHost; private String rerankUrl; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/BaichuanConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class BaichuanConfig { private String apiHost = "https://api.baichuan-ai.com/"; private String apiKey = ""; private String chatCompletionUrl = "v1/chat/completions"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/DashScopeConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description OpenAi骞冲彴閰嶇疆鏂囦欢淇℃伅 * @Date 2024/8/8 0:18 */ @Data @NoArgsConstructor @AllArgsConstructor public class DashScopeConfig { private String apiHost = "https://dashscope.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1/"; private String responsesUrl = "responses"; private String apiKey = ""; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/DeepSeekConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description DeepSeek 配置文件 * @Date 2024/8/29 10:31 */ @Data @NoArgsConstructor @AllArgsConstructor public class DeepSeekConfig { private String apiHost = "https://api.deepseek.com/"; private String apiKey = ""; private String chatCompletionUrl = "chat/completions"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/DoubaoConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 璞嗗寘(鐏北寮曟搸鏂硅垷) 閰嶇疆鏂囦欢 */ @Data @NoArgsConstructor @AllArgsConstructor public class DoubaoConfig { private String apiHost = "https://ark.cn-beijing.volces.com/api/v3/"; private String apiKey = ""; private String chatCompletionUrl = "chat/completions"; private String imageGenerationUrl = "images/generations"; private String responsesUrl = "responses"; private String rerankApiHost = "https://api-knowledgebase.mlp.cn-beijing.volces.com/"; private String rerankUrl = "api/knowledge/service/rerank"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/HunyuanConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 腾讯混元配置 * @Date 2024/8/30 19:50 */ @Data @NoArgsConstructor @AllArgsConstructor public class HunyuanConfig { private String apiHost = "https://hunyuan.tencentcloudapi.com/"; /** * apiKey 属于SecretId与SecretKey的拼接,格式为 {SecretId}.{SecretKey} */ private String apiKey = ""; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/JinaConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class JinaConfig { private String apiHost = "https://api.jina.ai/"; private String apiKey = ""; private String rerankUrl = "v1/rerank"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/LingyiConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 零一万物大模型 * @Date 2024/9/9 22:53 */ @Data @NoArgsConstructor @AllArgsConstructor public class LingyiConfig { private String apiHost = "https://api.lingyiwanwu.com/"; private String apiKey = ""; private String chatCompletionUrl = "v1/chat/completions"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/McpConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.Data; /** * @Author cly * @Description MCP (Model Context Protocol) 配置类 */ @Data public class McpConfig { /** * MCP服务器地址 */ private String serverUrl; /** * MCP服务器端口 */ private Integer serverPort = 3000; /** * 连接超时时间(毫秒) */ private Long connectTimeout = 30000L; /** * 读取超时时间(毫秒) */ private Long readTimeout = 60000L; /** * 写入超时时间(毫秒) */ private Long writeTimeout = 60000L; /** * 是否启用SSL */ private Boolean enableSsl = false; /** * 认证令牌 */ private String authToken; /** * 客户端名称 */ private String clientName = "ai4j-mcp-client"; /** * 客户端版本 */ private String clientVersion = "1.0.0"; /** * 最大重连次数 */ private Integer maxRetries = 3; /** * 重连间隔(毫秒) */ private Long retryInterval = 5000L; /** * 是否启用心跳检测 */ private Boolean enableHeartbeat = true; /** * 心跳间隔(毫秒) */ private Long heartbeatInterval = 30000L; /** * 消息队列大小 */ private Integer messageQueueSize = 1000; /** * 是否启用消息压缩 */ private Boolean enableCompression = false; /** * 传输类型:stdio, http, websocket */ private String transportType = "http"; /** * 获取完整的服务器URL */ public String getFullServerUrl() { if (serverUrl == null) { return null; } String protocol = enableSsl ? "https" : "http"; if (serverUrl.startsWith("http://") || serverUrl.startsWith("https://")) { return serverUrl; } return String.format("%s://%s:%d", protocol, serverUrl, serverPort); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/MilvusConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Arrays; import java.util.List; @Data @NoArgsConstructor @AllArgsConstructor public class MilvusConfig { private boolean enabled = false; private String host = "http://localhost:19530"; private String token = ""; private String dbName = ""; private String partitionName = ""; private String idField = "id"; private String vectorField = "vector"; private String contentField = "content"; private List outputFields = Arrays.asList( "id", "content", "documentId", "sourceName", "sourcePath", "sourceUri", "pageNumber", "sectionTitle", "chunkIndex" ); private String upsert = "/v2/vectordb/entities/upsert"; private String search = "/v2/vectordb/entities/search"; private String delete = "/v2/vectordb/entities/delete"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/MinimaxConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author : isxuwl * @Date: 2024/10/15 16:08 * @Model Description: * @Description: */ @Data @NoArgsConstructor @AllArgsConstructor public class MinimaxConfig { private String apiHost = "https://api.minimax.chat/"; private String apiKey = ""; private String chatCompletionUrl = "v1/text/chatcompletion_v2"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/MoonshotConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 月之暗面配置 * @Date 2024/8/29 23:00 */ @Data @NoArgsConstructor @AllArgsConstructor public class MoonshotConfig { private String apiHost = "https://api.moonshot.cn/"; private String apiKey = ""; private String chatCompletionUrl = "v1/chat/completions"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/OkHttpConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import okhttp3.logging.HttpLoggingInterceptor; /** * @Author cly * @Description OkHttp配置信息 * @Date 2024/8/11 0:11 */ @Data @NoArgsConstructor @AllArgsConstructor public class OkHttpConfig { private HttpLoggingInterceptor.Level log = HttpLoggingInterceptor.Level.HEADERS; private int connectTimeout = 300; private int writeTimeout = 300; private int readTimeout = 300; private int proxyPort = 10809; private String proxyHost = ""; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/OllamaConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description Ollama配置文件 * @Date 2024/9/20 11:07 */ @Data @NoArgsConstructor @AllArgsConstructor public class OllamaConfig { private String apiHost = "http://localhost:11434/"; private String apiKey = ""; private String chatCompletionUrl = "api/chat"; private String embeddingUrl = "api/embed"; private String rerankUrl = "api/rerank"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/OpenAiConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description OpenAi骞冲彴閰嶇疆鏂囦欢淇℃伅 * @Date 2024/8/8 0:18 */ @Data @NoArgsConstructor @AllArgsConstructor public class OpenAiConfig { private String apiHost = "https://api.openai.com/"; private String apiKey = ""; private String chatCompletionUrl = "v1/chat/completions"; private String embeddingUrl = "v1/embeddings"; private String speechUrl = "v1/audio/speech"; private String transcriptionUrl = "v1/audio/transcriptions"; private String translationUrl = "v1/audio/translations"; private String realtimeUrl = "v1/realtime"; private String imageGenerationUrl = "v1/images/generations"; private String responsesUrl = "v1/responses"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/PgVectorConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class PgVectorConfig { private boolean enabled = false; private String jdbcUrl = "jdbc:postgresql://localhost:5432/postgres"; private String username = ""; private String password = ""; private String tableName = "ai4j_vectors"; private String idColumn = "id"; private String datasetColumn = "dataset"; private String vectorColumn = "embedding"; private String contentColumn = "content"; private String metadataColumn = "metadata"; private String distanceOperator = "<=>"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/PineconeConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description Pinecone向量数据配置文件 * @Date 2024/8/16 16:37 */ @Data @NoArgsConstructor @AllArgsConstructor public class PineconeConfig { private String host = "https://xxx.svc.xxx.pinecone.io"; private String key = ""; private String upsert = "/vectors/upsert"; private String query = "/query"; private String delete = "/vectors/delete"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/QdrantConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class QdrantConfig { private boolean enabled = false; private String host = "http://localhost:6333"; private String apiKey = ""; private String vectorName = ""; private String upsert = "/collections/%s/points"; private String query = "/collections/%s/points/query"; private String delete = "/collections/%s/points/delete"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/config/ZhipuConfig.java ================================================ package io.github.lnyocly.ai4j.config; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 智谱AI平台配置信息 * @Date 2024/8/27 22:12 */ @Data @NoArgsConstructor @AllArgsConstructor public class ZhipuConfig { private String apiHost = "https://open.bigmodel.cn/api/paas/"; private String apiKey = ""; private String chatCompletionUrl = "v4/chat/completions"; private String embeddingUrl= "v4/embeddings"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/constant/Constants.java ================================================ package io.github.lnyocly.ai4j.constant; /** * @Author cly * @Description TODO * @Date 2024/8/11 0:19 */ public class Constants { public static final String SSE_CONTENT_TYPE = "text/event-stream"; public static final String DEFAULT_USER_AGENT = "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)"; public static final String APPLICATION_JSON = "application/json"; public static final String JSON_CONTENT_TYPE = APPLICATION_JSON + "; charset=utf-8"; public static final String METADATA_KEY = "content"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/convert/audio/AudioParameterConvert.java ================================================ package io.github.lnyocly.ai4j.convert.audio; /** * @Author cly * @Description 处理请求参数。 由统一的OpenAi音频格式--->其它模型格式 * @Date 2024/10/12 13:36 */ public interface AudioParameterConvert { } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/convert/audio/AudioResultConvert.java ================================================ package io.github.lnyocly.ai4j.convert.audio; /** * @Author cly * @Description 返回结果统一处理。 其它模型音频返回格式--->统一的OpenAi返回格式 * @Date 2024/10/12 13:35 */ public interface AudioResultConvert { } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/convert/chat/ParameterConvert.java ================================================ package io.github.lnyocly.ai4j.convert.chat; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; /** * @Author cly * @Description 处理请求参数 统一的OpenAi格式--->其它模型格式 * @Date 2024/8/12 1:04 */ public interface ParameterConvert { T convertChatCompletionObject(ChatCompletion chatCompletion); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/convert/chat/ResultConvert.java ================================================ package io.github.lnyocly.ai4j.convert.chat; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import okhttp3.sse.EventSourceListener; /** * @Author cly * @Description 处理结果输出 其它模型格式--->统一的OpenAi格式 * @Date 2024/8/12 1:05 */ public interface ResultConvert { EventSourceListener convertEventSource(SseListener eventSourceListener); ChatCompletionResponse convertChatCompletionResponse(T t); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/convert/embedding/EmbeddingParameterConvert.java ================================================ package io.github.lnyocly.ai4j.convert.embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; /** * EmbeddingParameterConvert * @param */ public interface EmbeddingParameterConvert { T convertEmbeddingRequest(Embedding embeddingRequest); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/convert/embedding/EmbeddingResultConvert.java ================================================ package io.github.lnyocly.ai4j.convert.embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; /** * @Author cly * @param */ public interface EmbeddingResultConvert { EmbeddingResponse convertEmbeddingResponse(T t); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/document/RecursiveCharacterTextSplitter.java ================================================ package io.github.lnyocly.ai4j.document; import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.Arrays; import java.util.List; /** * @Author cly * @Description 分词器 * @Date 2024/8/2 22:59 */ @Slf4j public class RecursiveCharacterTextSplitter { private List separators; private int chunkSize = 500; private int chunkOverlap = 50; // 构造函数,接受分隔符列表、块大小和块重叠作为参数 public RecursiveCharacterTextSplitter(List separators, int chunkSize, int chunkOverlap) { // 如果分隔符列表为null,则使用默认值 if (separators == null) { this.separators = Arrays.asList("\n\n", "\n", " ", ""); } else { this.separators = separators; } this.chunkSize = chunkSize; this.chunkOverlap = chunkOverlap; } public RecursiveCharacterTextSplitter(int chunkSize, int chunkOverlap) { this.separators = Arrays.asList("\n\n", "\n", " ", ""); this.chunkSize = chunkSize; this.chunkOverlap = chunkOverlap; } // 将文本分割成块的方法 public List splitText(String text) { // 声明一个空的字符串列表,用于存储最终的文本块 List finalChunks = new ArrayList<>(); String separator = separators.get(separators.size() - 1); // 循环遍历分隔符列表,找到可以在文本中找到的最合适的分隔符 for (String s : separators) { if (text.contains(s) || s.isEmpty()) { separator = s; break; } } List splits = Arrays.asList(text.split(separator)); // 声明一个空的字符串列表,用于存储长度小于块大小的子字符串 List goodSplits = new ArrayList<>(); // 循环遍历子字符串列表,将较短的子字符串添加到goodSplits列表中,将较长的子字符串递归地传递给splitText方法 for (String s : splits) { if (s.length() < chunkSize) { goodSplits.add(s); } else { if (!goodSplits.isEmpty()) { // 将goodSplits列表中的子字符串合并为一个文本块,并将其添加到最终的文本块列表中 List mergedText = mergeSplits(goodSplits, separator); finalChunks.addAll(mergedText); goodSplits.clear(); } // 递归地将较长的子字符串传递给splitText方法 List otherInfo = splitText(s); finalChunks.addAll(otherInfo); } } if (!goodSplits.isEmpty()) { List mergedText = mergeSplits(goodSplits, separator); finalChunks.addAll(mergedText); } return finalChunks; } private List mergeSplits(List splits, String separator) { int separatorLen = separator.length(); List docs = new ArrayList<>(); List currentDoc = new ArrayList<>(); int total = 0; for (String d : splits) { int len = d.length(); if (total + len + (separatorLen > 0 && !currentDoc.isEmpty() ? separatorLen : 0) > chunkSize) { if (total > chunkSize) { log.warn("Warning: Created a chunk of size {}, which is longer than the specified {}", total, chunkSize); } if (!currentDoc.isEmpty()) { String doc = joinDocs(currentDoc, separator); if (doc != null) { docs.add(doc); } // 通过移除currentDoc中的文档,将currentDoc的长度减小到指定的文档重叠长度chunkOverlap或更小, 结果存到下一个chunk的开始位置 while (total > chunkOverlap || (total + len + (separatorLen > 0 && !currentDoc.isEmpty() ? separatorLen : 0) > chunkSize && total > 0)) { total -= currentDoc.get(0).length() + (separatorLen > 0 && currentDoc.size() > 1 ? separatorLen : 0); currentDoc.remove(0); } } } currentDoc.add(d); total += len + (separatorLen > 0 && currentDoc.size() > 1 ? separatorLen : 0); } String doc = joinDocs(currentDoc, separator); if (doc != null) { docs.add(doc); } return docs; } private String joinDocs(List docs, String separator) { if (docs.isEmpty()) { return null; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < docs.size(); i++) { sb.append(docs.get(i)); if (i < docs.size() - 1) { sb.append(separator); } } return sb.toString(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/document/TikaUtil.java ================================================ package io.github.lnyocly.ai4j.document; import org.apache.tika.Tika; import org.apache.tika.exception.TikaException; import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.AutoDetectParser; import org.apache.tika.parser.ParseContext; import org.apache.tika.sax.BodyContentHandler; import org.xml.sax.SAXException; import java.io.File; import java.io.IOException; import java.io.InputStream; public class TikaUtil { private static final Tika tika = new Tika(); /** * 解析File文件,返回文档内容 * @param file 要解析的文件 * @return 解析后的文档内容 * @throws IOException * @throws TikaException * @throws SAXException */ public static String parseFile(File file) throws IOException, TikaException, SAXException { try (InputStream stream = file.toURI().toURL().openStream()) { return parseInputStream(stream); } } /** * 解析InputStream输入流,返回文档内容 * @param stream 要解析的输入流 * @return 解析后的文档内容 * @throws IOException * @throws TikaException * @throws SAXException */ public static String parseInputStream(InputStream stream) throws IOException, TikaException, SAXException { BodyContentHandler handler = new BodyContentHandler(); Metadata metadata = new Metadata(); AutoDetectParser parser = new AutoDetectParser(); ParseContext context = new ParseContext(); parser.parse(stream, handler, metadata, context); return handler.toString(); } /** * 使用Tika简单接口解析文件,返回文档内容 * @param file 要解析的文件 * @return 解析后的文档内容 * @throws IOException * @throws TikaException */ public static String parseFileWithTika(File file) throws IOException, TikaException { return tika.parseToString(file); } /** * 解析InputStream输入流,使用Tika简单接口,返回文档内容 * @param stream 要解析的输入流 * @return 解析后的文档内容 * @throws IOException * @throws TikaException */ public static String parseInputStreamWithTika(InputStream stream) throws IOException, TikaException { return tika.parseToString(stream); } /** * 检测File文件的MIME类型 * @param file 要检测的文件 * @return MIME类型 * @throws IOException */ public static String detectMimeType(File file) throws IOException { return tika.detect(file); } /** * 检测InputStream输入流的MIME类型 * @param stream 要检测的输入流 * @return MIME类型 * @throws IOException */ public static String detectMimeType(InputStream stream) throws IOException { return tika.detect(stream); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/Ai4jException.java ================================================ package io.github.lnyocly.ai4j.exception; /** * Base runtime exception for ai4j shared abstractions. */ public class Ai4jException extends RuntimeException { public Ai4jException(String message) { super(message); } public Ai4jException(String message, Throwable cause) { super(message, cause); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/CommonException.java ================================================ package io.github.lnyocly.ai4j.exception; /** * Legacy exception kept for 1.x compatibility. */ public class CommonException extends Ai4jException { public CommonException(String msg) { super(msg); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/AbstractErrorHandler.java ================================================ package io.github.lnyocly.ai4j.exception.chain; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; /** * @Author cly * @Description 错误处理抽象 * @Date 2024/9/18 20:57 */ public abstract class AbstractErrorHandler implements IErrorHandler{ protected IErrorHandler nextHandler; @Override public void setNext(IErrorHandler handler) { this.nextHandler = handler; } protected Error handleNext(String errorInfo) { if (nextHandler != null) { return nextHandler.parseError(errorInfo); } return null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/ErrorHandler.java ================================================ package io.github.lnyocly.ai4j.exception.chain; import io.github.lnyocly.ai4j.exception.chain.impl.HunyuanErrorHandler; import io.github.lnyocly.ai4j.exception.chain.impl.OpenAiErrorHandler; import io.github.lnyocly.ai4j.exception.chain.impl.UnknownErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description 创建错误处理的单例 * @Date 2024/9/18 21:09 */ public class ErrorHandler { private List handlers; private IErrorHandler chain; private ErrorHandler() { handlers = new ArrayList<>(); // 添加错误处理器 handlers.add(new OpenAiErrorHandler()); handlers.add(new HunyuanErrorHandler()); // 兜底的错误处理 handlers.add(new UnknownErrorHandler()); // 组装链 this.assembleChain(); } private void assembleChain(){ chain = handlers.get(0); IErrorHandler curr = handlers.get(0); for (int i = 1; i < handlers.size(); i++) { curr.setNext(handlers.get(i)); curr = handlers.get(i); } } private static class ErrorHandlerHolder { private static final ErrorHandler INSTANCE = new ErrorHandler(); } public static ErrorHandler getInstance() { return ErrorHandlerHolder.INSTANCE; } public Error process(String errorSring){ return chain.parseError(errorSring); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/IErrorHandler.java ================================================ package io.github.lnyocly.ai4j.exception.chain; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; /** * @Author cly * @Description 错误处理接口 * @Date 2024/9/18 20:55 */ public interface IErrorHandler { void setNext(IErrorHandler handler); Error parseError(String errorInfo); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/impl/HunyuanErrorHandler.java ================================================ package io.github.lnyocly.ai4j.exception.chain.impl; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.exception.chain.AbstractErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.HunyuanError; import io.github.lnyocly.ai4j.exception.error.OpenAiError; import org.apache.commons.lang3.ObjectUtils; /** * @Author cly * @Description 混元错误处理 * @Date 2024/9/18 23:59 */ public class HunyuanErrorHandler extends AbstractErrorHandler { @Override public Error parseError(String errorInfo) { // 解析json字符串 try{ HunyuanError hunyuanError = JSON.parseObject(errorInfo, HunyuanError.class); HunyuanError.Response response = hunyuanError.getResponse(); if(ObjectUtils.isEmpty(response)){ // 交给下一个节点处理 return nextHandler.parseError(errorInfo); } HunyuanError.Response.Error error = response.getError(); if(ObjectUtils.isEmpty(error)){ // 交给下一个节点处理 return nextHandler.parseError(errorInfo); } return new Error(error.getMessage(),error.getCode(),null,error.getCode()); }catch (Exception e){ throw new CommonException(errorInfo); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/impl/OpenAiErrorHandler.java ================================================ package io.github.lnyocly.ai4j.exception.chain.impl; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.exception.chain.AbstractErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; import org.apache.commons.lang3.ObjectUtils; /** * @Author cly * @Description OpenAi错误处理 * * [openai, zhipu, deepseek, lingyi, moonshot] 错误返回类似,这里共用一个处理类 * * @Date 2024/9/18 21:01 */ public class OpenAiErrorHandler extends AbstractErrorHandler { @Override public Error parseError(String errorInfo) { // 解析json字符串 try{ OpenAiError openAiError = JSON.parseObject(errorInfo, OpenAiError.class); Error error = openAiError.getError(); if(ObjectUtils.isEmpty(error)){ // 交给下一个节点处理 return nextHandler.parseError(errorInfo); } return error; }catch (Exception e){ throw new CommonException(errorInfo); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/impl/UnknownErrorHandler.java ================================================ package io.github.lnyocly.ai4j.exception.chain.impl; import io.github.lnyocly.ai4j.exception.chain.AbstractErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; /** * @Author cly * @Description 未知的错误处理,用于兜底处理 * @Date 2024/9/18 21:08 */ public class UnknownErrorHandler extends AbstractErrorHandler { @Override public Error parseError(String errorInfo) { Error error = new Error(); error.setParam(null); error.setType("Unknown Type"); error.setCode("Unknown Code"); error.setMessage(errorInfo); return error; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/error/Error.java ================================================ package io.github.lnyocly.ai4j.exception.error; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 基础错误实体 * @Date 2024/9/18 23:50 */ @Data @AllArgsConstructor @NoArgsConstructor public class Error { private String message; private String type; private String param; private String code; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/error/HunyuanError.java ================================================ package io.github.lnyocly.ai4j.exception.error; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 腾讯混元错误实体 * * {"Response":{"RequestId":"e4650694-f018-4490-b4d0-d5242cd68106","Error":{"Code":"InvalidParameterValue.Model","Message":"模型不存在"}}} * * @Date 2024/9/18 21:28 */ @Data @AllArgsConstructor @NoArgsConstructor public class HunyuanError { private Response Response; @Data @AllArgsConstructor @NoArgsConstructor public class Response{ private Error Error; @Data @AllArgsConstructor @NoArgsConstructor public class Error{ private String Code; private String Message; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/exception/error/OpenAiError.java ================================================ package io.github.lnyocly.ai4j.exception.error; import lombok.AllArgsConstructor; import lombok.Data; import lombok.Getter; import lombok.NoArgsConstructor; /** * @Author cly * @Description OpenAi错误返回实体类 * * { * "error": { * "message": "Incorrect API key provided: sk-proj-*************************************************************************************************************************8YA1. You can find your API key at https://platform.openai.com/account/api-keys.", * "type": "invalid_request_error", * "param": null, * "code": "invalid_api_key" * } * } * * @Date 2024/9/18 18:44 */ @Data @AllArgsConstructor @NoArgsConstructor public class OpenAiError { private Error error; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/interceptor/ContentTypeInterceptor.java ================================================ package io.github.lnyocly.ai4j.interceptor; import okhttp3.Interceptor; import okhttp3.MediaType; import okhttp3.Response; import okhttp3.ResponseBody; import okio.Buffer; import okio.BufferedSource; import java.io.IOException; import java.nio.charset.StandardCharsets; /** * @Author cly * @Description TODO * @Date 2024/9/20 18:56 */ public class ContentTypeInterceptor implements Interceptor { private static final MediaType EVENT_STREAM_MEDIA_TYPE = MediaType.get("text/event-stream"); private static final String NDJSON_CONTENT_TYPE = "application/x-ndjson"; private static final String SSE_CONTENT_TYPE = "text/event-stream"; @Override public Response intercept(Chain chain) throws IOException { Response response = chain.proceed(chain.request()); if (!isNdjsonResponse(response)) { return response; } ResponseBody responseBody = response.body(); if (responseBody == null) { return response; } return response.newBuilder() .header("Content-Type", SSE_CONTENT_TYPE) .body(ResponseBody.create(toSseBody(readBody(responseBody)), EVENT_STREAM_MEDIA_TYPE)) .build(); } private boolean isNdjsonResponse(Response response) { String contentType = response.header("Content-Type"); return contentType != null && contentType.contains(NDJSON_CONTENT_TYPE); } private String readBody(ResponseBody responseBody) throws IOException { BufferedSource source = responseBody.source(); source.request(Long.MAX_VALUE); Buffer buffer = source.getBuffer(); return buffer.clone().readString(StandardCharsets.UTF_8); } private String toSseBody(String ndjsonBody) { StringBuilder sseBody = new StringBuilder(); String[] ndjsonLines = ndjsonBody.split("\n"); for (String jsonLine : ndjsonLines) { if (!jsonLine.trim().isEmpty()) { sseBody.append("data: ").append(jsonLine).append("\n\n"); } } return sseBody.toString(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/interceptor/ErrorInterceptor.java ================================================ package io.github.lnyocly.ai4j.interceptor; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.exception.chain.ErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import lombok.extern.slf4j.Slf4j; import okhttp3.Interceptor; import okhttp3.MediaType; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; import org.jetbrains.annotations.NotNull; import okio.Buffer; import java.io.IOException; import java.nio.charset.StandardCharsets; /** * Intercepts API responses and raises provider-specific exceptions only when * the payload contains structured error objects. */ @Slf4j public class ErrorInterceptor implements Interceptor { @NotNull @Override public Response intercept(@NotNull Chain chain) throws IOException { Request original = chain.request(); Response response = chain.proceed(original); if (isStreamingResponse(response)) { return response; } ResponseBody responseBody = response.body(); byte[] contentBytes = getResponseBodyBytes(responseBody); String content = new String(contentBytes, StandardCharsets.UTF_8); boolean streamingRequest = isStreamingRequest(original); if (!response.isSuccessful() && response.code() != 100 && response.code() != 101) { if (streamingRequest) { return rebuildResponse(response, responseBody, contentBytes); } throw buildCommonException(response.code(), response.message(), content); } if (containsStructuredError(content)) { if (streamingRequest) { return rebuildResponse(response, responseBody, contentBytes); } ErrorHandler errorHandler = ErrorHandler.getInstance(); Error error = errorHandler.process(content); log.error("AI request failed: {}", error.getMessage()); throw new CommonException(error.getMessage()); } return rebuildResponse(response, responseBody, contentBytes); } private CommonException buildCommonException(int code, String message, String payload) { String errorMsg = payload == null ? "" : payload; if (errorMsg.trim().isEmpty()) { errorMsg = code + " " + message; log.error("AI request failed: {}", errorMsg); return new CommonException(errorMsg); } try { JSONObject object = JSON.parseObject(errorMsg); if (object != null) { ErrorHandler errorHandler = ErrorHandler.getInstance(); Error error = errorHandler.process(errorMsg); log.error("AI request failed: {}", error.getMessage()); return new CommonException(error.getMessage()); } } catch (Exception ignored) { // Keep raw payload for unknown providers or non-JSON responses. } log.error("AI request failed: {}", errorMsg); return new CommonException(errorMsg); } private boolean containsStructuredError(String content) { if (content == null || content.trim().isEmpty()) { return false; } JSONObject object; try { object = JSON.parseObject(content); } catch (Exception e) { return false; } if (object == null) { return false; } Object openAiError = object.get("error"); if (openAiError instanceof JSONObject) { return true; } if (openAiError instanceof String && !((String) openAiError).trim().isEmpty()) { return true; } JSONObject hunyuanResponse = object.getJSONObject("Response"); if (hunyuanResponse != null) { Object hunyuanError = hunyuanResponse.get("Error"); if (hunyuanError instanceof JSONObject) { return true; } if (hunyuanError instanceof String && !((String) hunyuanError).trim().isEmpty()) { return true; } } String status = object.getString("status"); return "failed".equalsIgnoreCase(status); } private boolean isStreamingResponse(Response response) { ResponseBody body = response.body(); if (body == null) { return false; } MediaType contentType = body.contentType(); if (contentType == null) { return false; } String type = contentType.toString(); return type.contains("text/event-stream") || type.contains("application/x-ndjson"); } private boolean isStreamingRequest(Request request) { if (request == null) { return false; } String accept = request.header("Accept"); if (accept != null) { String normalizedAccept = accept.toLowerCase(); if (normalizedAccept.contains("text/event-stream") || normalizedAccept.contains("application/x-ndjson")) { return true; } } if (request.body() == null) { return false; } try { Buffer buffer = new Buffer(); request.body().writeTo(buffer); String body = buffer.readString(StandardCharsets.UTF_8); String normalizedBody = body == null ? "" : body.toLowerCase(); return normalizedBody.contains("\"stream\":true") || normalizedBody.contains("\"stream\": true"); } catch (Exception ignored) { return false; } } private Response rebuildResponse(Response response, ResponseBody responseBody, byte[] contentBytes) { MediaType contentType = responseBody == null ? null : responseBody.contentType(); ResponseBody newBody = ResponseBody.create(contentType, contentBytes); return response.newBuilder().body(newBody).build(); } private byte[] getResponseBodyBytes(ResponseBody responseBody) throws IOException { if (responseBody == null) { return new byte[0]; } return responseBody.bytes(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/AbstractManagedStreamListener.java ================================================ package io.github.lnyocly.ai4j.listener; import io.github.lnyocly.ai4j.exception.CommonException; import lombok.Getter; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; abstract class AbstractManagedStreamListener extends EventSourceListener implements ManagedStreamListener { @Getter private volatile CountDownLatch countDownLatch = new CountDownLatch(1); @Getter private EventSource eventSource; private volatile boolean cancelRequested = false; private volatile Throwable failure; private volatile Response failureResponse; private volatile long openedAtEpochMs; private volatile long lastActivityAtEpochMs; private volatile boolean receivedEvent; @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { attachEventSource(eventSource); cancelRequested = false; openedAtEpochMs = System.currentTimeMillis(); lastActivityAtEpochMs = openedAtEpochMs; receivedEvent = false; } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { attachEventSource(eventSource); if (cancelRequested) { finishAttempt(); return; } recordFailure(resolveFailure(t, response), response); finishAttempt(); } public void cancelStream() { cancelRequested = true; cancelActiveEventSource(); finishAttempt(); } @Override public void awaitCompletion(StreamExecutionOptions options) throws InterruptedException { CountDownLatch latch = countDownLatch; long firstTokenTimeoutMs = options == null ? 0L : options.normalizedFirstTokenTimeoutMs(); long idleTimeoutMs = options == null ? 0L : options.normalizedIdleTimeoutMs(); if (openedAtEpochMs <= 0L) { long now = System.currentTimeMillis(); openedAtEpochMs = now; lastActivityAtEpochMs = now; } while (true) { if (latch.await(100L, TimeUnit.MILLISECONDS)) { return; } if (cancelRequested) { return; } if (Thread.currentThread().isInterrupted()) { cancelStream(); throw new InterruptedException("Model stream interrupted"); } long now = System.currentTimeMillis(); if (!receivedEvent && firstTokenTimeoutMs > 0L && openedAtEpochMs > 0L && now - openedAtEpochMs >= firstTokenTimeoutMs) { recordFailure(new TimeoutException( "Timed out waiting for first model stream event after " + firstTokenTimeoutMs + " ms" )); cancelActiveEventSource(); finishAttempt(latch); return; } if (receivedEvent && idleTimeoutMs > 0L && lastActivityAtEpochMs > 0L && now - lastActivityAtEpochMs >= idleTimeoutMs) { recordFailure(new TimeoutException( "Timed out waiting for model stream activity after " + idleTimeoutMs + " ms" )); cancelActiveEventSource(); finishAttempt(latch); return; } } } @Override public Throwable getFailure() { return failure; } @Override public void recordFailure(Throwable failure) { recordFailure(failure, null); } public void dispatchFailure() { Throwable currentFailure = failure; if (currentFailure == null) { return; } Response response = failureResponse; clearFailure(); error(currentFailure, response); } @Override public void clearFailure() { failure = null; failureResponse = null; } @Override public void prepareForRetry() { clearFailure(); eventSource = null; cancelRequested = false; openedAtEpochMs = 0L; lastActivityAtEpochMs = 0L; receivedEvent = false; resetRetryState(); } @Override public boolean hasReceivedEvent() { return receivedEvent; } @Override public boolean isCancelRequested() { return cancelRequested; } @Override public void onRetrying(Throwable failure, int attempt, int maxAttempts) { retry(failure, attempt, maxAttempts); } protected void error(Throwable t, Response response) { } protected void retry(Throwable t, int attempt, int maxAttempts) { } protected void resetRetryState() { } protected Throwable resolveFailure(@Nullable Throwable t, @Nullable Response response) { if (t != null && t.getMessage() != null && !t.getMessage().trim().isEmpty()) { return t; } if (response != null) { String message = (response.code() + " " + (response.message() == null ? "" : response.message())).trim(); if (!message.isEmpty()) { return new CommonException(message); } } return t == null ? new CommonException("stream request failed") : t; } protected final void attachEventSource(EventSource eventSource) { this.eventSource = eventSource; } protected final void clearCancelRequested() { cancelRequested = false; } protected final void markActivity() { long now = System.currentTimeMillis(); if (openedAtEpochMs <= 0L) { openedAtEpochMs = now; } lastActivityAtEpochMs = now; receivedEvent = true; } protected final void finishAttempt() { finishAttempt(countDownLatch); } protected final void finishAttempt(CountDownLatch latch) { CountDownLatch release = advanceLatch(latch); release.countDown(); } protected final void cancelActiveEventSource() { EventSource activeEventSource = eventSource; if (activeEventSource != null) { try { activeEventSource.cancel(); } catch (Exception ignored) { // Best effort cancel so the waiting caller can continue unwinding. } } } private void recordFailure(Throwable failure, Response response) { this.failure = failure; this.failureResponse = response; } private synchronized CountDownLatch advanceLatch(CountDownLatch expected) { CountDownLatch current = countDownLatch; if (expected != null && current != expected) { return expected; } countDownLatch = new CountDownLatch(1); return current; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/ImageSseListener.java ================================================ package io.github.lnyocly.ai4j.listener; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageData; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamEvent; import lombok.Getter; import okhttp3.Response; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; /** * @Author cly * @Description 图片生成流式监听器 * @Date 2026/1/31 */ public abstract class ImageSseListener extends EventSourceListener { /** * 异常回调 */ protected void error(Throwable t, Response response) {} /** * 事件回调 */ protected abstract void onEvent(); @Getter private final List events = new ArrayList<>(); @Getter private ImageStreamEvent currEvent; @Getter private final ImageGenerationResponse response = new ImageGenerationResponse(); @Getter private CountDownLatch countDownLatch = new CountDownLatch(1); public void accept(ImageStreamEvent event) { this.currEvent = event; this.events.add(event); appendResponse(event); this.onEvent(); } private void appendResponse(ImageStreamEvent event) { if (event == null) { return; } if (event.getUsage() != null) { response.setUsage(event.getUsage()); } if (event.getCreatedAt() != null && response.getCreated() == null) { response.setCreated(event.getCreatedAt()); } if (shouldAppendImage(event)) { if (response.getData() == null) { response.setData(new ArrayList<>()); } ImageData imageData = new ImageData(); imageData.setUrl(event.getUrl()); imageData.setB64Json(event.getB64Json()); imageData.setSize(event.getSize()); response.getData().add(imageData); } } private boolean shouldAppendImage(ImageStreamEvent event) { if (event == null) { return false; } if (event.getUrl() == null && event.getB64Json() == null) { return false; } String type = event.getType(); return type == null || !type.contains("partial_image"); } public void complete() { countDownLatch.countDown(); countDownLatch = new CountDownLatch(1); } public void onError(Throwable t, Response response) { this.error(t, response); } @Override public void onFailure(@NotNull okhttp3.sse.EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { this.error(t, response); complete(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/ManagedStreamListener.java ================================================ package io.github.lnyocly.ai4j.listener; public interface ManagedStreamListener { void awaitCompletion(StreamExecutionOptions options) throws InterruptedException; Throwable getFailure(); void recordFailure(Throwable failure); void clearFailure(); void prepareForRetry(); boolean hasReceivedEvent(); boolean isCancelRequested(); void onRetrying(Throwable failure, int attempt, int maxAttempts); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/RealtimeListener.java ================================================ package io.github.lnyocly.ai4j.listener; import lombok.extern.slf4j.Slf4j; import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; import okio.ByteString; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** * @Author cly * @Description RealtimeListener, 用于统一处理WebSocket的事件(Realtime客户端专用) * @Date 2024/10/12 16:33 */ @Slf4j public abstract class RealtimeListener extends WebSocketListener { protected abstract void onOpen(WebSocket webSocket); protected abstract void onMessage(ByteString bytes); protected abstract void onMessage(String text); protected abstract void onFailure(); @Override public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) { log.info("WebSocket Opened: " + response.message()); this.onOpen(webSocket); } @Override public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) { log.info("Receive Byte Message: " + bytes.toString()); this.onMessage(bytes); } @Override public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) { log.info("Receive String Message: " + text); this.onMessage(text); } @Override public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) { log.error("WebSocket Error: ", t); } @Override public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { } @Override public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) { log.info("WebSocket Closed: " + reason); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/ResponseSseListener.java ================================================ package io.github.lnyocly.ai4j.listener; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent; import lombok.Getter; import okhttp3.sse.EventSource; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; public abstract class ResponseSseListener extends AbstractManagedStreamListener { @Override protected void error(Throwable t, okhttp3.Response response) { } protected abstract void onEvent(); @Getter private final List events = new ArrayList<>(); @Getter private ResponseStreamEvent currEvent; @Getter private final Response response = new Response(); @Getter private final StringBuilder outputText = new StringBuilder(); @Getter private final StringBuilder reasoningSummary = new StringBuilder(); @Getter private final StringBuilder functionArguments = new StringBuilder(); @Getter private String currText = ""; @Getter private String currFunctionArguments = ""; public void accept(ResponseStreamEvent event) { this.currEvent = event; this.events.add(event); this.currText = ""; this.currFunctionArguments = ""; markActivity(); applyEvent(event); this.onEvent(); } private void applyEvent(ResponseStreamEvent event) { if (event == null) { return; } if (event.getResponse() != null) { mergeResponse(event.getResponse()); } String type = event.getType(); if (type == null) { return; } switch (type) { case "response.output_text.delta": if (event.getDelta() != null) { outputText.append(event.getDelta()); currText = event.getDelta(); } break; case "response.output_text.done": if (outputText.length() == 0 && event.getText() != null) { outputText.append(event.getText()); currText = event.getText(); } break; case "response.reasoning_summary_text.delta": if (event.getDelta() != null) { reasoningSummary.append(event.getDelta()); } break; case "response.reasoning_summary_text.done": if (reasoningSummary.length() == 0 && event.getText() != null) { reasoningSummary.append(event.getText()); } break; case "response.function_call_arguments.delta": if (event.getDelta() != null) { functionArguments.append(event.getDelta()); currFunctionArguments = event.getDelta(); } break; case "response.function_call_arguments.done": if (functionArguments.length() == 0 && event.getArguments() != null) { functionArguments.append(event.getArguments()); currFunctionArguments = event.getArguments(); } break; default: break; } } private void mergeResponse(Response source) { if (source == null) { return; } if (source.getId() != null) { response.setId(source.getId()); } if (source.getObject() != null) { response.setObject(source.getObject()); } if (source.getCreatedAt() != null) { response.setCreatedAt(source.getCreatedAt()); } if (source.getModel() != null) { response.setModel(source.getModel()); } if (source.getStatus() != null) { response.setStatus(source.getStatus()); } if (source.getOutput() != null) { response.setOutput(source.getOutput()); } if (source.getError() != null) { response.setError(source.getError()); } if (source.getIncompleteDetails() != null) { response.setIncompleteDetails(source.getIncompleteDetails()); } if (source.getInstructions() != null) { response.setInstructions(source.getInstructions()); } if (source.getMaxOutputTokens() != null) { response.setMaxOutputTokens(source.getMaxOutputTokens()); } if (source.getPreviousResponseId() != null) { response.setPreviousResponseId(source.getPreviousResponseId()); } if (source.getUsage() != null) { response.setUsage(source.getUsage()); } if (source.getMetadata() != null) { response.setMetadata(source.getMetadata()); } if (source.getContextManagement() != null) { response.setContextManagement(source.getContextManagement()); } } public void complete() { finishAttempt(); } public void onError(Throwable t, okhttp3.Response response) { this.error(t, response); } @Override public void onClosed(@NotNull EventSource eventSource) { attachEventSource(eventSource); complete(); } @Override protected void retry(Throwable t, int attempt, int maxAttempts) { } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/SseListener.java ================================================ package io.github.lnyocly.ai4j.listener; import cn.hutool.core.util.StrUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import okhttp3.Response; import okhttp3.sse.EventSource; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description SseListener * @Date 2024/8/13 23:25 */ @Slf4j public abstract class SseListener extends AbstractManagedStreamListener { /** * 异常回调 */ protected void error(Throwable t, Response response) {} protected abstract void send(); /** * 最终的消息输出 */ @Getter private final StringBuilder output = new StringBuilder(); /** * 流式输出,当前消息的内容(回答消息、函数参数) */ @Getter private String currStr = ""; /** * 流式输出,当前单条SSE消息对象,即ChatCompletionResponse对象 */ @Getter private String currData = ""; /** * 记录当前所调用函数工具的名称 */ @Getter private String currToolName = ""; /** * 记录当前是否为思考状态reasoning */ @Getter private boolean isReasoning = false; /** * 思考内容的输出 */ @Getter private final StringBuilder reasoningOutput = new StringBuilder(); /** * 是否显示每个函数调用输出的参数文本 */ @Getter @Setter private boolean showToolArgs = false; /** * 花费token */ @Getter private final Usage usage = new Usage(); @Setter @Getter private List toolCalls = new ArrayList<>(); @Setter @Getter private ToolCall toolCall; /** * 最终的函数调用参数 */ private final StringBuilder argument = new StringBuilder(); @Getter @Setter private String finishReason = null; @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { // 封装SSE消息对象 currData = data; markActivity(); if (getEventSource() == null) { attachEventSource(eventSource); } if ("[DONE]".equalsIgnoreCase(data)) { // 整个对话结束,结束前将SSE最后一条“DONE”消息发送出去 currStr = ""; this.send(); return; } ObjectMapper objectMapper = new ObjectMapper(); ChatCompletionResponse chatCompletionResponse = null; try { chatCompletionResponse = objectMapper.readValue(data, ChatCompletionResponse.class); } catch (JsonProcessingException e) { throw new CommonException("read data error"); } // 统计token,当设置include_usage = true时,最后一条消息会携带usage, 其他消息中usage为null Usage currUsage = chatCompletionResponse.getUsage(); if(currUsage != null){ usage.setPromptTokens(usage.getPromptTokens() + currUsage.getPromptTokens()); usage.setCompletionTokens(usage.getCompletionTokens() + currUsage.getCompletionTokens()); usage.setTotalTokens(usage.getTotalTokens() + currUsage.getTotalTokens()); } List choices = chatCompletionResponse.getChoices(); if((choices == null || choices.isEmpty()) && chatCompletionResponse.getUsage() != null){ this.currStr = ""; this.send(); return; } if(choices == null || choices.isEmpty()){ return; } ChatMessage responseMessage = choices.get(0).getDelta(); if (responseMessage == null) { return; } List messageToolCalls = responseMessage.getToolCalls(); ToolCall firstMessageToolCall = firstToolCall(messageToolCalls); finishReason = choices.get(0).getFinishReason(); // Ollama 在工具调用时返回 stop 而非 tool_calls if("stop".equals(finishReason) && responseMessage.getContent()!=null && "".equals(responseMessage.getContent().getText()) && !toolCalls.isEmpty()){ finishReason = "tool_calls"; } // tool_calls回答已经结束 if("tool_calls".equals(finishReason)){ if (toolCall != null) { consumeFragmentedToolCalls(messageToolCalls); finalizeCurrentToolCall(); } else if (shouldTreatAsCompleteToolCalls(responseMessage, messageToolCalls)) { addCompleteToolCalls(messageToolCalls); } else { consumeFragmentedToolCalls(messageToolCalls); finalizeCurrentToolCall(); } return; } // 消息回答完毕 if ("stop".equals(finishReason)) { // ollama 最后一条消息只到stop if(responseMessage.getContent() != null && responseMessage.getContent().getText() != null) { currStr = responseMessage.getContent().getText(); output.append(currStr); }else { currStr = ""; } this.send(); return; } if(ChatMessageType.ASSISTANT.getRole().equals(responseMessage.getRole()) && (responseMessage.getContent()==null || StringUtils.isEmpty(responseMessage.getContent().getText())) && StringUtils.isEmpty(responseMessage.getReasoningContent()) && isEmpty(messageToolCalls)){ // 空消息忽略 return; } if(isEmpty(messageToolCalls)) { // 判断是否为混元的tool最后一条说明性content,用于忽略 // :{"Role":"assistant","Content":"计划使用get_current_weather工具来获取北京和深圳的当前天气。\n\t\n\t用户想要知道北京和深圳今天的天气情况。用户的请求是关于天气的查询,需要使用天气查询工具来获取信息。"} if(toolCall !=null && StringUtils.isNotEmpty(argument)&& "assistant".equals(responseMessage.getRole()) && (responseMessage.getContent()!=null && StringUtils.isNotEmpty(responseMessage.getContent().getText())) ){ return; } // 响应回答 // 包括content和reasoning_content if(StringUtils.isNotEmpty(responseMessage.getReasoningContent())){ isReasoning = true; // reasoningOutput 与 output 分离,目前仅用于deepseek reasoningOutput.append(responseMessage.getReasoningContent()); //output.append(responseMessage.getReasoningContent()); currStr = responseMessage.getReasoningContent(); }else { isReasoning = false; if (responseMessage.getContent() == null) { this.send(); return; } output.append(responseMessage.getContent().getText()); currStr = responseMessage.getContent().getText(); } this.send(); }else{ // 函数调用回答 if (shouldTreatAsCompleteToolCalls(responseMessage, messageToolCalls)) { addCompleteToolCalls(messageToolCalls); } else { consumeFragmentedToolCalls(messageToolCalls); } } //log.info("测试结果:{}", chatCompletionResponse); } @Override public void onClosed(@NotNull EventSource eventSource) { attachEventSource(eventSource); finishAttempt(); clearCancelRequested(); } @Override protected void resetRetryState() { finishReason = null; currData = ""; currStr = ""; currToolName = ""; } private ToolCall firstToolCall(List calls) { if (isEmpty(calls)) { return null; } return calls.get(0); } private String safeToolArguments(ToolCall call) { if (call == null || call.getFunction() == null) { return ""; } return StrUtil.emptyIfNull(call.getFunction().getArguments()); } private String safeToolName(ToolCall call) { if (call == null || call.getFunction() == null) { return ""; } return StrUtil.emptyIfNull(call.getFunction().getName()); } private boolean isEmpty(List values) { return values == null || values.isEmpty(); } private boolean shouldTreatAsCompleteToolCalls(ChatMessage responseMessage, List messageToolCalls) { if (responseMessage == null || responseMessage.getContent() == null) { return false; } if (!"".equals(responseMessage.getContent().getText()) || isEmpty(messageToolCalls)) { return false; } for (ToolCall call : messageToolCalls) { if (!hasToolName(call) || !hasStructuredJsonObjectArguments(call)) { return false; } } return true; } private void addCompleteToolCalls(List completeToolCalls) { if (isEmpty(completeToolCalls)) { return; } for (ToolCall completeToolCall : completeToolCalls) { if (completeToolCall == null || completeToolCall.getFunction() == null || !hasToolName(completeToolCall)) { continue; } currToolName = safeToolName(completeToolCall); toolCalls.add(completeToolCall); if (showToolArgs) { this.currStr = StrUtil.emptyIfNull(completeToolCall.getFunction().getArguments()); this.send(); } } argument.setLength(0); currToolName = ""; } private void consumeFragmentedToolCalls(List messageToolCalls) { if (isEmpty(messageToolCalls)) { return; } for (ToolCall currentToolCall : messageToolCalls) { if (currentToolCall == null || currentToolCall.getFunction() == null) { continue; } String argumentsDelta = StrUtil.emptyIfNull(safeToolArguments(currentToolCall)); if (hasToolIdentity(currentToolCall)) { if (toolCall == null) { startToolCall(currentToolCall, argumentsDelta); } else if (isSameToolCall(toolCall, currentToolCall)) { mergeToolIdentity(toolCall, currentToolCall); argument.append(argumentsDelta); } else { finalizeCurrentToolCall(); startToolCall(currentToolCall, argumentsDelta); } if (showToolArgs) { this.currStr = argumentsDelta; this.send(); } continue; } if (toolCall != null) { argument.append(argumentsDelta); if (showToolArgs) { this.currStr = argumentsDelta; this.send(); } } } } private void startToolCall(ToolCall currentToolCall, String argumentsDelta) { toolCall = currentToolCall; argument.setLength(0); argument.append(StrUtil.emptyIfNull(argumentsDelta)); currToolName = safeToolName(currentToolCall); } private void finalizeCurrentToolCall() { if (toolCall == null) { argument.setLength(0); currToolName = ""; return; } if (toolCall.getFunction() != null) { toolCall.getFunction().setArguments(argument.toString()); } toolCalls.add(toolCall); toolCall = null; argument.setLength(0); currToolName = ""; } private void mergeToolIdentity(ToolCall target, ToolCall source) { if (target == null || source == null) { return; } if (StrUtil.isBlank(target.getId()) && StrUtil.isNotBlank(source.getId())) { target.setId(source.getId()); } if (StrUtil.isBlank(target.getType()) && StrUtil.isNotBlank(source.getType())) { target.setType(source.getType()); } if (target.getFunction() == null || source.getFunction() == null) { return; } if (StrUtil.isBlank(target.getFunction().getName()) && StrUtil.isNotBlank(source.getFunction().getName())) { target.getFunction().setName(source.getFunction().getName()); } } private boolean hasToolIdentity(ToolCall call) { return call != null && (StrUtil.isNotBlank(call.getId()) || hasToolName(call)); } private boolean hasToolName(ToolCall call) { return call != null && call.getFunction() != null && StrUtil.isNotBlank(call.getFunction().getName()); } private boolean isSameToolCall(ToolCall left, ToolCall right) { if (left == null || right == null) { return false; } if (StrUtil.isNotBlank(left.getId()) && StrUtil.isNotBlank(right.getId())) { return left.getId().equals(right.getId()); } if (left.getFunction() == null || right.getFunction() == null) { return false; } return StrUtil.isNotBlank(left.getFunction().getName()) && left.getFunction().getName().equals(right.getFunction().getName()); } private boolean hasStructuredJsonObjectArguments(ToolCall call) { String arguments = safeToolArguments(call); if (StringUtils.isBlank(arguments)) { return false; } try { JsonNode node = new ObjectMapper().readTree(arguments); return node != null && node.isObject(); } catch (Exception ignored) { return false; } } @Override protected Throwable resolveFailure(@Nullable Throwable t, @Nullable Response response) { if (t != null && StringUtils.isNotBlank(t.getMessage())) { return t; } String message = resolveFailureMessage(t, response); if (StringUtils.isBlank(message)) { return t == null ? new CommonException("stream request failed") : t; } return new CommonException(message); } protected String resolveFailureMessage(@Nullable Throwable t, @Nullable Response response) { if (t != null && StringUtils.isNotBlank(t.getMessage())) { return t.getMessage().trim(); } String responseMessage = responseMessage(response); if (StringUtils.isNotBlank(responseMessage)) { return responseMessage; } if (response != null) { String statusLine = (response.code() + " " + StrUtil.emptyIfNull(response.message())).trim(); if (StringUtils.isNotBlank(statusLine)) { return statusLine; } } return t == null ? "stream request failed" : t.getClass().getSimpleName(); } private String responseMessage(@Nullable Response response) { if (response == null) { return null; } try { okhttp3.ResponseBody peekedBody = response.peekBody(8192L); if (peekedBody == null) { return null; } String payload = StringUtils.trimToNull(peekedBody.string()); if (payload == null) { return null; } String extracted = extractStructuredErrorMessage(payload); return extracted == null ? StringUtils.abbreviate(payload, 320) : extracted; } catch (Exception ignored) { return null; } } private String extractStructuredErrorMessage(String payload) { if (StringUtils.isBlank(payload)) { return null; } try { JsonNode root = new ObjectMapper().readTree(payload); String message = firstJsonText( root.path("error").path("message"), root.path("error"), root.path("message"), root.path("msg"), root.path("detail"), root.path("Response").path("Error").path("Message"), root.path("Response").path("Error"), root.path("error_msg") ); return StringUtils.trimToNull(message); } catch (Exception ignored) { return null; } } private String firstJsonText(JsonNode... nodes) { if (nodes == null) { return null; } for (JsonNode node : nodes) { if (node == null || node.isMissingNode() || node.isNull()) { continue; } if (node.isTextual()) { String text = StringUtils.trimToNull(node.asText()); if (text != null) { return text; } continue; } if (node.isObject()) { String text = firstJsonText(node.path("message"), node.path("Message"), node.path("msg"), node.path("detail")); if (text != null) { return text; } } } return null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/StreamExecutionOptions.java ================================================ package io.github.lnyocly.ai4j.listener; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class StreamExecutionOptions { @Builder.Default private long firstTokenTimeoutMs = 0L; @Builder.Default private long idleTimeoutMs = 0L; @Builder.Default private int maxRetries = 0; @Builder.Default private long retryBackoffMs = 0L; public long normalizedFirstTokenTimeoutMs() { return Math.max(0L, firstTokenTimeoutMs); } public long normalizedIdleTimeoutMs() { return Math.max(0L, idleTimeoutMs); } public int normalizedMaxRetries() { return Math.max(0, maxRetries); } public long normalizedRetryBackoffMs() { return Math.max(0L, retryBackoffMs); } public int totalAttempts() { return normalizedMaxRetries() + 1; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/listener/StreamExecutionSupport.java ================================================ package io.github.lnyocly.ai4j.listener; public final class StreamExecutionSupport { public static final String DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY = "ai4j.stream.default.first-token-timeout-ms"; public static final String DEFAULT_IDLE_TIMEOUT_PROPERTY = "ai4j.stream.default.idle-timeout-ms"; public static final String DEFAULT_MAX_RETRIES_PROPERTY = "ai4j.stream.default.max-retries"; public static final String DEFAULT_RETRY_BACKOFF_PROPERTY = "ai4j.stream.default.retry-backoff-ms"; public static final long DEFAULT_FIRST_TOKEN_TIMEOUT_MS = 30_000L; public static final long DEFAULT_IDLE_TIMEOUT_MS = 30_000L; public static final int DEFAULT_MAX_RETRIES = 0; public static final long DEFAULT_RETRY_BACKOFF_MS = 0L; private StreamExecutionSupport() { } public interface StreamStarter { void start() throws Exception; } public static void execute(ManagedStreamListener listener, StreamExecutionOptions options, StreamStarter starter) throws Exception { if (listener == null) { throw new IllegalArgumentException("listener is required"); } if (starter == null) { throw new IllegalArgumentException("starter is required"); } StreamExecutionOptions resolved = resolveOptions(options); int maxAttempts = Math.max(1, resolved.totalAttempts()); int attempt = 1; while (true) { listener.clearFailure(); try { starter.start(); } catch (Exception ex) { listener.recordFailure(ex); } listener.awaitCompletion(resolved); Throwable failure = listener.getFailure(); if (failure == null || listener.isCancelRequested() || Thread.currentThread().isInterrupted()) { return; } if (listener.hasReceivedEvent() || attempt >= maxAttempts) { return; } int nextAttempt = attempt + 1; listener.onRetrying(failure, nextAttempt, maxAttempts); listener.prepareForRetry(); long backoffMs = resolved.normalizedRetryBackoffMs(); if (backoffMs > 0L) { try { Thread.sleep(backoffMs); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); return; } } attempt = nextAttempt; } } static StreamExecutionOptions resolveOptions(StreamExecutionOptions options) { if (options != null) { return options; } return StreamExecutionOptions.builder() .firstTokenTimeoutMs(longProperty(DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY, DEFAULT_FIRST_TOKEN_TIMEOUT_MS)) .idleTimeoutMs(longProperty(DEFAULT_IDLE_TIMEOUT_PROPERTY, DEFAULT_IDLE_TIMEOUT_MS)) .maxRetries(intProperty(DEFAULT_MAX_RETRIES_PROPERTY, DEFAULT_MAX_RETRIES)) .retryBackoffMs(longProperty(DEFAULT_RETRY_BACKOFF_PROPERTY, DEFAULT_RETRY_BACKOFF_MS)) .build(); } private static long longProperty(String key, long fallback) { String value = System.getProperty(key); if (value == null || value.trim().isEmpty()) { return fallback; } try { return Long.parseLong(value.trim()); } catch (NumberFormatException ignored) { return fallback; } } private static int intProperty(String key, int fallback) { String value = System.getProperty(key); if (value == null || value.trim().isEmpty()) { return fallback; } try { return Integer.parseInt(value.trim()); } catch (NumberFormatException ignored) { return fallback; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpParameter.java ================================================ package io.github.lnyocly.ai4j.mcp.annotation; import java.lang.annotation.*; /** * @Author cly * @Description MCP参数注解,用于标记MCP工具方法的参数 */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface McpParameter { /** * 参数名称,如果不指定则使用参数名 */ String name() default ""; /** * 参数描述 */ String description() default ""; /** * 是否必需参数 */ boolean required() default true; /** * 参数默认值 * 在MCP工具调用时使用 */ String defaultValue() default ""; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpPrompt.java ================================================ package io.github.lnyocly.ai4j.mcp.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Author cly * @Description MCP提示词注解,用于标记MCP服务中的提示词方法 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface McpPrompt { /** * 提示词名称 */ String name(); /** * 提示词描述 */ String description() default ""; /** * 提示词类型 (user, assistant, system) */ String role() default "user"; /** * 是否支持动态参数 */ boolean dynamic() default true; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpPromptParameter.java ================================================ package io.github.lnyocly.ai4j.mcp.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Author cly * @Description MCP提示词参数注解,用于标记MCP提示词方法的参数 */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface McpPromptParameter { /** * 参数名称 */ String name(); /** * 参数描述 */ String description() default ""; /** * 是否必需 */ boolean required() default true; /** * 默认值 */ String defaultValue() default ""; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpResource.java ================================================ package io.github.lnyocly.ai4j.mcp.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Author cly * @Description MCP资源注解,用于标记MCP服务中的资源方法 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface McpResource { /** * 资源URI模板,支持参数占位符 * 例如: "file://{path}", "database://users/{id}" */ String uri(); /** * 资源名称 */ String name(); /** * 资源描述 */ String description() default ""; /** * 资源MIME类型 */ String mimeType() default ""; /** * 是否支持订阅变更通知 */ boolean subscribable() default false; /** * 资源大小(字节),-1表示未知 */ long size() default -1L; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpResourceParameter.java ================================================ package io.github.lnyocly.ai4j.mcp.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Author cly * @Description MCP资源参数注解,用于标记MCP资源方法的参数 */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface McpResourceParameter { /** * 参数名称,对应URI模板中的占位符 */ String name(); /** * 参数描述 */ String description() default ""; /** * 是否必需 */ boolean required() default true; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpService.java ================================================ package io.github.lnyocly.ai4j.mcp.annotation; import java.lang.annotation.*; /** * @Author cly * @Description MCP服务注解,用于标记MCP服务类 */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface McpService { /** * 服务名称 */ String name() default ""; /** * 服务版本 */ String version() default "1.0.0"; /** * 服务描述 */ String description() default ""; /** * 服务端口(仅HTTP传输时使用) */ int port() default 3000; /** * 传输类型:stdio, sse, streamable_http */ String transport() default "stdio"; /** * 是否自动启动 */ boolean autoStart() default true; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpTool.java ================================================ package io.github.lnyocly.ai4j.mcp.annotation; import java.lang.annotation.*; /** * @Author cly * @Description MCP工具注解,用于标记MCP服务中的工具方法 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface McpTool { /** * 工具名称,如果不指定则使用方法名 */ String name() default ""; /** * 工具描述 */ String description() default ""; /** * 工具输入Schema的JSON字符串 * 如果不指定,将根据方法参数自动生成 */ String inputSchema() default ""; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/client/McpClient.java ================================================ package io.github.lnyocly.ai4j.mcp.client; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.entity.*; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.transport.McpTransport; import io.github.lnyocly.ai4j.mcp.transport.McpTransportSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; /** * @Author cly * @Description MCP客户端核心类 */ public class McpClient implements McpTransport.McpMessageHandler { private static final Logger log = LoggerFactory.getLogger(McpClient.class); private static final long HEARTBEAT_INTERVAL_MINUTES = 10L; private final String clientName; private final String clientVersion; private final McpTransport transport; private final boolean autoReconnect; private final AtomicBoolean initialized; private final AtomicBoolean connected; private final AtomicLong messageIdCounter; // 在 McpClient 类中添加以下成员变量 private final AtomicBoolean isReconnecting = new AtomicBoolean(false); private final ScheduledExecutorService reconnectExecutor = Executors.newSingleThreadScheduledExecutor(); // 存储待响应的请求 private final Map> pendingRequests; // 缓存的服务器工具列表 private volatile List availableTools; private volatile List availableResources; private volatile List availablePrompts; private ScheduledExecutorService heartbeatExecutor; public McpClient(String clientName, String clientVersion, McpTransport transport) { this(clientName, clientVersion, transport, true); } public McpClient(String clientName, String clientVersion, McpTransport transport, boolean autoReconnect) { this.clientName = clientName; this.clientVersion = clientVersion; this.transport = transport; this.autoReconnect = autoReconnect; this.initialized = new AtomicBoolean(false); this.connected = new AtomicBoolean(false); this.messageIdCounter = new AtomicLong(0); this.pendingRequests = new ConcurrentHashMap<>(); // 设置传输层消息处理器 transport.setMessageHandler(this); } /** * 连接到MCP服务器 */ public CompletableFuture connect() { return CompletableFuture.runAsync(() -> { if (!connected.get()) { log.info("连接到MCP服务器: {} v{}", clientName, clientVersion); try { // 启动传输层,添加超时 transport.start().get(30, java.util.concurrent.TimeUnit.SECONDS); log.debug("传输层启动成功"); // 发送初始化请求,添加超时 initialize().get(30, java.util.concurrent.TimeUnit.SECONDS); log.debug("初始化请求完成"); connected.set(true); // 在所有步骤成功后再设置 log.debug("MCP客户端连接成功"); if (transport.needsHeartbeat()) { log.debug("正在启动心跳服务..."); startHeartbeat(); } } catch (Exception e) { log.debug("连接MCP服务器失败: {}", McpTransportSupport.safeMessage(e), e); // 确保在失败时状态被重置 connected.set(false); initialized.set(false); throw new RuntimeException(McpTransportSupport.safeMessage(e), e); } } }); } /** * 断开连接 */ public CompletableFuture disconnect() { return CompletableFuture.runAsync(() -> { if (connected.get() || transport.isConnected()) { if (transport.needsHeartbeat() && connected.get()) { log.debug("正在停止心跳服务..."); stopHeartbeat(); } connected.set(false); initialized.set(false); availableTools = null; availableResources = null; availablePrompts = null; try { // 先停止传输层,避免触发新的回调 transport.stop().join(); // 取消所有待响应的请求 pendingRequests.values().forEach(future -> future.completeExceptionally(new RuntimeException("连接已断开"))); pendingRequests.clear(); log.debug("MCP客户端已断开连接"); } catch (Exception e) { log.error("断开连接时发生错误", e); } } // 最后关闭重连调度器,确保传输层已完全停止 reconnectExecutor.shutdownNow(); }); } private void startHeartbeat() { if (heartbeatExecutor == null || heartbeatExecutor.isShutdown()) { heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(); } // 长连接传输由底层连接自行保活;这里仅保留低频兜底检查。 heartbeatExecutor.scheduleAtFixedRate(() -> { if (isConnected()) { log.debug("执行MCP心跳检查"); this.getAvailableTools().exceptionally(e -> { log.warn("心跳请求失败: {}", e.getMessage()); // 如果心跳失败,也可以考虑触发重连 this.onError(new IOException("Heartbeat failed, connection assumed lost.", e)); return null; }); } }, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_INTERVAL_MINUTES, TimeUnit.MINUTES); } private void stopHeartbeat() { if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) { heartbeatExecutor.shutdown(); heartbeatExecutor = null; } } /** * 检查是否已连接 */ public boolean isConnected() { return connected.get() && transport.isConnected(); } /** * 检查是否已初始化 */ public boolean isInitialized() { return initialized.get(); } /** * 获取可用工具列表 */ public CompletableFuture> getAvailableTools() { if (availableTools != null) { return CompletableFuture.completedFuture(availableTools); } return sendRequest("tools/list", null) .thenApply(response -> { try { if (response.isSuccessResponse() && response.getResult() != null) { List parsedTools = McpClientResponseSupport.parseToolsListResponse(response.getResult()); availableTools = parsedTools; return parsedTools; } else { log.warn("获取工具列表失败: {}", response.getError()); return new ArrayList(); } } catch (Exception e) { log.error("解析工具列表响应失败", e); return new ArrayList(); } }); } /** * 获取可用资源列表 */ public CompletableFuture> getAvailableResources() { if (availableResources != null) { return CompletableFuture.completedFuture(availableResources); } return sendRequest("resources/list", null) .thenApply(response -> { try { if (response.isSuccessResponse() && response.getResult() != null) { List parsedResources = McpClientResponseSupport.parseResourcesListResponse(response.getResult()); availableResources = parsedResources; return parsedResources; } else { log.warn("获取资源列表失败: {}", response.getError()); return new ArrayList(); } } catch (Exception e) { log.error("解析资源列表响应失败", e); return new ArrayList(); } }); } /** * 读取资源内容 */ public CompletableFuture readResource(String uri) { if (uri == null || uri.trim().isEmpty()) { CompletableFuture future = new CompletableFuture(); future.completeExceptionally(new IllegalArgumentException("uri is required")); return future; } Map params = new HashMap(); params.put("uri", uri); return sendRequest("resources/read", params) .thenApply(response -> { try { if (response.isSuccessResponse() && response.getResult() != null) { return McpClientResponseSupport.parseResourceReadResponse(response.getResult()); } else { log.warn("读取资源失败: {}", response.getError()); return null; } } catch (Exception e) { log.error("解析资源读取响应失败", e); return null; } }); } /** * 获取可用提示模板列表 */ public CompletableFuture> getAvailablePrompts() { if (availablePrompts != null) { return CompletableFuture.completedFuture(availablePrompts); } return sendRequest("prompts/list", null) .thenApply(response -> { try { if (response.isSuccessResponse() && response.getResult() != null) { List parsedPrompts = McpClientResponseSupport.parsePromptsListResponse(response.getResult()); availablePrompts = parsedPrompts; return parsedPrompts; } else { log.warn("获取提示模板列表失败: {}", response.getError()); return new ArrayList(); } } catch (Exception e) { log.error("解析提示模板列表响应失败", e); return new ArrayList(); } }); } /** * 获取提示模板渲染结果 */ public CompletableFuture getPrompt(String name) { return getPrompt(name, null); } /** * 获取提示模板渲染结果 */ public CompletableFuture getPrompt(String name, Map arguments) { if (name == null || name.trim().isEmpty()) { CompletableFuture future = new CompletableFuture(); future.completeExceptionally(new IllegalArgumentException("name is required")); return future; } Map params = new HashMap(); params.put("name", name); if (arguments != null && !arguments.isEmpty()) { params.put("arguments", arguments); } return sendRequest("prompts/get", params) .thenApply(response -> { try { if (response.isSuccessResponse() && response.getResult() != null) { return McpClientResponseSupport.parsePromptGetResponse(name, response.getResult()); } else { log.warn("获取提示模板失败: {}", response.getError()); return null; } } catch (Exception e) { log.error("解析提示模板响应失败", e); return null; } }); } /** * 调用工具 */ public CompletableFuture callTool(String toolName, Object arguments) { if (!isConnected() || !isInitialized()) { CompletableFuture future = new CompletableFuture<>(); String errorMsg = "客户端未连接或未初始化,无法调用工具。Connected: " + isConnected() + ", Initialized: " + isInitialized(); log.warn(errorMsg); future.completeExceptionally(new IllegalStateException(errorMsg)); return future; } Map params = new HashMap<>(); params.put("name", toolName); params.put("arguments", arguments); return sendRequest("tools/call", params) .thenApply(response -> { try { if (response.isSuccessResponse() && response.getResult() != null) { // 解析工具调用结果 return McpClientResponseSupport.parseToolCallResponse(response.getResult()); } else { log.warn("工具调用失败: {}", response.getError()); return "工具调用失败: " + (response.getError() != null ? response.getError().getMessage() : "未知错误"); } } catch (Exception e) { log.error("解析工具调用响应失败", e); return "工具调用解析失败: " + e.getMessage(); } }) .exceptionally(ex -> { log.error("调用工具 '{}' 失败,根本原因: {}", toolName, ex.getMessage()); // 将原始异常重新包装或转换为一个更具体的业务异常 throw new RuntimeException(ex.getMessage(), ex); }); } @Override public void handleMessage(McpMessage message) { try { log.debug("处理消息: {}", message); if (message.isResponse()) { handleResponse(message); } else if (message.isNotification()) { handleNotification(message); } else if (message.isRequest()) { handleRequest(message); } else { log.warn("收到未知类型的消息: {}", message); } } catch (Exception e) { log.error("处理消息时发生错误", e); } } @Override public void onConnected() { log.debug("MCP传输层连接已建立"); connected.set(true); } @Override public void onDisconnected(String reason) { log.debug("MCP传输层连接已断开: {}", reason); if (connected.get()) { // 增加一个判断,避免重复执行 connected.set(false); initialized.set(false); // 清除缓存,因为重新连接后工具列表可能变化 availableTools = null; // 停止心跳 stopHeartbeat(); // 停止并清理传输层,这将清除旧的 endpointUrl try { transport.stop().join(); // 使用 join() 确保传输层已完全停止 } catch (Exception e) { log.warn("在 onDisconnected 期间停止传输层失败", e); } // 取消所有待处理的请求 pendingRequests.values().forEach(future -> future.completeExceptionally(new RuntimeException(reason))); pendingRequests.clear(); // 触发异步重连 if (autoReconnect) { scheduleReconnection(); } else { log.debug("自动重连已禁用,跳过MCP重连: {}", clientName); } } } @Override public void onError(Throwable error) { log.debug("MCP传输层发生错误: {}", McpTransportSupport.safeMessage(error), error); // 将传输层错误视为一次断开连接事件。 this.onDisconnected(McpTransportSupport.safeMessage(error)); } /** * 调度一个异步的重连任务 */ private void scheduleReconnection() { // 检查线程池是否已关闭 if (reconnectExecutor.isShutdown()) { log.debug("重连调度器已关闭,跳过重连"); return; } if (isReconnecting.compareAndSet(false, true)) { log.debug("将在5秒后尝试重新连接..."); try { reconnectExecutor.schedule(() -> { try { log.debug("开始执行重连..."); connect().get(60, TimeUnit.SECONDS); // 使用 get() 来等待重连完成 log.debug("MCP客户端重连成功"); } catch (Exception e) { log.error("重连失败,将安排下一次重连", e); // 如果这次重连失败,再次调度 isReconnecting.set(false); // 重置标志以便下次可以重连 scheduleReconnection(); // 简单起见,这里直接再次调度。实际中可引入指数退避策略。 } finally { // 无论成功与否,最终都重置标志位 // 如果成功,下一次断线时可以再次触发重连 // 如果失败,下一次调度时也可以继续 isReconnecting.set(false); } }, 5, TimeUnit.SECONDS); // 延迟5秒后执行 } catch (RejectedExecutionException e) { log.debug("重连调度器已关闭,无法调度重连任务"); isReconnecting.set(false); } } } /** * 发送初始化请求 */ private CompletableFuture initialize() { log.debug("开始MCP初始化流程"); // 构建客户端能力 - 使用更完整的配置 Map capabilities = new HashMap<>(); // 添加sampling能力 capabilities.put("sampling", new HashMap<>()); // 添加roots能力 Map roots = new HashMap<>(); roots.put("listChanged", true); capabilities.put("roots", roots); // 添加tools能力 Map tools = new HashMap<>(); tools.put("listChanged", true); capabilities.put("tools", tools); // 添加resources能力 Map resources = new HashMap<>(); resources.put("listChanged", true); resources.put("subscribe", true); capabilities.put("resources", resources); // 添加prompts能力 Map prompts = new HashMap<>(); prompts.put("listChanged", true); capabilities.put("prompts", prompts); Map clientInfo = new HashMap<>(); clientInfo.put("name", clientName); clientInfo.put("version", clientVersion); Map params = new HashMap<>(); // 使用与服务器兼容的协议版本 params.put("protocolVersion", "2025-03-26"); params.put("capabilities", capabilities); params.put("clientInfo", clientInfo); return sendRequest("initialize", params) .thenCompose(response -> { log.debug("收到初始化响应,发送initialized通知"); // 发送初始化完成通知 - 使用空对象而不是null return sendNotification("notifications/initialized", new HashMap<>()); }) .thenRun(() -> { initialized.set(true); log.debug("MCP客户端初始化完成"); }); } /** * 发送请求消息 */ private CompletableFuture sendRequest(String method, Object params) { long messageId = nextMessageId(); // 创建请求消息 McpRequest request = new McpRequest(method, messageId, params); // 添加详细日志 log.debug("发送MCP请求: method={}, id={}, params={}", method, messageId, JSON.toJSONString(params)); CompletableFuture future = new CompletableFuture<>(); pendingRequests.put(messageId, future); // 发送消息 transport.sendMessage(request); return future; } /** * 发送通知消息 */ private CompletableFuture sendNotification(String method, Object params) { // 创建通知消息 McpNotification notification = new McpNotification(method, params); log.debug("发送通知: method={}", method); return transport.sendMessage(notification) .thenRun(() -> { log.debug("通知发送完成: method={}", method); }) .exceptionally(throwable -> { log.error("发送通知失败: method={}", method, throwable); throw new RuntimeException("发送通知失败", throwable); }); } /** * 处理响应消息 */ private void handleResponse(McpMessage message) { Object messageId = message.getId(); // 记录完整的响应消息用于调试 log.debug("收到MCP响应: id={}, 完整消息={}", messageId, JSON.toJSONString(message)); // 尝试不同的ID类型匹配 CompletableFuture future = pendingRequests.remove(messageId); if (future == null && messageId instanceof Number) { // 如果直接匹配失败,尝试转换为long类型匹配 long longId = ((Number) messageId).longValue(); future = pendingRequests.remove(longId); } if (future != null) { if (message.isSuccessResponse()) { future.complete(message); } else { log.error("MCP请求失败详情: id={}, error={}, 完整错误消息={}", messageId, message.getError(), JSON.toJSONString(message)); future.completeExceptionally(new RuntimeException("请求失败: " + message.getError())); } } else { log.warn("收到未知请求的响应: {}", messageId); } } /** * 处理通知消息 */ private void handleNotification(McpMessage message) { String method = message.getMethod(); log.debug("收到通知: {}", method); // 处理各种标准MCP通知 switch (method) { case "notifications/tools/list_changed": // 工具列表变更通知 log.debug("服务器工具列表已变更,清除缓存"); availableTools = null; // 清除缓存 break; case "notifications/resources/list_changed": // 资源列表变更通知 log.debug("服务器资源列表已变更"); availableResources = null; break; case "notifications/prompts/list_changed": // 提示列表变更通知 log.debug("服务器提示列表已变更"); availablePrompts = null; break; case "notifications/progress": // 进度通知 log.debug("收到进度通知: {}", message.getParams()); break; default: log.debug("收到未处理的通知: {}", method); break; } } /** * 处理请求消息(服务器向客户端发送的请求) */ private void handleRequest(McpMessage message) { String method = message.getMethod(); log.debug("收到服务器请求: {}", method); // 这里可以处理服务器向客户端发送的请求 // 比如sampling/createMessage等 } /** * 生成下一个消息ID */ private long nextMessageId() { return messageIdCounter.incrementAndGet(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/client/McpClientResponseSupport.java ================================================ package io.github.lnyocly.ai4j.mcp.client; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.entity.McpPrompt; import io.github.lnyocly.ai4j.mcp.entity.McpPromptResult; import io.github.lnyocly.ai4j.mcp.entity.McpResource; import io.github.lnyocly.ai4j.mcp.entity.McpResourceContent; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * MCP 客户端响应解析辅助类 */ public final class McpClientResponseSupport { private McpClientResponseSupport() { } public static List parseToolsListResponse(Object result) { List tools = new ArrayList(); try { Map resultMap = asMap(result); if (resultMap != null) { List toolsList = asList(resultMap.get("tools")); for (Object toolObj : toolsList) { Map toolMap = asMap(toolObj); if (toolMap != null) { McpToolDefinition tool = McpToolDefinition.builder() .name((String) toolMap.get("name")) .description((String) toolMap.get("description")) .inputSchema(asMap(toolMap.get("inputSchema"))) .build(); tools.add(tool); } } } } catch (Exception ignored) { } return tools; } public static String parseToolCallResponse(Object result) { try { Map resultMap = asMap(result); if (resultMap != null) { Object content = resultMap.get("content"); if (content != null) { List contentList = asList(content); if (!contentList.isEmpty()) { StringBuilder resultText = new StringBuilder(); for (Object item : contentList) { Map itemMap = asMap(item); if (itemMap != null) { Object text = itemMap.get("text"); if (text != null) { resultText.append(text.toString()); } } } return resultText.toString(); } return content.toString(); } return JSON.toJSONString(result); } return result != null ? result.toString() : ""; } catch (Exception e) { return "解析结果失败: " + e.getMessage(); } } public static List parseResourcesListResponse(Object result) { List resources = new ArrayList(); try { Map resultMap = asMap(result); if (resultMap == null) { return resources; } List resourceList = asList(resultMap.get("resources")); for (Object resourceObj : resourceList) { Map resourceMap = asMap(resourceObj); if (resourceMap == null) { continue; } resources.add(McpResource.builder() .uri(stringValue(resourceMap.get("uri"))) .name(stringValue(resourceMap.get("name"))) .description(stringValue(resourceMap.get("description"))) .mimeType(stringValue(resourceMap.get("mimeType"))) .size(longValue(resourceMap.get("size"))) .build()); } } catch (Exception ignored) { } return resources; } public static McpResourceContent parseResourceReadResponse(Object result) { try { Map resultMap = asMap(result); if (resultMap == null) { return null; } List contentList = asList(resultMap.get("contents")); if (contentList.isEmpty()) { return null; } if (contentList.size() == 1) { return parseSingleResourceContent(contentList.get(0)); } List contents = new ArrayList(contentList.size()); String uri = null; String mimeType = null; for (Object contentObj : contentList) { Map itemMap = asMap(contentObj); if (itemMap == null) { continue; } if (uri == null) { uri = stringValue(itemMap.get("uri")); } if (mimeType == null) { mimeType = stringValue(itemMap.get("mimeType")); } Object extracted = extractResourceContent(itemMap); contents.add(extracted == null ? itemMap : extracted); } return McpResourceContent.builder() .uri(uri) .mimeType(mimeType) .contents(contents) .build(); } catch (Exception ignored) { return null; } } public static List parsePromptsListResponse(Object result) { List prompts = new ArrayList(); try { Map resultMap = asMap(result); if (resultMap == null) { return prompts; } List promptList = asList(resultMap.get("prompts")); for (Object promptObj : promptList) { Map promptMap = asMap(promptObj); if (promptMap == null) { continue; } prompts.add(McpPrompt.builder() .name(stringValue(promptMap.get("name"))) .description(stringValue(promptMap.get("description"))) .arguments(asMap(promptMap.get("arguments"))) .build()); } } catch (Exception ignored) { } return prompts; } public static McpPromptResult parsePromptGetResponse(String name, Object result) { try { Map resultMap = asMap(result); if (resultMap == null) { return null; } List messages = asList(resultMap.get("messages")); StringBuilder content = new StringBuilder(); for (Object messageObj : messages) { Map messageMap = asMap(messageObj); if (messageMap == null) { continue; } appendPromptMessageText(content, messageMap.get("content")); } return McpPromptResult.builder() .name(name) .description(stringValue(resultMap.get("description"))) .content(content.toString()) .build(); } catch (Exception ignored) { return null; } } private static McpResourceContent parseSingleResourceContent(Object contentObj) { Map contentMap = asMap(contentObj); if (contentMap == null) { return null; } Object contents = extractResourceContent(contentMap); return McpResourceContent.builder() .uri(stringValue(contentMap.get("uri"))) .mimeType(stringValue(contentMap.get("mimeType"))) .contents(contents == null ? contentMap : contents) .build(); } private static Object extractResourceContent(Map contentMap) { if (contentMap == null) { return null; } if (contentMap.containsKey("text")) { return contentMap.get("text"); } if (contentMap.containsKey("blob")) { return contentMap.get("blob"); } if (contentMap.containsKey("contents")) { return contentMap.get("contents"); } return null; } private static void appendPromptMessageText(StringBuilder builder, Object content) { if (content == null) { return; } if (content instanceof String) { builder.append(content); return; } Map contentMap = asMap(content); if (contentMap != null) { Object text = contentMap.get("text"); if (text != null) { builder.append(text); return; } } List parts = asList(content); for (Object partObj : parts) { Map partMap = asMap(partObj); if (partMap != null && partMap.get("text") != null) { builder.append(partMap.get("text")); } } } private static Map asMap(Object value) { if (!(value instanceof Map)) { return null; } Map result = new HashMap(); for (Map.Entry entry : ((Map) value).entrySet()) { if (entry.getKey() != null) { result.put(String.valueOf(entry.getKey()), entry.getValue()); } } return result; } private static List asList(Object value) { if (!(value instanceof List)) { return new ArrayList(); } return new ArrayList((List) value); } private static String stringValue(Object value) { if (value == null) { return null; } String text = String.valueOf(value); return text.isEmpty() ? null : text; } private static Long longValue(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).longValue(); } try { return Long.parseLong(String.valueOf(value)); } catch (NumberFormatException ignored) { return null; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/FileMcpConfigSource.java ================================================ package io.github.lnyocly.ai4j.mcp.config; import com.alibaba.fastjson2.JSON; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; /** * @Author cly * @Description 基于文件的MCP配置源实现 */ public class FileMcpConfigSource implements McpConfigSource { private static final Logger log = LoggerFactory.getLogger(FileMcpConfigSource.class); private final String configFile; private final List listeners = new CopyOnWriteArrayList<>(); private volatile Map configs = new HashMap<>(); public FileMcpConfigSource(String configFile) { this.configFile = configFile; loadConfigs(); } @Override public Map getAllConfigs() { return new HashMap<>(configs); } @Override public McpServerConfig.McpServerInfo getConfig(String serverId) { return configs.get(serverId); } @Override public void addConfigChangeListener(ConfigChangeListener listener) { listeners.add(listener); } @Override public void removeConfigChangeListener(ConfigChangeListener listener) { listeners.remove(listener); } /** * 重新加载配置文件(支持热更新) */ public void reloadConfigs() { Map oldConfigs = new HashMap<>(configs); loadConfigs(); Map newConfigs = configs; // 检测配置变更并通知监听器 detectAndNotifyChanges(oldConfigs, newConfigs); } /** * 从文件加载配置 */ private void loadConfigs() { try { McpServerConfig serverConfig = McpConfigIO.loadServerConfig(configFile, getClass().getClassLoader()); Map enabledConfigs = McpConfigIO.extractEnabledConfigs(serverConfig); if (!enabledConfigs.isEmpty()) { this.configs = enabledConfigs; log.info("成功加载MCP配置文件: {}, 共 {} 个启用的服务", configFile, enabledConfigs.size()); } else { this.configs = new HashMap<>(); if (serverConfig == null) { log.info("未找到MCP配置文件: {}", configFile); } else { log.warn("MCP配置文件为空或没有启用的服务: {}", configFile); } } } catch (Exception e) { this.configs = new HashMap<>(); log.error("加载MCP配置文件失败: {}", configFile, e); } } /** * 检测配置变更并通知监听器 */ private void detectAndNotifyChanges(Map oldConfigs, Map newConfigs) { // 检测新增的配置 newConfigs.forEach((serverId, config) -> { if (!oldConfigs.containsKey(serverId)) { notifyConfigAdded(serverId, config); } else if (!configEquals(oldConfigs.get(serverId), config)) { notifyConfigUpdated(serverId, config); } }); // 检测删除的配置 oldConfigs.forEach((serverId, config) -> { if (!newConfigs.containsKey(serverId)) { notifyConfigRemoved(serverId); } }); } /** * 比较两个配置是否相等 */ private boolean configEquals(McpServerConfig.McpServerInfo config1, McpServerConfig.McpServerInfo config2) { if (config1 == null && config2 == null) return true; if (config1 == null || config2 == null) return false; // 简单的JSON序列化比较 try { String json1 = JSON.toJSONString(config1); String json2 = JSON.toJSONString(config2); return json1.equals(json2); } catch (Exception e) { log.warn("比较配置时发生错误", e); return false; } } private void notifyConfigAdded(String serverId, McpServerConfig.McpServerInfo config) { listeners.forEach(listener -> { try { listener.onConfigAdded(serverId, config); } catch (Exception e) { log.error("通知配置添加失败: {}", serverId, e); } }); } private void notifyConfigRemoved(String serverId) { listeners.forEach(listener -> { try { listener.onConfigRemoved(serverId); } catch (Exception e) { log.error("通知配置删除失败: {}", serverId, e); } }); } private void notifyConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) { listeners.forEach(listener -> { try { listener.onConfigUpdated(serverId, config); } catch (Exception e) { log.error("通知配置更新失败: {}", serverId, e); } }); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpConfigIO.java ================================================ package io.github.lnyocly.ai4j.mcp.config; import com.alibaba.fastjson2.JSON; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; /** * MCP 配置读取辅助类 */ public final class McpConfigIO { private McpConfigIO() { } public static McpServerConfig loadServerConfig(String configFile, ClassLoader classLoader) throws IOException { String configContent = loadConfigContent(configFile, classLoader); if (configContent == null || configContent.trim().isEmpty()) { return null; } return JSON.parseObject(configContent, McpServerConfig.class); } public static Map loadEnabledConfigs( String configFile, ClassLoader classLoader) throws IOException { return extractEnabledConfigs(loadServerConfig(configFile, classLoader)); } public static Map extractEnabledConfigs(McpServerConfig serverConfig) { Map enabledConfigs = new HashMap(); if (serverConfig == null || serverConfig.getMcpServers() == null) { return enabledConfigs; } serverConfig.getMcpServers().forEach((serverId, serverInfo) -> { if (serverInfo.getEnabled() == null || serverInfo.getEnabled()) { enabledConfigs.put(serverId, serverInfo); } }); return enabledConfigs; } public static String loadConfigContent(String configFile, ClassLoader classLoader) throws IOException { InputStream inputStream = classLoader.getResourceAsStream(configFile); if (inputStream != null) { try { byte[] bytes = readAllBytes(inputStream); return new String(bytes, StandardCharsets.UTF_8); } finally { inputStream.close(); } } Path configPath = Paths.get(configFile); if (Files.exists(configPath)) { return new String(Files.readAllBytes(configPath), StandardCharsets.UTF_8); } return null; } private static byte[] readAllBytes(InputStream inputStream) throws IOException { byte[] buffer = new byte[8192]; int bytesRead; ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } return outputStream.toByteArray(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpConfigManager.java ================================================ package io.github.lnyocly.ai4j.mcp.config; import io.github.lnyocly.ai4j.mcp.transport.McpTransportFactory; import io.github.lnyocly.ai4j.mcp.transport.TransportConfig; import io.github.lnyocly.ai4j.mcp.util.McpTypeSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; /** * MCP配置管理器 - 支持动态配置和事件通知 */ public class McpConfigManager implements McpConfigSource { private static final Logger log = LoggerFactory.getLogger(McpConfigManager.class); private final Map configs = new ConcurrentHashMap<>(); private final List listeners = new CopyOnWriteArrayList(); /** * 兼容旧版监听器类型 */ @Deprecated public interface ConfigChangeListener extends McpConfigSource.ConfigChangeListener { } /** * 添加配置变更监听器 */ @Override public void addConfigChangeListener(McpConfigSource.ConfigChangeListener listener) { listeners.add(listener); } /** * 移除配置变更监听器 */ @Override public void removeConfigChangeListener(McpConfigSource.ConfigChangeListener listener) { listeners.remove(listener); } /** * 添加兼容旧版监听器 */ public void addConfigChangeListener(ConfigChangeListener listener) { addConfigChangeListener((McpConfigSource.ConfigChangeListener) listener); } /** * 移除兼容旧版监听器 */ public void removeConfigChangeListener(ConfigChangeListener listener) { removeConfigChangeListener((McpConfigSource.ConfigChangeListener) listener); } /** * 添加MCP服务器配置(支持运行时动态添加) */ public void addConfig(String serverId, McpServerConfig.McpServerInfo config) { McpServerConfig.McpServerInfo oldConfig = configs.put(serverId, config); log.info("添加MCP服务器配置: {}", serverId); // 通知监听器 if (oldConfig == null) { notifyConfigAdded(serverId, config); } else { notifyConfigUpdated(serverId, config); } } /** * 删除MCP服务器配置(支持运行时动态删除) */ public void removeConfig(String serverId) { McpServerConfig.McpServerInfo removedConfig = configs.remove(serverId); if (removedConfig != null) { log.info("删除MCP服务器配置: {}", serverId); notifyConfigRemoved(serverId); } } /** * 获取MCP服务器配置 */ @Override public McpServerConfig.McpServerInfo getConfig(String serverId) { return configs.get(serverId); } /** * 获取所有配置 */ @Override public Map getAllConfigs() { return new ConcurrentHashMap<>(configs); } /** * 更新配置 */ public void updateConfig(String serverId, McpServerConfig.McpServerInfo config) { addConfig(serverId, config); } /** * 检查配置是否存在 */ public boolean hasConfig(String serverId) { return configs.containsKey(serverId); } /** * 验证配置有效性 */ public boolean validateConfig(McpServerConfig.McpServerInfo config) { if (config == null) { return false; } if (config.getName() == null || config.getName().trim().isEmpty()) { return false; } String normalizedType = McpTypeSupport.resolveType(config); String rawType = config.getType() != null ? config.getType() : config.getTransport(); if (rawType != null && !rawType.trim().isEmpty() && !McpTypeSupport.isKnownType(rawType)) { return false; } try { TransportConfig transportConfig = TransportConfig.fromServerInfo(config); McpTransportFactory.TransportType factoryType = McpTransportFactory.TransportType.fromString(normalizedType); McpTransportFactory.validateConfig(factoryType, transportConfig); return true; } catch (Exception e) { log.debug("MCP配置校验失败: {}", config.getName(), e); return false; } } // 通知方法 private void notifyConfigAdded(String serverId, McpServerConfig.McpServerInfo config) { for (McpConfigSource.ConfigChangeListener listener : listeners) { try { listener.onConfigAdded(serverId, config); } catch (Exception e) { log.error("通知配置添加事件失败: {}", serverId, e); } } } private void notifyConfigRemoved(String serverId) { for (McpConfigSource.ConfigChangeListener listener : listeners) { try { listener.onConfigRemoved(serverId); } catch (Exception e) { log.error("通知配置删除事件失败: {}", serverId, e); } } } private void notifyConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) { for (McpConfigSource.ConfigChangeListener listener : listeners) { try { listener.onConfigUpdated(serverId, config); } catch (Exception e) { log.error("通知配置更新事件失败: {}", serverId, e); } } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpConfigSource.java ================================================ package io.github.lnyocly.ai4j.mcp.config; import java.util.Map; /** * @Author cly * @Description MCP配置源接口,支持多种配置来源(文件、数据库、Redis等) */ public interface McpConfigSource { /** * 获取所有配置 */ Map getAllConfigs(); /** * 获取指定配置 */ McpServerConfig.McpServerInfo getConfig(String serverId); /** * 添加配置变更监听器 */ void addConfigChangeListener(ConfigChangeListener listener); /** * 移除配置变更监听器 */ void removeConfigChangeListener(ConfigChangeListener listener); /** * 配置变更监听器 */ interface ConfigChangeListener { /** * 配置添加事件 */ void onConfigAdded(String serverId, McpServerConfig.McpServerInfo config); /** * 配置移除事件 */ void onConfigRemoved(String serverId); /** * 配置更新事件 */ void onConfigUpdated(String serverId, McpServerConfig.McpServerInfo config); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpServerConfig.java ================================================ package io.github.lnyocly.ai4j.mcp.config; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.util.List; import java.util.Map; /** * @Author cly * @Description MCP服务器配置,对应mcp-servers-config.json文件格式 */ @Data public class McpServerConfig { /** * MCP服务器配置映射 */ @JsonProperty("mcpServers") private Map mcpServers; /** * 单个MCP服务器信息 */ @Data public static class McpServerInfo { /** * 服务器名称 */ private String name; /** * 服务器描述 */ private String description; /** * 启动命令 */ private String command; /** * 命令参数 */ private List args; /** * 环境变量 */ private Map env; /** * 工作目录 */ private String cwd; /** * 传输类型:stdio, http (已弃用,使用type字段) * @deprecated 使用 {@link #type} 字段替代 */ @Deprecated private String transport = "stdio"; /** * 传输类型:stdio, streamable_http, http, sse */ private String type; /** * HTTP/SSE传输时的完整URL(包含端点路径) * 例如:http://localhost:8080/mcp */ private String url; /** * 自定义HTTP头(用于认证等) */ private Map headers; // Getters and Setters for new fields public String getType() { return type; } public void setType(String type) { this.type = type; } public Map getHeaders() { return headers; } public void setHeaders(Map headers) { this.headers = headers; } /** * 是否启用 */ private Boolean enabled = true; /** * 是否自动重连 */ private Boolean autoReconnect = true; /** * 重连间隔(毫秒) */ private Long reconnectInterval = 5000L; /** * 最大重连次数 */ private Integer maxReconnectAttempts = 3; /** * 连接超时(毫秒) */ private Long connectTimeout = 30000L; /** * 标签 */ private List tags; /** * 优先级 */ private Integer priority = 0; /** * 配置版本(用于配置变更检测) */ private Long version = System.currentTimeMillis(); /** * 创建时间 */ private Long createdTime = System.currentTimeMillis(); /** * 最后更新时间 */ private Long lastUpdatedTime = System.currentTimeMillis(); /** * 是否需要用户认证 */ private Boolean requiresAuth = false; /** * 支持的认证类型 */ private List authTypes; /** * 更新版本号 */ public void updateVersion() { this.version = System.currentTimeMillis(); this.lastUpdatedTime = System.currentTimeMillis(); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpError.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP错误信息 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpError { /** * 错误代码 */ @JsonProperty("code") private Integer code; /** * 错误消息 */ @JsonProperty("message") private String message; /** * 错误详细数据 */ @JsonProperty("data") private Object data; /** * 标准错误代码枚举 */ public enum ErrorCode { // JSON-RPC标准错误代码 PARSE_ERROR(-32700, "Parse error"), INVALID_REQUEST(-32600, "Invalid Request"), METHOD_NOT_FOUND(-32601, "Method not found"), INVALID_PARAMS(-32602, "Invalid params"), INTERNAL_ERROR(-32603, "Internal error"), // MCP特定错误代码 INITIALIZATION_FAILED(-32000, "Initialization failed"), CONNECTION_FAILED(-32001, "Connection failed"), AUTHENTICATION_FAILED(-32002, "Authentication failed"), RESOURCE_NOT_FOUND(-32003, "Resource not found"), TOOL_NOT_FOUND(-32004, "Tool not found"), PROMPT_NOT_FOUND(-32005, "Prompt not found"), PERMISSION_DENIED(-32006, "Permission denied"), TIMEOUT(-32007, "Operation timeout"), RATE_LIMITED(-32008, "Rate limited"), SERVER_UNAVAILABLE(-32009, "Server unavailable"); private final int code; private final String message; ErrorCode(int code, String message) { this.code = code; this.message = message; } public int getCode() { return code; } public String getMessage() { return message; } public static ErrorCode fromCode(int code) { for (ErrorCode errorCode : values()) { if (errorCode.code == code) { return errorCode; } } return null; } } /** * 创建标准错误 */ public static McpError of(ErrorCode errorCode) { return McpError.builder() .code(errorCode.getCode()) .message(errorCode.getMessage()) .build(); } /** * 创建自定义错误 */ public static McpError of(ErrorCode errorCode, String customMessage) { return McpError.builder() .code(errorCode.getCode()) .message(customMessage) .build(); } /** * 创建带详细数据的错误 */ public static McpError of(ErrorCode errorCode, String customMessage, Object data) { return McpError.builder() .code(errorCode.getCode()) .message(customMessage) .data(data) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpInitializeResponse.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; /** * @Author cly * @Description MCP初始化响应 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpInitializeResponse { /** * 协议版本 */ @JsonProperty("protocolVersion") private String protocolVersion; /** * 服务器能力 */ @JsonProperty("capabilities") private Map capabilities; /** * 服务器信息 */ @JsonProperty("serverInfo") private McpServerInfo serverInfo; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpMessage.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; /** * @Author cly * @Description MCP消息基类,基于JSON-RPC 2.0 */ @Data @SuperBuilder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public abstract class McpMessage { /** * JSON-RPC版本,固定为"2.0" */ @Builder.Default @JsonProperty("jsonrpc") private String jsonrpc = "2.0"; /** * 消息方法名 */ @JsonProperty("method") private String method; /** * 消息ID(请求和响应时使用) */ @JsonProperty("id") private Object id; /** * 消息参数 */ @JsonProperty("params") private Object params; /** * 响应结果(仅响应消息使用) */ @JsonProperty("result") private Object result; /** * 错误信息(仅错误响应使用) */ @JsonProperty("error") private McpError error; /** * 消息时间戳 */ @JsonIgnore private Long timestamp; /** * 判断是否为请求消息 */ @JsonIgnore public boolean isRequest() { return method != null && id != null && result == null && error == null; } /** * 判断是否为通知消息 */ @JsonIgnore public boolean isNotification() { return method != null && id == null; } /** * 判断是否为响应消息 */ @JsonIgnore public boolean isResponse() { return method == null && id != null && (result != null || error != null); } /** * 判断是否为成功响应 */ @JsonIgnore public boolean isSuccessResponse() { return isResponse() && error == null; } /** * 判断是否为错误响应 */ @JsonIgnore public boolean isErrorResponse() { return isResponse() && error != null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpNotification.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.SuperBuilder; /** * @Author cly * @Description MCP通知消息 */ @Data @SuperBuilder @EqualsAndHashCode(callSuper = true) public class McpNotification extends McpMessage { public McpNotification() { super(); } public McpNotification(String method, Object params) { super(); setJsonrpc("2.0"); setMethod(method); setParams(params); setId(null); // 通知消息没有ID } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpPrompt.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; /** * @Author cly * @Description MCP提示词模板 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpPrompt { /** * 提示词名称 */ @JsonProperty("name") private String name; /** * 提示词描述 */ @JsonProperty("description") private String description; /** * 参数Schema */ @JsonProperty("arguments") private Map arguments; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpPromptResult.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP提示词结果 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpPromptResult { /** * 提示词名称 */ @JsonProperty("name") private String name; /** * 生成的提示词内容 */ @JsonProperty("content") private String content; /** * 提示词描述 */ @JsonProperty("description") private String description; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpRequest.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.SuperBuilder; /** * @Author cly * @Description MCP请求消息 */ @Data @SuperBuilder @EqualsAndHashCode(callSuper = true) public class McpRequest extends McpMessage { public McpRequest() { super(); } public McpRequest(String method, Object id, Object params) { super(); setJsonrpc("2.0"); setMethod(method); setId(id); setParams(params); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpResource.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP资源定义 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpResource { /** * 资源URI */ @JsonProperty("uri") private String uri; /** * 资源名称 */ @JsonProperty("name") private String name; /** * 资源描述 */ @JsonProperty("description") private String description; /** * 资源MIME类型 */ @JsonProperty("mimeType") private String mimeType; /** * 资源大小(字节) */ @JsonProperty("size") private Long size; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpResourceContent.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP资源内容 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpResourceContent { /** * 资源URI */ @JsonProperty("uri") private String uri; /** * 资源内容 */ @JsonProperty("contents") private Object contents; /** * 资源MIME类型 */ @JsonProperty("mimeType") private String mimeType; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpResponse.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.SuperBuilder; /** * @Author cly * @Description MCP响应消息 */ @Data @SuperBuilder @EqualsAndHashCode(callSuper = true) public class McpResponse extends McpMessage { public McpResponse() { super(); } public McpResponse(Object id, Object result) { super(); setJsonrpc("2.0"); setId(id); setResult(result); } public McpResponse(Object id, McpError error) { super(); setJsonrpc("2.0"); setId(id); setError(error); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpRoot.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP根目录 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpRoot { /** * 根目录URI */ @JsonProperty("uri") private String uri; /** * 根目录名称 */ @JsonProperty("name") private String name; /** * 根目录描述 */ @JsonProperty("description") private String description; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpSamplingRequest.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description MCP采样请求 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpSamplingRequest { /** * 消息列表 */ @JsonProperty("messages") private List messages; /** * 模型参数 */ @JsonProperty("modelPreferences") private Object modelPreferences; /** * 系统提示词 */ @JsonProperty("systemPrompt") private String systemPrompt; /** * 包含上下文 */ @JsonProperty("includeContext") private String includeContext; /** * 最大令牌数 */ @JsonProperty("maxTokens") private Integer maxTokens; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpSamplingResult.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP采样结果 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpSamplingResult { /** * 生成的内容 */ @JsonProperty("content") private String content; /** * 使用的模型 */ @JsonProperty("model") private String model; /** * 停止原因 */ @JsonProperty("stopReason") private String stopReason; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpServerInfo.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP服务器信息 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpServerInfo { /** * 服务器名称 */ @JsonProperty("name") private String name; /** * 服务器版本 */ @JsonProperty("version") private String version; /** * 服务器描述 */ @JsonProperty("description") private String description; /** * 服务器作者 */ @JsonProperty("author") private String author; /** * 服务器主页 */ @JsonProperty("homepage") private String homepage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpServerReference.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import io.github.lnyocly.ai4j.mcp.util.McpTypeSupport; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; /** * @Author cly * @Description MCP服务器引用对象,用于ChatCompletion中指定MCP服务 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpServerReference { /** * 服务器ID或名称 */ private String name; /** * 服务器描述 */ private String description; /** * 启动命令(用于stdio传输) */ private String command; /** * 命令参数 */ private List args; /** * 环境变量 */ private Map env; /** * 工作目录 */ private String cwd; /** * 传输类型:stdio, sse, streamable_http */ private String type; /** * 旧版传输类型字段,保留兼容 */ @Deprecated @Builder.Default private String transport = McpTypeSupport.TYPE_STDIO; /** * HTTP/SSE传输时的URL */ private String url; /** * 自定义HTTP头 */ private Map headers; /** * 是否启用 */ @Builder.Default private Boolean enabled = true; /** * 连接超时(毫秒) */ @Builder.Default private Long connectTimeout = 30000L; /** * 标签 */ private List tags; /** * 优先级 */ @Builder.Default private Integer priority = 0; /** * 创建简单的服务器引用(仅指定名称) */ public static McpServerReference of(String name) { return McpServerReference.builder() .name(name) .build(); } /** * 创建stdio传输的服务器引用 */ public static McpServerReference stdio(String name, String command, List args) { return McpServerReference.builder() .name(name) .command(command) .args(args) .type(McpTypeSupport.TYPE_STDIO) .transport(McpTypeSupport.TYPE_STDIO) .build(); } /** * 创建Streamable HTTP传输的服务器引用 */ public static McpServerReference http(String name, String url) { return McpServerReference.builder() .name(name) .url(url) .type(McpTypeSupport.TYPE_STREAMABLE_HTTP) .transport("http") .build(); } /** * 创建SSE传输的服务器引用 */ public static McpServerReference sse(String name, String url) { return McpServerReference.builder() .name(name) .url(url) .type(McpTypeSupport.TYPE_SSE) .transport(McpTypeSupport.TYPE_SSE) .build(); } /** * 获取归一化后的传输类型 */ public String getResolvedType() { return McpTypeSupport.resolveType(this); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpTool.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; /** * @Author cly * @Description MCP工具定义 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpTool { /** * 工具名称 */ @JsonProperty("name") private String name; /** * 工具描述 */ @JsonProperty("description") private String description; /** * 输入参数Schema(JSON Schema格式) */ @JsonProperty("inputSchema") private Map inputSchema; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpToolDefinition.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; /** * @Author cly * @Description MCP工具定义,基于现有Function设计扩展 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpToolDefinition { /** * 工具名称 */ @JsonProperty("name") private String name; /** * 工具描述 */ @JsonProperty("description") private String description; /** * 输入参数Schema(JSON Schema格式) */ @JsonProperty("inputSchema") private Map inputSchema; /** * 工具类的Class对象(用于反射调用) */ private Class functionClass; /** * 请求参数类的Class对象 */ private Class requestClass; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpToolResult.java ================================================ package io.github.lnyocly.ai4j.mcp.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description MCP工具执行结果 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class McpToolResult { /** * 工具名称 */ @JsonProperty("toolName") private String toolName; /** * 执行结果 */ @JsonProperty("result") private Object result; /** * 是否成功 */ @JsonProperty("success") private Boolean success; /** * 错误信息 */ @JsonProperty("error") private String error; /** * 执行时间(毫秒) */ @JsonProperty("executionTime") private Long executionTime; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGateway.java ================================================ package io.github.lnyocly.ai4j.mcp.gateway; import io.github.lnyocly.ai4j.mcp.client.McpClient; import io.github.lnyocly.ai4j.mcp.config.McpConfigIO; import io.github.lnyocly.ai4j.mcp.config.McpConfigSource; import io.github.lnyocly.ai4j.mcp.config.McpServerConfig; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.util.McpToolConversionSupport; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** * @Author cly * @Description 独立的MCP网关,管理多个MCP客户端连接,提供统一的工具调用接口 */ public class McpGateway { private static final Logger log = LoggerFactory.getLogger(McpGateway.class); private static final String DEFAULT_CONFIG_FILE = "mcp-servers-config.json"; // 全局实例管理 private static volatile McpGateway globalInstance = null; private static final Object instanceLock = new Object(); // 管理的MCP客户端 // Key格式: // - 全局服务:serviceId (如 "github", "filesystem") // - 用户服务:user_{userId}_service_{serviceId} (如 "user_123_service_github") private final Map mcpClients; // 工具名称到客户端的映射 // Key格式: // - 全局工具:toolName (如 "search_repositories") // - 用户工具:user_{userId}_tool_{toolName} (如 "user_123_tool_search_repositories") private final McpGatewayToolRegistry toolRegistry; // MCP服务器配置 private McpServerConfig serverConfig; // 配置源(用于动态配置管理) private McpConfigSource configSource; private final McpGatewayClientFactory clientFactory; private final McpGatewayConfigSourceBinding configSourceBinding; // 网关是否已初始化 private volatile boolean initialized = false; /** * 获取全局MCP网关实例 */ public static McpGateway getInstance() { if (globalInstance == null) { synchronized (instanceLock) { if (globalInstance == null) { globalInstance = new McpGateway(); log.info("创建全局MCP网关实例"); } } } return globalInstance; } /** * 设置全局MCP网关实例 */ public static void setGlobalInstance(McpGateway instance) { synchronized (instanceLock) { globalInstance = instance; log.info("设置全局MCP网关实例"); } } /** * 清除全局实例(用于测试) */ public static void clearGlobalInstance() { synchronized (instanceLock) { if (globalInstance != null) { try { globalInstance.shutdown(); } catch (Exception e) { log.warn("关闭全局MCP网关实例时发生错误", e); } globalInstance = null; log.info("清除全局MCP网关实例"); } } } /** * 检查网关是否已初始化 */ public boolean isInitialized() { return initialized; } public McpGateway() { this(new McpGatewayClientFactory()); } McpGateway(McpGatewayClientFactory clientFactory) { this.mcpClients = new ConcurrentHashMap<>(); this.toolRegistry = new McpGatewayToolRegistry(); this.clientFactory = clientFactory; this.configSourceBinding = new McpGatewayConfigSourceBinding(this, clientFactory); } /** * 设置配置源(用于动态配置管理) */ public void setConfigSource(McpConfigSource configSource) { McpConfigSource previousConfigSource = this.configSource; this.configSource = configSource; configSourceBinding.rebind(previousConfigSource, configSource, initialized); } /** * 从配置源加载所有配置 */ private void loadConfigsFromSource() { configSourceBinding.loadAll(configSource); } /** * 初始化MCP网关,从配置文件加载MCP服务器配置 */ public CompletableFuture initialize() { return initialize(DEFAULT_CONFIG_FILE); } /** * 初始化MCP网关,从指定配置文件加载MCP服务器配置 */ public CompletableFuture initialize(String configFile) { return CompletableFuture.runAsync(() -> { if (initialized) { log.warn("MCP网关已经初始化"); return; } log.info("初始化MCP网关,配置文件: {}", configFile); try { // 如果没有设置配置源,则从配置文件加载 if (configSource == null) { loadServerConfig(configFile); // 启动配置的MCP服务器 if (serverConfig != null && serverConfig.getMcpServers() != null) { startConfiguredServers(); } } else { // 使用配置源 loadConfigsFromSource(); } initialized = true; // 自动设置为全局实例(如果还没有全局实例的话) if (globalInstance == null) { setGlobalInstance(this); } log.info("MCP网关初始化完成"); } catch (Exception e) { log.error("初始化MCP网关失败", e); throw new RuntimeException("初始化MCP网关失败", e); } }); } /** * 加载服务器配置文件 */ private void loadServerConfig(String configFile) { try { serverConfig = McpConfigIO.loadServerConfig(configFile, getClass().getClassLoader()); if (serverConfig != null) { log.info("成功加载MCP服务器配置,共 {} 个服务器", serverConfig.getMcpServers() != null ? serverConfig.getMcpServers().size() : 0); } else { log.info("未找到MCP服务器配置文件: {}", configFile); } } catch (Exception e) { log.warn("加载MCP服务器配置失败: {}", configFile, e); } } /** * 启动配置文件中的MCP服务器 */ private void startConfiguredServers() { List> futures = new ArrayList<>(); serverConfig.getMcpServers().forEach((serverId, serverInfo) -> { if (serverInfo.getEnabled() != null && serverInfo.getEnabled()) { CompletableFuture future = startMcpServer(serverId, serverInfo) .exceptionally(throwable -> { log.error("启动MCP服务器失败: {}", serverId, throwable); return null; }); futures.add(future); } }); // 等待所有服务器启动完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } /** * 启动单个MCP服务器 */ private CompletableFuture startMcpServer(String serverId, McpServerConfig.McpServerInfo serverInfo) { return CompletableFuture.runAsync(() -> { log.info("启动MCP服务器: {} ({})", serverId, serverInfo.getName()); try { McpClient client = clientFactory.create(serverId, serverInfo); addMcpClient(serverId, client).join(); log.info("MCP服务器启动成功: {}", serverId); } catch (Exception e) { log.error("启动MCP服务器失败: {}", serverId, e); throw new RuntimeException("启动MCP服务器失败: " + serverId, e); } }); } /** * 添加全局MCP客户端 */ public CompletableFuture addMcpClient(String serviceId, McpClient client) { return addMcpClientInternal(serviceId, client); } /** * 添加用户级别的MCP客户端 */ public CompletableFuture addUserMcpClient(String userId, String serviceId, McpClient client) { String userClientKey = McpGatewayKeySupport.buildUserClientKey(userId, serviceId); return addMcpClientInternal(userClientKey, client); } /** * 内部统一的客户端添加方法 */ private CompletableFuture addMcpClientInternal(String clientKey, McpClient client) { return CompletableFuture.runAsync(() -> { log.info("添加MCP客户端: {}", clientKey); McpClient previousClient = mcpClients.put(clientKey, client); // 连接客户端并获取工具列表 try { client.connect().join(); toolRegistry.refresh(mcpClients).join(); if (previousClient != null && previousClient != client) { disconnectClientQuietly(clientKey, previousClient); } log.info("MCP客户端 {} 添加成功", clientKey); } catch (Exception e) { mcpClients.remove(clientKey, client); if (previousClient != null && previousClient != client) { mcpClients.put(clientKey, previousClient); } disconnectClientQuietly(clientKey, client); log.error("添加MCP客户端失败: {}", clientKey, e); throw new RuntimeException("添加MCP客户端失败", e); } }); } /** * 动态添加MCP服务器 */ public CompletableFuture addMcpServer(String serverId, McpServerConfig.McpServerInfo serverInfo) { return CompletableFuture.runAsync(() -> { log.info("动态添加MCP服务器: {}", serverId); try { McpClient client = clientFactory.create(serverId, serverInfo); addMcpClient(serverId, client).join(); log.info("MCP服务器添加成功: {}", serverId); } catch (Exception e) { log.error("添加MCP服务器失败: {}", serverId, e); throw new RuntimeException("添加MCP服务器失败", e); } }); } /** * 移除全局MCP客户端 */ public CompletableFuture removeMcpClient(String serviceId) { return removeMcpClientInternal(serviceId); } /** * 移除用户级别的MCP客户端 */ public CompletableFuture removeUserMcpClient(String userId, String serviceId) { String userClientKey = McpGatewayKeySupport.buildUserClientKey(userId, serviceId); return removeMcpClientInternal(userClientKey); } /** * 内部统一的客户端移除方法 */ private CompletableFuture removeMcpClientInternal(String clientKey) { return CompletableFuture.runAsync(() -> { McpClient client = mcpClients.remove(clientKey); if (client != null) { log.info("移除MCP客户端: {}", clientKey); try { client.disconnect().join(); toolRegistry.refresh(mcpClients).join(); log.info("MCP客户端 {} 移除成功", clientKey); } catch (Exception e) { log.error("移除MCP客户端时发生错误: {}", clientKey, e); } } }); } /** * 获取所有可用的工具(转换为OpenAI Tool格式) */ public CompletableFuture> getAvailableTools() { if (toolRegistry.getAvailableToolsCache() != null) { return CompletableFuture.completedFuture(toolRegistry.getAvailableToolsCache()); } return toolRegistry.refresh(mcpClients) .thenApply(v -> toolRegistry.getAvailableToolsCache()); } /** * 获取所有可用的工具(转换为OpenAI Tool格式) */ public CompletableFuture> getAvailableTools(List serviceIds) { if (serviceIds != null && !serviceIds.isEmpty()) { return CompletableFuture.completedFuture(mcpClients.entrySet().stream().filter(entry -> serviceIds.contains(entry.getKey())).map(entry -> { return entry.getValue().getAvailableTools().join(); }).flatMap(list -> list.stream().map(McpToolConversionSupport::convertToOpenAiTool)).collect(Collectors.toList())); } if (toolRegistry.getAvailableToolsCache() != null) { return CompletableFuture.completedFuture(toolRegistry.getAvailableToolsCache()); } return toolRegistry.refresh(mcpClients) .thenApply(v -> toolRegistry.getAvailableToolsCache()); } /** * 获取用户的可用工具(包括用户专属工具和全局工具) */ public CompletableFuture> getUserAvailableTools(List serviceIds, String userId) { return CompletableFuture.supplyAsync(() -> { List userTools = new ArrayList<>(); String userPrefix = McpGatewayKeySupport.buildUserPrefix(userId); // 获取用户专属工具 mcpClients.entrySet().stream() .filter(entry -> entry.getKey().startsWith(userPrefix)) .forEach(entry -> { try { List clientTools = entry.getValue().getAvailableTools().join(); for (McpToolDefinition tool : clientTools) { userTools.add(McpToolConversionSupport.convertToOpenAiTool(tool)); } } catch (Exception e) { log.error("获取用户工具列表失败: clientKey={}", entry.getKey(), e); } }); List filterIds = serviceIds != null ? serviceIds : new ArrayList<>(); // 获取全局工具 mcpClients.entrySet().stream() .filter(entry -> { if (McpGatewayKeySupport.isUserClientKey(entry.getKey())) { return false; } if (filterIds.isEmpty()) { return true; } return filterIds.contains(entry.getKey()); }) .forEach(entry -> { try { List clientTools = entry.getValue().getAvailableTools().join(); for (McpToolDefinition tool : clientTools) { userTools.add(McpToolConversionSupport.convertToOpenAiTool(tool)); } } catch (Exception e) { log.error("获取全局工具列表失败: clientKey={}", entry.getKey(), e); } }); return userTools; }); } /** * 调用全局工具 */ public CompletableFuture callTool(String toolName, Object arguments) { return callToolInternal(toolName, arguments); } /** * 调用用户工具(优先使用用户专属,回退到全局) */ public CompletableFuture callUserTool(String userId, String toolName, Object arguments) { String userToolKey = McpGatewayKeySupport.buildUserToolKey(userId, toolName); // 先尝试用户专属工具 String clientId = toolRegistry.getClientId(userToolKey); if (clientId != null) { log.debug("找到用户专属工具: {} -> {}", userToolKey, clientId); return callToolInternal(toolName, arguments, clientId); } // 回退到全局工具 log.debug("未找到用户专属工具,尝试全局工具: {}", toolName); return callToolInternal(toolName, arguments); } /** * 内部工具调用方法(查找全局工具) */ private CompletableFuture callToolInternal(String toolName, Object arguments) { String clientId = toolRegistry.getClientId(toolName); if (clientId == null) { CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(new IllegalArgumentException("工具不存在: " + toolName)); return future; } return callToolInternal(toolName, arguments, clientId); } /** * 内部工具调用方法(指定客户端) */ private CompletableFuture callToolInternal(String toolName, Object arguments, String clientId) { McpClient client = mcpClients.get(clientId); if (client == null || !client.isConnected()) { CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(new IllegalStateException("MCP客户端不可用: " + clientId)); return future; } log.info("调用MCP工具: {} 通过客户端: {}", toolName, clientId); return client.callTool(toolName, arguments) .whenComplete((result, throwable) -> { if (throwable != null) { log.error("调用MCP工具失败: {}", toolName, throwable); } else { log.debug("MCP工具调用成功: {}", toolName); } }); } /** * 清除用户的所有MCP客户端 */ public CompletableFuture clearUserMcpClients(String userId) { return CompletableFuture.runAsync(() -> { String userPrefix = McpGatewayKeySupport.buildUserPrefix(userId); List userClientKeys = mcpClients.keySet().stream() .filter(key -> key.startsWith(userPrefix)) .collect(Collectors.toList()); for (String clientKey : userClientKeys) { try { removeMcpClientInternal(clientKey).join(); } catch (Exception e) { log.error("清除用户MCP客户端失败: clientKey={}", clientKey, e); } } log.info("清除用户所有MCP客户端完成: userId={}, count={}", userId, userClientKeys.size()); }); } /** * 获取网关状态信息 */ public Map getGatewayStatus() { Map status = new HashMap<>(); // 统计全局和用户客户端 long globalClients = mcpClients.keySet().stream() .filter(key -> !McpGatewayKeySupport.isUserClientKey(key)) .count(); long userClients = mcpClients.keySet().stream() .filter(McpGatewayKeySupport::isUserClientKey) .count(); status.put("totalClients", mcpClients.size()); status.put("globalClients", globalClients); status.put("userClients", userClients); status.put("connectedClients", mcpClients.values().stream() .mapToLong(client -> client.isConnected() ? 1 : 0) .sum()); status.put("totalTools", toolRegistry.snapshotMappings().size()); Map clientStatus = new HashMap<>(); mcpClients.forEach((id, client) -> { Map info = new HashMap<>(); info.put("connected", client.isConnected()); info.put("initialized", client.isInitialized()); info.put("type", McpGatewayKeySupport.isUserClientKey(id) ? "user" : "global"); clientStatus.put(id, info); }); status.put("clients", clientStatus); return status; } /** * 获取工具名称到客户端ID的映射 */ public Map getToolToClientMap() { return toolRegistry.snapshotMappings(); } /** * 关闭网关,断开所有客户端连接 */ public CompletableFuture shutdown() { return CompletableFuture.runAsync(() -> { log.info("关闭MCP网关"); List> futures = mcpClients.values().stream() .map(McpClient::disconnect) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); mcpClients.clear(); toolRegistry.clearAll(); log.info("MCP网关已关闭"); }); } private void disconnectClientQuietly(String clientKey, McpClient client) { if (client == null) { return; } try { client.disconnect().join(); } catch (Exception e) { log.warn("关闭旧MCP客户端失败: {}", clientKey, e); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayClientFactory.java ================================================ package io.github.lnyocly.ai4j.mcp.gateway; import io.github.lnyocly.ai4j.mcp.client.McpClient; import io.github.lnyocly.ai4j.mcp.config.McpServerConfig; import io.github.lnyocly.ai4j.mcp.transport.McpTransport; import io.github.lnyocly.ai4j.mcp.transport.McpTransportFactory; import io.github.lnyocly.ai4j.mcp.transport.TransportConfig; import io.github.lnyocly.ai4j.mcp.util.McpTypeSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * MCP Gateway 客户端创建器 */ class McpGatewayClientFactory { private static final Logger log = LoggerFactory.getLogger(McpGatewayClientFactory.class); private static final String DEFAULT_CLIENT_VERSION = "1.0.0"; private final String clientVersion; McpGatewayClientFactory() { this(DEFAULT_CLIENT_VERSION); } McpGatewayClientFactory(String clientVersion) { this.clientVersion = clientVersion; } public McpClient create(String serverId, McpServerConfig.McpServerInfo serverInfo) { String transportType = McpTypeSupport.resolveType(serverInfo); try { TransportConfig config = TransportConfig.fromServerInfo(serverInfo); McpTransportFactory.TransportType factoryType = McpTransportFactory.TransportType.fromString(transportType); McpTransportFactory.validateConfig(factoryType, config); McpTransport transport = McpTransportFactory.createTransport(factoryType, config); return new McpClient(serverId, clientVersion, transport); } catch (Exception e) { log.error("创建MCP客户端失败: serverId={}, transportType={}", serverId, transportType, e); throw new RuntimeException("创建MCP客户端失败: " + e.getMessage(), e); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayConfigSourceBinding.java ================================================ package io.github.lnyocly.ai4j.mcp.gateway; import io.github.lnyocly.ai4j.mcp.client.McpClient; import io.github.lnyocly.ai4j.mcp.config.McpConfigSource; import io.github.lnyocly.ai4j.mcp.config.McpServerConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Map; /** * MCP Gateway 与配置源之间的桥接器 */ class McpGatewayConfigSourceBinding { private static final Logger log = LoggerFactory.getLogger(McpGatewayConfigSourceBinding.class); private final McpGateway gateway; private final McpGatewayClientFactory clientFactory; private final McpConfigSource.ConfigChangeListener configChangeListener; McpGatewayConfigSourceBinding(McpGateway gateway, McpGatewayClientFactory clientFactory) { this.gateway = gateway; this.clientFactory = clientFactory; this.configChangeListener = new McpConfigSource.ConfigChangeListener() { @Override public void onConfigAdded(String serverId, McpServerConfig.McpServerInfo config) { tryAddOrReplace(serverId, config, "动态添加MCP服务成功"); } @Override public void onConfigRemoved(String serverId) { gateway.removeMcpClient(serverId).join(); log.info("动态移除MCP服务: {}", serverId); } @Override public void onConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) { tryAddOrReplace(serverId, config, "动态更新MCP服务成功"); } }; } public void rebind(McpConfigSource currentSource, McpConfigSource nextSource, boolean initialized) { if (currentSource == nextSource) { return; } if (currentSource != null) { currentSource.removeConfigChangeListener(configChangeListener); } if (nextSource != null) { nextSource.addConfigChangeListener(configChangeListener); if (initialized) { loadAll(nextSource); } } } public void loadAll(McpConfigSource configSource) { if (configSource == null) { return; } try { Map configs = configSource.getAllConfigs(); configs.forEach((serverId, config) -> { try { McpClient client = clientFactory.create(serverId, config); gateway.addMcpClient(serverId, client).join(); } catch (Exception e) { log.error("从配置源加载MCP服务失败: {}", serverId, e); } }); log.info("从配置源加载了 {} 个MCP服务", configs.size()); } catch (Exception e) { log.error("从配置源加载配置失败", e); } } private void tryAddOrReplace(String serverId, McpServerConfig.McpServerInfo config, String successLog) { try { McpClient client = clientFactory.create(serverId, config); gateway.addMcpClient(serverId, client).join(); log.info("{}: {}", successLog, serverId); } catch (Exception e) { log.error("{}: {}", successLog.replace("成功", "失败"), serverId, e); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayKeySupport.java ================================================ package io.github.lnyocly.ai4j.mcp.gateway; /** * MCP gateway key 规则辅助类 */ public final class McpGatewayKeySupport { private McpGatewayKeySupport() { } public static String buildUserClientKey(String userId, String serviceId) { return "user_" + userId + "_service_" + serviceId; } public static String buildUserToolKey(String userId, String toolName) { return "user_" + userId + "_tool_" + toolName; } public static String buildUserPrefix(String userId) { return "user_" + userId + "_"; } public static boolean isUserClientKey(String clientKey) { return clientKey != null && clientKey.startsWith("user_") && clientKey.contains("_service_"); } public static String extractUserIdFromClientKey(String clientKey) { if (!isUserClientKey(clientKey)) { throw new IllegalArgumentException("不是有效的用户客户端Key: " + clientKey); } String withoutPrefix = clientKey.substring(5); int serviceIndex = withoutPrefix.indexOf("_service_"); if (serviceIndex > 0) { return withoutPrefix.substring(0, serviceIndex); } throw new IllegalArgumentException("无法从客户端Key中提取用户ID: " + clientKey); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayToolRegistry.java ================================================ package io.github.lnyocly.ai4j.mcp.gateway; import io.github.lnyocly.ai4j.mcp.client.McpClient; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.util.McpToolConversionSupport; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; /** * MCP gateway 工具注册表,负责工具缓存与客户端映射 */ public class McpGatewayToolRegistry { private static final Logger log = LoggerFactory.getLogger(McpGatewayToolRegistry.class); private final Map toolToClientMap = new ConcurrentHashMap(); private volatile List availableTools; public List getAvailableToolsCache() { return availableTools; } public String getClientId(String toolKey) { return toolToClientMap.get(toolKey); } public Map snapshotMappings() { return new HashMap(toolToClientMap); } public void clearClientMappings(String clientKey) { toolToClientMap.entrySet().removeIf(entry -> clientKey.equals(entry.getValue())); availableTools = null; log.debug("清理客户端工具映射: {}", clientKey); } public void clearAll() { toolToClientMap.clear(); availableTools = null; } public CompletableFuture refresh(Map mcpClients) { return CompletableFuture.runAsync(() -> { log.info("刷新MCP工具映射"); List> futures = new ArrayList>(); List refreshedTools = Collections.synchronizedList(new ArrayList()); Map refreshedMappings = new ConcurrentHashMap(); mcpClients.forEach((clientId, client) -> { if (client.isConnected()) { CompletableFuture future = client.getAvailableTools() .thenAccept(tools -> registerClientTools(clientId, tools, refreshedTools, refreshedMappings)) .exceptionally(throwable -> { log.error("获取客户端工具列表失败: {}", clientId, throwable); return null; }); futures.add(future); } }); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); toolToClientMap.clear(); toolToClientMap.putAll(refreshedMappings); availableTools = new ArrayList(refreshedTools); log.info("工具映射刷新完成,共 {} 个工具", refreshedTools.size()); }); } private void registerClientTools( String clientId, List tools, List refreshedTools, Map refreshedMappings) { for (McpToolDefinition tool : tools) { refreshedTools.add(McpToolConversionSupport.convertToOpenAiTool(tool)); if (McpGatewayKeySupport.isUserClientKey(clientId)) { String userId = McpGatewayKeySupport.extractUserIdFromClientKey(clientId); String userToolKey = McpGatewayKeySupport.buildUserToolKey(userId, tool.getName()); refreshedMappings.put(userToolKey, clientId); log.debug("建立用户工具映射: {} -> {}", userToolKey, clientId); } else { refreshedMappings.put(tool.getName(), clientId); log.debug("建立全局工具映射: {} -> {}", tool.getName(), clientId); } } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpHttpServerSupport.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import com.alibaba.fastjson2.JSON; import com.sun.net.httpserver.HttpExchange; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; /** * MCP HTTP 服务端辅助方法 */ public final class McpHttpServerSupport { private McpHttpServerSupport() { } public static void setCorsHeaders(HttpExchange exchange, String allowMethods, String allowHeaders) { exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); exchange.getResponseHeaders().add("Access-Control-Allow-Methods", allowMethods); exchange.getResponseHeaders().add("Access-Control-Allow-Headers", allowHeaders); } public static String readRequestBody(HttpExchange exchange) throws IOException { BufferedReader reader = new BufferedReader( new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8)); try { StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line); } return sb.toString(); } finally { reader.close(); } } public static void writeJsonResponse(HttpExchange exchange, int statusCode, Object payload) throws IOException { writeJsonResponse(exchange, statusCode, payload, null); } public static void writeJsonResponse( HttpExchange exchange, int statusCode, Object payload, Map extraHeaders) throws IOException { byte[] responseBytes = JSON.toJSONString(payload).getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); if (extraHeaders != null) { for (Map.Entry entry : extraHeaders.entrySet()) { exchange.getResponseHeaders().add(entry.getKey(), entry.getValue()); } } exchange.sendResponseHeaders(statusCode, responseBytes.length); OutputStream os = exchange.getResponseBody(); try { os.write(responseBytes); } finally { os.close(); } } public static void sendError(HttpExchange exchange, int statusCode, String message) throws IOException { Map errorData = new HashMap(); errorData.put("code", statusCode); errorData.put("message", message); Map payload = new HashMap(); payload.put("error", errorData); writeJsonResponse(exchange, statusCode, payload); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServer.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import java.util.concurrent.CompletableFuture; /** * MCP服务器公共接口 * 定义所有MCP服务器的基本操作 * * @Author cly */ public interface McpServer { /** * 启动MCP服务器 * * @return CompletableFuture,完成时表示服务器启动完成 */ CompletableFuture start(); /** * 停止MCP服务器 * * @return CompletableFuture,完成时表示服务器停止完成 */ CompletableFuture stop(); /** * 检查服务器是否正在运行 * * @return true表示服务器正在运行,false表示已停止 */ boolean isRunning(); /** * 获取服务器信息 * * @return 服务器信息字符串,包含名称、版本等 */ String getServerInfo(); /** * 获取服务器名称 * * @return 服务器名称 */ String getServerName(); /** * 获取服务器版本 * * @return 服务器版本 */ String getServerVersion(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerEngine.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.entity.McpError; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.entity.McpPrompt; import io.github.lnyocly.ai4j.mcp.entity.McpPromptResult; import io.github.lnyocly.ai4j.mcp.entity.McpResource; import io.github.lnyocly.ai4j.mcp.entity.McpResourceContent; import io.github.lnyocly.ai4j.mcp.entity.McpResponse; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.util.McpPromptAdapter; import io.github.lnyocly.ai4j.mcp.util.McpResourceAdapter; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.tool.ToolUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * MCP 服务端公共协议处理引擎 */ public class McpServerEngine { private static final Logger log = LoggerFactory.getLogger(McpServerEngine.class); private static final String DEFAULT_PROTOCOL_VERSION = "2024-11-05"; private final String serverName; private final String serverVersion; private final List supportedProtocolVersions; private final String defaultProtocolVersion; private final boolean initializationRequired; private final boolean pingEnabled; private final boolean toolsListChanged; public McpServerEngine( String serverName, String serverVersion, List supportedProtocolVersions, String defaultProtocolVersion, boolean initializationRequired, boolean pingEnabled, boolean toolsListChanged) { this.serverName = serverName; this.serverVersion = serverVersion; this.supportedProtocolVersions = supportedProtocolVersions != null ? new ArrayList(supportedProtocolVersions) : new ArrayList(); this.defaultProtocolVersion = defaultProtocolVersion != null ? defaultProtocolVersion : DEFAULT_PROTOCOL_VERSION; this.initializationRequired = initializationRequired; this.pingEnabled = pingEnabled; this.toolsListChanged = toolsListChanged; } public McpMessage processMessage(McpMessage message, McpServerSessionState session) { if (message == null) { return createErrorResponse(null, -32600, "Invalid Request"); } if (message.isRequest()) { String method = message.getMethod(); if ("initialize".equals(method)) { return handleInitialize(message, session); } if ("tools/list".equals(method)) { return handleToolsList(message, session); } if ("tools/call".equals(method)) { return handleToolsCall(message, session); } if ("resources/list".equals(method)) { return handleResourcesList(message, session); } if ("resources/read".equals(method)) { return handleResourcesRead(message, session); } if ("prompts/list".equals(method)) { return handlePromptsList(message, session); } if ("prompts/get".equals(method)) { return handlePromptsGet(message, session); } if ("ping".equals(method) && pingEnabled) { return handlePing(message); } return createErrorResponse(message.getId(), -32601, "Method not found: " + method); } if (message.isNotification()) { handleNotification(message, session); return null; } return createErrorResponse(message.getId(), -32600, "Invalid Request"); } private McpMessage handleInitialize(McpMessage message, McpServerSessionState session) { try { Map params = asMap(message.getParams()); String requestedVersion = params != null ? stringValue(params.get("protocolVersion")) : null; String protocolVersion = resolveProtocolVersion(requestedVersion); Map capabilities = buildCapabilities(); Map serverInfo = new HashMap(); serverInfo.put("name", serverName); serverInfo.put("version", serverVersion); Map result = new HashMap(); result.put("protocolVersion", protocolVersion); result.put("capabilities", capabilities); result.put("serverInfo", serverInfo); if (session != null) { session.setInitialized(true); session.getCapabilities().clear(); session.getCapabilities().putAll(capabilities); } McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(result); return response; } catch (Exception e) { log.error("处理初始化请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private McpMessage handleToolsList(McpMessage message, McpServerSessionState session) { McpMessage initError = requireInitialization(message, session); if (initError != null) { return initError; } try { Map result = new HashMap(); result.put("tools", convertToMcpToolDefinitions()); McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(result); return response; } catch (Exception e) { log.error("处理工具列表请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private McpMessage handleToolsCall(McpMessage message, McpServerSessionState session) { McpMessage initError = requireInitialization(message, session); if (initError != null) { return initError; } try { Map params = asMap(message.getParams()); if (params == null) { return createErrorResponse(message.getId(), -32602, "Invalid params: object is required"); } String toolName = stringValue(params.get("name")); if (toolName == null || toolName.isEmpty()) { return createErrorResponse(message.getId(), -32602, "Invalid params: name is required"); } Object arguments = params.get("arguments"); String result = ToolUtil.invoke(toolName, arguments != null ? JSON.toJSONString(arguments) : "{}"); Map textContent = new HashMap(); textContent.put("type", "text"); textContent.put("text", result != null ? result : ""); Map responseData = new HashMap(); responseData.put("content", Arrays.asList(textContent)); responseData.put("isError", false); McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(responseData); return response; } catch (Exception e) { log.error("处理工具调用请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private McpMessage handleResourcesList(McpMessage message, McpServerSessionState session) { McpMessage initError = requireInitialization(message, session); if (initError != null) { return initError; } try { List resources = McpResourceAdapter.getAllMcpResources(); Map result = new HashMap(); result.put("resources", resources); McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(result); return response; } catch (Exception e) { log.error("处理资源列表请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private McpMessage handleResourcesRead(McpMessage message, McpServerSessionState session) { McpMessage initError = requireInitialization(message, session); if (initError != null) { return initError; } try { Map params = asMap(message.getParams()); String uri = params != null ? stringValue(params.get("uri")) : null; if (uri == null || uri.isEmpty()) { return createErrorResponse(message.getId(), -32602, "Invalid params: uri is required"); } McpResourceContent resourceContent = McpResourceAdapter.readMcpResource(uri); Map content = new HashMap(); content.put("uri", resourceContent.getUri()); content.put("mimeType", resourceContent.getMimeType()); Object contents = resourceContent.getContents(); if (contents instanceof String) { content.put("text", contents); } else { content.put("text", JSON.toJSONString(contents)); } Map result = new HashMap(); result.put("contents", Arrays.asList(content)); McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(result); return response; } catch (Exception e) { log.error("处理资源读取请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private McpMessage handlePromptsList(McpMessage message, McpServerSessionState session) { McpMessage initError = requireInitialization(message, session); if (initError != null) { return initError; } try { List prompts = McpPromptAdapter.getAllMcpPrompts(); Map result = new HashMap(); result.put("prompts", prompts); McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(result); return response; } catch (Exception e) { log.error("处理提示词列表请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private McpMessage handlePromptsGet(McpMessage message, McpServerSessionState session) { McpMessage initError = requireInitialization(message, session); if (initError != null) { return initError; } try { Map params = asMap(message.getParams()); String name = params != null ? stringValue(params.get("name")) : null; Map arguments = params != null ? asMap(params.get("arguments")) : null; if (name == null || name.isEmpty()) { return createErrorResponse(message.getId(), -32602, "Invalid params: name is required"); } McpPromptResult promptResult = McpPromptAdapter.getMcpPrompt(name, arguments); Map content = new HashMap(); content.put("type", "text"); content.put("text", promptResult.getContent()); Map userMessage = new HashMap(); userMessage.put("role", "user"); userMessage.put("content", content); Map result = new HashMap(); result.put("description", promptResult.getDescription()); result.put("messages", Arrays.asList(userMessage)); McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(result); return response; } catch (Exception e) { log.error("处理提示词获取请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private McpMessage handlePing(McpMessage message) { try { Map result = new HashMap(); result.put("status", "pong"); McpResponse response = new McpResponse(); response.setId(message.getId()); response.setResult(result); return response; } catch (Exception e) { log.error("处理 ping 请求失败", e); return createErrorResponse(message.getId(), -32603, "Internal error: " + e.getMessage()); } } private void handleNotification(McpMessage message, McpServerSessionState session) { if ("notifications/initialized".equals(message.getMethod()) && session != null) { session.setInitialized(true); } } private McpMessage requireInitialization(McpMessage message, McpServerSessionState session) { if (initializationRequired && (session == null || !session.isInitialized())) { return createErrorResponse(message != null ? message.getId() : null, -32002, "Server not initialized"); } return null; } private String resolveProtocolVersion(String requestedVersion) { if (requestedVersion != null && supportedProtocolVersions.contains(requestedVersion)) { return requestedVersion; } if (!supportedProtocolVersions.isEmpty() && supportedProtocolVersions.contains(defaultProtocolVersion)) { return defaultProtocolVersion; } if (!supportedProtocolVersions.isEmpty()) { return supportedProtocolVersions.get(0); } return requestedVersion != null ? requestedVersion : defaultProtocolVersion; } private Map buildCapabilities() { Map capabilities = new HashMap(); Map toolsCapability = new HashMap(); if (toolsListChanged) { toolsCapability.put("listChanged", true); } capabilities.put("tools", toolsCapability); Map resourcesCapability = new HashMap(); resourcesCapability.put("subscribe", true); resourcesCapability.put("listChanged", true); capabilities.put("resources", resourcesCapability); Map promptsCapability = new HashMap(); promptsCapability.put("listChanged", true); capabilities.put("prompts", promptsCapability); return capabilities; } private McpResponse createErrorResponse(Object id, int code, String message) { McpError error = new McpError(); error.setCode(code); error.setMessage(message); McpResponse response = new McpResponse(); response.setId(id); response.setError(error); return response; } private List convertToMcpToolDefinitions() { List mcpTools = new ArrayList(); try { List tools = ToolUtil.getLocalMcpTools(); for (Tool tool : tools) { if (tool.getFunction() != null) { McpToolDefinition mcpTool = McpToolDefinition.builder() .name(tool.getFunction().getName()) .description(tool.getFunction().getDescription()) .inputSchema(convertParametersToInputSchema(tool.getFunction().getParameters())) .build(); mcpTools.add(mcpTool); } } } catch (Exception e) { log.error("转换工具列表失败", e); } return mcpTools; } private Map convertParametersToInputSchema(Tool.Function.Parameter parameters) { Map schema = new HashMap(); if (parameters != null) { schema.put("type", parameters.getType()); if (parameters.getProperties() != null) { schema.put("properties", parameters.getProperties()); } if (parameters.getRequired() != null) { schema.put("required", parameters.getRequired()); } } return schema; } private Map asMap(Object value) { if (value == null) { return null; } if (!(value instanceof Map)) { throw new IllegalArgumentException("MCP消息参数必须为对象"); } Map result = new HashMap(); for (Map.Entry entry : ((Map) value).entrySet()) { if (entry.getKey() != null) { result.put(String.valueOf(entry.getKey()), entry.getValue()); } } return result; } private String stringValue(Object value) { return value != null ? String.valueOf(value) : null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerFactory.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import io.github.lnyocly.ai4j.mcp.util.McpTypeSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * MCP服务器工厂类 * 统一创建不同类型的MCP服务器 * * @Author cly */ public class McpServerFactory { private static final Logger log = LoggerFactory.getLogger(McpServerFactory.class); /** * 服务器类型枚举 */ public enum ServerType { STDIO("stdio"), SSE("sse"), STREAMABLE_HTTP("streamable_http"), @Deprecated HTTP("http"); // 向后兼容,映射到streamable_http private final String value; ServerType(String value) { this.value = value; } public String getValue() { return value; } /** * 从字符串创建服务器类型 */ public static ServerType fromString(String value) { if (!McpTypeSupport.isKnownType(value)) { log.warn("未知的服务器类型: {}, 使用默认的stdio", value); } String normalizedType = McpTypeSupport.normalizeType(value); switch (normalizedType) { case McpTypeSupport.TYPE_SSE: return SSE; case McpTypeSupport.TYPE_STREAMABLE_HTTP: return STREAMABLE_HTTP; case McpTypeSupport.TYPE_STDIO: default: return STDIO; } } } /** * 服务器配置类 */ public static class ServerConfig { private String name; private String version; private Integer port; private String host; public ServerConfig(String name, String version) { this.name = name; this.version = version; this.port = 8080; // 默认端口 this.host = "localhost"; // 默认主机 } public ServerConfig withPort(int port) { this.port = port; return this; } public ServerConfig withHost(String host) { this.host = host; return this; } // Getters public String getName() { return name; } public String getVersion() { return version; } public Integer getPort() { return port; } public String getHost() { return host; } // Setters public void setName(String name) { this.name = name; } public void setVersion(String version) { this.version = version; } public void setPort(Integer port) { this.port = port; } public void setHost(String host) { this.host = host; } @Override public String toString() { return "ServerConfig{" + "name='" + name + '\'' + ", version='" + version + '\'' + ", port=" + port + ", host='" + host + '\'' + '}'; } } /** * 创建MCP服务器 * * @param type 服务器类型 * @param config 服务器配置 * @return MCP服务器实例 */ public static McpServer createServer(ServerType type, ServerConfig config) { if (config == null) { throw new IllegalArgumentException("服务器配置不能为空"); } log.debug("创建MCP服务器: type={}, config={}", type, config); switch (type) { case STDIO: return createStdioServer(config); case SSE: return createSseServer(config); case STREAMABLE_HTTP: case HTTP: return createStreamableHttpServer(config); default: throw new IllegalArgumentException("不支持的服务器类型: " + type); } } /** * 便捷方法:从字符串类型创建服务器 */ public static McpServer createServer(String typeString, ServerConfig config) { ServerType type = ServerType.fromString(typeString); return createServer(type, config); } /** * 便捷方法:使用默认配置创建服务器 */ public static McpServer createServer(String typeString, String name, String version) { return createServer(typeString, new ServerConfig(name, version)); } /** * 便捷方法:创建带端口的服务器 */ public static McpServer createServer(String typeString, String name, String version, int port) { return createServer(typeString, new ServerConfig(name, version).withPort(port)); } /** * 创建Stdio服务器 */ private static StdioMcpServer createStdioServer(ServerConfig config) { return new StdioMcpServer(config.getName(), config.getVersion()); } /** * 创建SSE服务器 */ private static SseMcpServer createSseServer(ServerConfig config) { return new SseMcpServer(config.getName(), config.getVersion(), config.getPort()); } /** * 创建Streamable HTTP服务器 */ private static StreamableHttpMcpServer createStreamableHttpServer(ServerConfig config) { return new StreamableHttpMcpServer(config.getName(), config.getVersion(), config.getPort()); } /** * 验证服务器配置 */ public static void validateConfig(ServerType type, ServerConfig config) { if (config == null) { throw new IllegalArgumentException("服务器配置不能为空"); } if (config.getName() == null || config.getName().trim().isEmpty()) { throw new IllegalArgumentException("服务器名称不能为空"); } if (config.getVersion() == null || config.getVersion().trim().isEmpty()) { throw new IllegalArgumentException("服务器版本不能为空"); } switch (type) { case SSE: case STREAMABLE_HTTP: case HTTP: if (config.getPort() == null || config.getPort() <= 0 || config.getPort() > 65535) { throw new IllegalArgumentException("HTTP/SSE服务器需要有效的端口号 (1-65535)"); } break; case STDIO: // Stdio服务器不需要端口 break; } } /** * 获取所有支持的服务器类型 */ public static ServerType[] getSupportedTypes() { return ServerType.values(); } /** * 检查服务器类型是否支持 */ public static boolean isSupported(String typeString) { return McpTypeSupport.isKnownType(typeString); } /** * 启动服务器的通用方法 */ public static void startServer(McpServer server) { try { server.start().join(); } catch (Exception e) { log.error("启动服务器失败", e); throw new RuntimeException("启动服务器失败", e); } } /** * 停止服务器的通用方法 */ public static void stopServer(McpServer server) { try { server.stop().join(); } catch (Exception e) { log.error("停止服务器失败", e); throw new RuntimeException("停止服务器失败", e); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerSessionState.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * MCP 服务端会话状态 */ public class McpServerSessionState { private final String sessionId; private volatile boolean initialized; private final Map capabilities = new ConcurrentHashMap(); public McpServerSessionState(String sessionId) { this.sessionId = sessionId; } public String getSessionId() { return sessionId; } public boolean isInitialized() { return initialized; } public void setInitialized(boolean initialized) { this.initialized = initialized; } public Map getCapabilities() { return capabilities; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerSessionSupport.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import java.util.Map; import java.util.Random; import java.util.function.Function; /** * MCP 服务端会话辅助方法 */ public final class McpServerSessionSupport { private McpServerSessionSupport() { } public static String generateSessionId(String prefix) { return prefix + "_" + System.currentTimeMillis() + "_" + Integer.toHexString(new Random().nextInt()); } public static T getOrCreateSession( Map sessions, String sessionId, String sessionPrefix, Function sessionFactory) { String resolvedSessionId = sessionId; if (resolvedSessionId == null) { resolvedSessionId = generateSessionId(sessionPrefix); } T existing = sessions.get(resolvedSessionId); if (existing != null) { return existing; } T newSession = sessionFactory.apply(resolvedSessionId); sessions.put(resolvedSessionId, newSession); return newSession; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/SseMcpServer.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import com.alibaba.fastjson2.JSON; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.util.McpMessageCodec; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; /** * * 1. 提供两个独立端点:/sse (SSE连接) 和 /message (HTTP POST) * 2. 客户端首先连接/sse端点建立SSE连接 * 3. 服务器发送'endpoint'事件,告知客户端POST端点URL * 4. 客户端使用POST端点发送JSON-RPC消息 * 5. 服务器通过SSE发送'message'事件响应 */ public class SseMcpServer implements McpServer { private static final Logger log = LoggerFactory.getLogger(SseMcpServer.class); private final String serverName; private final String serverVersion; private final int port; private final AtomicBoolean running = new AtomicBoolean(false); private final ConcurrentHashMap sseClients = new ConcurrentHashMap(); private final ConcurrentHashMap sessions = new ConcurrentHashMap(); private final McpServerEngine serverEngine; private HttpServer httpServer; public SseMcpServer(String serverName, String serverVersion, int port) { this.serverName = serverName; this.serverVersion = serverVersion; this.port = port; this.serverEngine = new McpServerEngine( serverName, serverVersion, Collections.singletonList("2024-11-05"), "2024-11-05", true, true, false); } public CompletableFuture start() { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (running.compareAndSet(false, true)) { try { log.info("启动SSE MCP服务器: {} v{}, 端口: {}", serverName, serverVersion, port); httpServer = HttpServer.create(new InetSocketAddress(port), 0); httpServer.createContext("/sse", new SseHandler()); httpServer.createContext("/message", new MessageHandler()); httpServer.createContext("/health", new HealthHandler()); httpServer.createContext("/", new RootHandler()); httpServer.setExecutor(Executors.newCachedThreadPool()); httpServer.start(); log.info("SSE MCP服务器启动成功"); log.info("SSE端点: http://localhost:{}/sse", port); log.info("POST端点: http://localhost:{}/message", port); log.info("健康检查: http://localhost:{}/health", port); } catch (Exception e) { running.set(false); log.error("启动SSE MCP服务器失败", e); throw new RuntimeException("启动SSE MCP服务器失败", e); } } } }); } public CompletableFuture stop() { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (running.compareAndSet(true, false)) { log.info("停止SSE MCP服务器"); if (httpServer != null) { httpServer.stop(5); } sseClients.clear(); sessions.clear(); log.info("SSE MCP服务器已停止"); } } }); } public boolean isRunning() { return running.get(); } public String getServerInfo() { return String.format("%s v%s (sse)", serverName, serverVersion); } public String getServerName() { return serverName; } public String getServerVersion() { return serverVersion; } /** * 会话上下文 */ private static class SessionContext extends McpServerSessionState { private final long createdTime; public SessionContext(String sessionId) { super(sessionId); this.createdTime = System.currentTimeMillis(); } public long getCreatedTime() { return createdTime; } } /** * SSE处理器 - 只处理GET请求建立SSE连接 */ private class SseHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { McpHttpServerSupport.setCorsHeaders(exchange, "GET, POST, OPTIONS", "Content-Type, Mcp-Session-Id, Accept"); String method = exchange.getRequestMethod(); String sessionId = exchange.getRequestHeaders().getFirst("mcp-session-id"); log.debug("SSE端点收到请求: {} {}, Session: {}", method, exchange.getRequestURI().getPath(), sessionId); if ("OPTIONS".equals(method)) { exchange.sendResponseHeaders(200, 0); exchange.close(); return; } if (!"GET".equals(method)) { log.warn("SSE端点收到非GET请求: {}", method); McpHttpServerSupport.sendError(exchange, 405, "Method Not Allowed: SSE endpoint only supports GET"); return; } String acceptHeader = exchange.getRequestHeaders().getFirst("Accept"); if (acceptHeader == null || !acceptHeader.contains("text/event-stream")) { McpHttpServerSupport.sendError(exchange, 400, "Bad Request: Must accept text/event-stream"); return; } establishSseConnection(exchange); } } /** * 消息处理器 - 处理客户端发送的消息 */ private class MessageHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { McpHttpServerSupport.setCorsHeaders(exchange, "GET, POST, OPTIONS", "Content-Type, Mcp-Session-Id, Accept"); String method = exchange.getRequestMethod(); String sessionId = exchange.getRequestHeaders().getFirst("mcp-session-id"); log.debug("POST端点收到请求: {} {}, Session: {}", method, exchange.getRequestURI().getPath(), sessionId); if ("OPTIONS".equals(method)) { exchange.sendResponseHeaders(200, 0); exchange.close(); return; } if (!"POST".equals(method)) { log.warn("POST端点收到非POST请求: {} - 客户端实现可能有误", method); if ("GET".equals(method)) { Map error = new HashMap(); error.put("error", "Method Not Allowed"); error.put("message", "The /message endpoint only accepts POST requests with JSON-RPC messages"); error.put("receivedMethod", method); error.put("expectedMethod", "POST"); error.put("hint", "This suggests a client implementation issue. Check your MCP SSE client configuration."); error.put("correctFlow", new String[]{ "1. Connect to GET /sse to establish SSE connection", "2. Receive 'endpoint' event with message URL", "3. Send POST requests to /message endpoint" }); String errorResponse = JSON.toJSONString(error); exchange.getResponseHeaders().add("Content-Type", "application/json"); byte[] errorBytes = errorResponse.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(405, errorBytes.length); OutputStream os = exchange.getResponseBody(); try { os.write(errorBytes); } finally { os.close(); } } else { McpHttpServerSupport.sendError(exchange, 405, "Method Not Allowed: Message endpoint only supports POST"); } return; } try { handleMessageRequest(exchange); } catch (Exception e) { log.error("处理消息请求失败", e); McpHttpServerSupport.sendError(exchange, 500, "Internal Server Error: " + e.getMessage()); } } private void handleMessageRequest(HttpExchange exchange) throws IOException { String requestBody = McpHttpServerSupport.readRequestBody(exchange); log.debug("收到消息请求: {}", requestBody); McpMessage message = McpMessageCodec.parseMessage(requestBody); String sessionId = findSessionForRequest(); if (sessionId == null) { McpHttpServerSupport.sendError(exchange, 400, "No active SSE connection found. Please establish SSE connection first."); return; } SessionContext session = sessions.get(sessionId); if (session == null) { McpHttpServerSupport.sendError(exchange, 404, "Session not found"); return; } McpMessage response = serverEngine.processMessage(message, session); if (response != null) { sendSseMessage(sessionId, response); } exchange.sendResponseHeaders(204, 0); exchange.close(); } /** * 查找请求对应的会话ID * 根据MCP SSE协议,如果有多个活跃连接,使用最近建立的连接 */ private String findSessionForRequest() { if (sseClients.size() == 1) { return sseClients.keySet().iterator().next(); } String latestSessionId = null; long latestTime = 0L; for (String sessionId : sseClients.keySet()) { SessionContext session = sessions.get(sessionId); if (session != null && session.getCreatedTime() > latestTime) { latestTime = session.getCreatedTime(); latestSessionId = sessionId; } } if (latestSessionId != null) { log.debug("使用最近的SSE会话: {}", latestSessionId); } return latestSessionId; } } /** * 健康检查处理器 */ private class HealthHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { McpHttpServerSupport.setCorsHeaders(exchange, "GET, POST, OPTIONS", "Content-Type, Mcp-Session-Id, Accept"); Map health = new HashMap(); health.put("status", "healthy"); health.put("server", serverName); health.put("version", serverVersion); health.put("timestamp", java.time.Instant.now().toString()); health.put("sessions", sessions.size()); health.put("sseClients", sseClients.size()); Map endpoints = new HashMap(); endpoints.put("sse", "/sse"); endpoints.put("message", "/message"); endpoints.put("health", "/health"); health.put("endpoints", endpoints); McpHttpServerSupport.writeJsonResponse(exchange, 200, health); } } /** * 获取或创建会话 */ private SessionContext getOrCreateSession(String sessionId) { return McpServerSessionSupport.getOrCreateSession(sessions, sessionId, "sse_session", SessionContext::new); } /** * 生成会话ID */ private String generateSessionId() { return McpServerSessionSupport.generateSessionId("sse_session"); } public int getPort() { return port; } /** * 建立SSE连接 */ private void establishSseConnection(HttpExchange exchange) throws IOException { String sessionId = generateSessionId(); SessionContext session = getOrCreateSession(sessionId); log.info("建立SSE连接,会话: {}", sessionId); exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); exchange.getResponseHeaders().add("Cache-Control", "no-cache"); exchange.getResponseHeaders().add("Connection", "keep-alive"); exchange.sendResponseHeaders(200, 0); PrintWriter writer = new PrintWriter( new OutputStreamWriter(exchange.getResponseBody(), StandardCharsets.UTF_8), true); sseClients.put(sessionId, writer); String endpointUrl = "http://localhost:" + port + "/message"; writer.println("event: endpoint"); writer.println("data: " + endpointUrl); writer.println(); writer.flush(); log.info("发送endpoint事件: {}", endpointUrl); try { while (running.get() && !writer.checkError()) { Thread.sleep(30000); if (running.get() && !writer.checkError()) { writer.println("event: ping"); writer.println("data: {}"); writer.println(); writer.flush(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { sseClients.remove(sessionId); sessions.remove(sessionId); try { writer.close(); } catch (Exception e) { log.debug("关闭SSE连接失败", e); } log.info("SSE连接断开,会话: {}", sessionId); } } /** * 通过SSE发送消息 - 符合MCP协议 */ private void sendSseMessage(String sessionId, McpMessage message) { PrintWriter writer = sseClients.get(sessionId); if (writer != null && !writer.checkError()) { try { String messageJson = JSON.toJSONString(message); writer.println("event: message"); writer.println("data: " + messageJson); writer.println(); writer.flush(); log.debug("通过SSE发送message事件到会话 {}: {}", sessionId, messageJson); } catch (Exception e) { log.error("发送SSE消息失败", e); sseClients.remove(sessionId); sessions.remove(sessionId); } } else { log.warn("会话 {} 的SSE连接不可用", sessionId); sseClients.remove(sessionId); sessions.remove(sessionId); } } /** * 根路径处理器 - 提供端点信息和错误诊断 */ private class RootHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { McpHttpServerSupport.setCorsHeaders(exchange, "GET, POST, OPTIONS", "Content-Type, Mcp-Session-Id, Accept"); String method = exchange.getRequestMethod(); String path = exchange.getRequestURI().getPath(); if ("OPTIONS".equals(method)) { exchange.sendResponseHeaders(200, 0); exchange.close(); return; } if ("POST".equals(method)) { log.warn("收到POST请求到根路径,可能是客户端配置错误。正确的端点是: /sse (GET) 和 /message (POST)"); Map error = new HashMap(); error.put("error", "Invalid endpoint for POST requests"); error.put("message", "POST requests should be sent to /message endpoint"); error.put("correctEndpoints", new String[]{ "GET /sse - 建立SSE连接", "POST /message - 发送MCP消息" }); McpHttpServerSupport.writeJsonResponse(exchange, 400, error); return; } Map info = new HashMap(); info.put("server", serverName); info.put("version", serverVersion); info.put("protocol", "MCP SSE Transport (2024-11-05)"); info.put("endpoints", new String[]{ "GET /sse - SSE连接端点", "POST /message - 消息发送端点", "GET /health - 健康检查", "GET / - 端点信息" }); info.put("usage", "First connect to /sse endpoint, then send messages to /message endpoint"); McpHttpServerSupport.writeJsonResponse(exchange, 200, info); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/StdioMcpServer.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.entity.McpResponse; import io.github.lnyocly.ai4j.mcp.util.McpMessageCodec; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.Collections; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; /** * Stdio MCP服务器实现 * 直接处理标准输入输出的MCP服务器,不使用传输层抽象 * * @Author cly */ public class StdioMcpServer implements McpServer { private static final Logger log = LoggerFactory.getLogger(StdioMcpServer.class); private final String serverName; private final String serverVersion; private final AtomicBoolean running; private final McpServerSessionState sessionState; private final McpServerEngine serverEngine; public StdioMcpServer(String serverName, String serverVersion) { this.serverName = serverName; this.serverVersion = serverVersion; this.running = new AtomicBoolean(false); this.sessionState = new McpServerSessionState("stdio"); this.serverEngine = new McpServerEngine( serverName, serverVersion, Collections.singletonList("2024-11-05"), "2024-11-05", false, false, false); log.info("Stdio MCP服务器已创建: {} v{}", serverName, serverVersion); } /** * 启动MCP服务器 */ public CompletableFuture start() { return CompletableFuture.runAsync(() -> { if (running.compareAndSet(false, true)) { log.info("启动Stdio MCP服务器: {} v{}", serverName, serverVersion); try { BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); String line; log.info("Stdio MCP服务器启动成功,等待stdin输入..."); while (running.get() && (line = reader.readLine()) != null) { try { if (!line.trim().isEmpty()) { McpMessage message = McpMessageCodec.parseMessage(line); handleMessage(message); } } catch (Exception e) { log.error("处理stdin消息失败: {}", line, e); sendResponse(createInternalErrorResponse(null, e)); } } } catch (Exception e) { running.set(false); log.error("启动Stdio MCP服务器失败", e); throw new RuntimeException("启动Stdio MCP服务器失败", e); } } }); } /** * 停止MCP服务器 */ public CompletableFuture stop() { return CompletableFuture.runAsync(() -> { if (running.compareAndSet(true, false)) { log.info("停止Stdio MCP服务器"); sessionState.setInitialized(false); sessionState.getCapabilities().clear(); log.info("Stdio MCP服务器已停止"); } }); } /** * 检查服务器是否正在运行 */ public boolean isRunning() { return running.get(); } /** * 获取服务器信息 */ public String getServerInfo() { return String.format("%s v%s (stdio)", serverName, serverVersion); } /** * 获取服务器名称 */ public String getServerName() { return serverName; } /** * 获取服务器版本 */ public String getServerVersion() { return serverVersion; } /** * 处理MCP消息 */ private void handleMessage(McpMessage message) { try { log.debug("处理Stdio消息: {}", message); McpMessage response = serverEngine.processMessage(message, sessionState); if (response != null) { sendResponse(response); } } catch (Exception e) { log.error("处理Stdio消息时发生错误", e); sendResponse(createInternalErrorResponse(message, e)); } } /** * 发送响应 */ private void sendResponse(McpMessage response) { try { String jsonResponse = JSON.toJSONString(response); System.out.println(jsonResponse); System.out.flush(); log.debug("发送响应到stdout: {}", jsonResponse); } catch (Exception e) { log.error("发送响应失败", e); } } private McpResponse createInternalErrorResponse(McpMessage originalMessage, Exception error) { McpResponse errorResponse = new McpResponse(); if (originalMessage != null) { errorResponse.setId(originalMessage.getId()); } io.github.lnyocly.ai4j.mcp.entity.McpError mcpError = new io.github.lnyocly.ai4j.mcp.entity.McpError(); mcpError.setCode(-32603); mcpError.setMessage("Internal error: " + error.getMessage()); errorResponse.setError(mcpError); return errorResponse; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/StreamableHttpMcpServer.java ================================================ package io.github.lnyocly.ai4j.mcp.server; import com.alibaba.fastjson2.JSON; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.util.McpMessageCodec; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; /** * Streamable HTTP MCP服务器实现 * 支持MCP 2025-03-26规范 */ public class StreamableHttpMcpServer implements McpServer { private static final Logger log = LoggerFactory.getLogger(StreamableHttpMcpServer.class); private final String serverName; private final String serverVersion; private final int port; private final AtomicBoolean running = new AtomicBoolean(false); private final ConcurrentHashMap sseClients = new ConcurrentHashMap(); private final ConcurrentHashMap sessions = new ConcurrentHashMap(); private final McpServerEngine serverEngine; private HttpServer httpServer; public StreamableHttpMcpServer(String serverName, String serverVersion, int port) { this.serverName = serverName; this.serverVersion = serverVersion; this.port = port; this.serverEngine = new McpServerEngine( serverName, serverVersion, Arrays.asList("2025-03-26", "2024-11-05"), "2025-03-26", true, false, true); } public CompletableFuture start() { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (running.compareAndSet(false, true)) { try { log.info("启动Streamable HTTP MCP服务器: {} v{}, 端口: {}", serverName, serverVersion, port); httpServer = HttpServer.create(new InetSocketAddress(port), 0); httpServer.createContext("/mcp", new McpHandler()); httpServer.createContext("/", new RootHandler()); httpServer.createContext("/health", new HealthHandler()); httpServer.setExecutor(Executors.newCachedThreadPool()); httpServer.start(); log.info("Streamable HTTP MCP服务器启动成功"); log.info("MCP端点: http://localhost:{}/mcp", port); log.info("根路径: http://localhost:{}/", port); log.info("健康检查: http://localhost:{}/health", port); } catch (Exception e) { running.set(false); log.error("启动Streamable HTTP MCP服务器失败", e); throw new RuntimeException("启动Streamable HTTP MCP服务器失败", e); } } } }); } public CompletableFuture stop() { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (running.compareAndSet(true, false)) { log.info("停止Streamable HTTP MCP服务器"); if (httpServer != null) { httpServer.stop(5); } sseClients.clear(); sessions.clear(); log.info("Streamable HTTP MCP服务器已停止"); } } }); } public boolean isRunning() { return running.get(); } public String getServerInfo() { return String.format("%s v%s (streamable_http)", serverName, serverVersion); } public String getServerName() { return serverName; } public String getServerVersion() { return serverVersion; } /** * 会话上下文 */ private static class SessionContext extends McpServerSessionState { private final long createdTime; public SessionContext(String sessionId) { super(sessionId); this.createdTime = System.currentTimeMillis(); } public long getCreatedTime() { return createdTime; } } /** * MCP处理器 - 支持POST和GET */ private class McpHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { McpHttpServerSupport.setCorsHeaders( exchange, "GET, POST, DELETE, OPTIONS", "Content-Type, mcp-session-id, last-event-id, Accept"); if ("OPTIONS".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(200, 0); exchange.close(); return; } String method = exchange.getRequestMethod(); String acceptHeader = exchange.getRequestHeaders().getFirst("Accept"); String sessionId = exchange.getRequestHeaders().getFirst("mcp-session-id"); log.debug("收到请求: {} {}, Accept: {}, Session: {}", method, exchange.getRequestURI().getPath(), acceptHeader, sessionId); try { if ("POST".equals(method)) { handlePostRequest(exchange); } else if ("GET".equals(method)) { handleGetRequest(exchange); } else if ("DELETE".equals(method)) { handleDeleteRequest(exchange); } else { McpHttpServerSupport.sendError(exchange, 405, "Method Not Allowed"); } } catch (Exception e) { log.error("处理MCP请求失败", e); McpHttpServerSupport.sendError(exchange, 500, "Internal Server Error: " + e.getMessage()); } } /** * 处理POST请求 - 客户端到服务器的消息 */ private void handlePostRequest(HttpExchange exchange) throws IOException { String requestBody = McpHttpServerSupport.readRequestBody(exchange); log.debug("收到POST请求: {}", requestBody); processClientMessage(exchange, requestBody); } /** * 处理GET请求 - 建立SSE连接或返回服务器信息 */ private void handleGetRequest(HttpExchange exchange) throws IOException { String acceptHeader = exchange.getRequestHeaders().getFirst("Accept"); if (acceptHeader != null && acceptHeader.contains("text/event-stream")) { establishSseConnection(exchange); } else { Map info = new HashMap(); info.put("server", serverName); info.put("version", serverVersion); info.put("protocol", "MCP Streamable HTTP"); info.put("message", "MCP Server is running"); info.put("supportedVersions", Arrays.asList("2024-11-05", "2025-03-26")); McpHttpServerSupport.writeJsonResponse(exchange, 200, info); } } /** * 处理DELETE请求 - 终止会话 */ private void handleDeleteRequest(HttpExchange exchange) throws IOException { String sessionId = exchange.getRequestHeaders().getFirst("mcp-session-id"); if (sessionId != null && sessions.containsKey(sessionId)) { sessions.remove(sessionId); sseClients.remove(sessionId); log.info("会话已终止: {}", sessionId); exchange.sendResponseHeaders(204, 0); } else { McpHttpServerSupport.sendError(exchange, 404, "Session not found"); } exchange.close(); } } /** * 获取或创建会话 */ private SessionContext getOrCreateSession(String sessionId) { return McpServerSessionSupport.getOrCreateSession(sessions, sessionId, "mcp_session", SessionContext::new); } /** * 生成会话ID */ private String generateSessionId() { return McpServerSessionSupport.generateSessionId("mcp_session"); } private void processClientMessage(HttpExchange exchange, String requestBody) throws IOException { McpMessage message = McpMessageCodec.parseMessage(requestBody); String sessionId = exchange.getRequestHeaders().getFirst("mcp-session-id"); SessionContext session = getOrCreateSession(sessionId); McpMessage response = serverEngine.processMessage(message, session); if (response != null) { String acceptHeader = exchange.getRequestHeaders().getFirst("Accept"); boolean acceptsSse = acceptHeader != null && acceptHeader.contains("text/event-stream"); if (acceptsSse && message.isRequest()) { sendSseResponse(exchange, response, session); } else { sendJsonResponse(exchange, response, session); } } else { exchange.sendResponseHeaders(202, 0); exchange.close(); } } /** * 发送SSE流响应 */ private void sendSseResponse(HttpExchange exchange, McpMessage response, SessionContext session) throws IOException { log.debug("发送SSE流响应: {}", JSON.toJSONString(response)); exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); exchange.getResponseHeaders().add("Cache-Control", "no-cache"); exchange.getResponseHeaders().add("Connection", "keep-alive"); if (session != null) { exchange.getResponseHeaders().add("mcp-session-id", session.getSessionId()); } exchange.sendResponseHeaders(200, 0); PrintWriter writer = new PrintWriter( new OutputStreamWriter(exchange.getResponseBody(), StandardCharsets.UTF_8), true); try { String responseJson = JSON.toJSONString(response); writer.println("data: " + responseJson); writer.println(); writer.flush(); writer.close(); } catch (Exception e) { log.error("发送SSE响应失败", e); writer.close(); } } /** * 发送JSON响应 */ private void sendJsonResponse(HttpExchange exchange, McpMessage response, SessionContext session) throws IOException { log.debug("发送JSON响应: {}", JSON.toJSONString(response)); Map headers = null; if (session != null) { headers = new HashMap(); headers.put("mcp-session-id", session.getSessionId()); } McpHttpServerSupport.writeJsonResponse(exchange, 200, response, headers); } /** * 建立SSE连接 */ private void establishSseConnection(HttpExchange exchange) throws IOException { String sessionId = exchange.getRequestHeaders().getFirst("mcp-session-id"); SessionContext session = null; if (sessionId != null) { session = sessions.get(sessionId); } if (session == null) { session = getOrCreateSession(sessionId); sessionId = session.getSessionId(); log.info("为SSE连接创建新会话: {}", sessionId); } log.info("建立SSE连接,会话: {}", sessionId); exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); exchange.getResponseHeaders().add("Cache-Control", "no-cache"); exchange.getResponseHeaders().add("Connection", "keep-alive"); exchange.getResponseHeaders().add("mcp-session-id", sessionId); exchange.sendResponseHeaders(200, 0); PrintWriter writer = new PrintWriter( new OutputStreamWriter(exchange.getResponseBody(), StandardCharsets.UTF_8), true); sseClients.put(sessionId, writer); writer.println("event: connected"); writer.println("data: {\"message\":\"SSE连接已建立\",\"sessionId\":\"" + sessionId + "\"}"); writer.println(); try { while (running.get() && !writer.checkError()) { Thread.sleep(1000); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { sseClients.remove(sessionId); writer.close(); log.info("SSE连接断开,会话: {}", sessionId); } } /** * 健康检查处理器 */ private class HealthHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { McpHttpServerSupport.setCorsHeaders( exchange, "GET, POST, DELETE, OPTIONS", "Content-Type, mcp-session-id, last-event-id, Accept"); Map health = new HashMap(); health.put("status", "healthy"); health.put("server", serverName); health.put("version", serverVersion); health.put("timestamp", java.time.Instant.now().toString()); health.put("sessions", sessions.size()); health.put("sseClients", sseClients.size()); Map endpoints = new HashMap(); endpoints.put("mcp", "/mcp"); endpoints.put("health", "/health"); health.put("endpoints", endpoints); McpHttpServerSupport.writeJsonResponse(exchange, 200, health); } } public int getPort() { return port; } /** * 根路径处理器 - 提供端点信息或重定向到MCP端点 */ private class RootHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { McpHttpServerSupport.setCorsHeaders( exchange, "GET, POST, DELETE, OPTIONS", "Content-Type, mcp-session-id, last-event-id, Accept"); if ("OPTIONS".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(200, 0); exchange.close(); return; } String method = exchange.getRequestMethod(); String path = exchange.getRequestURI().getPath(); if ("POST".equals(method) && "/".equals(path)) { String acceptHeader = exchange.getRequestHeaders().getFirst("Accept"); String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); if (acceptHeader != null && acceptHeader.contains("application/json") && contentType != null && contentType.contains("application/json")) { log.info("检测到根路径的MCP请求,转发到/mcp端点"); String requestBody = McpHttpServerSupport.readRequestBody(exchange); if (requestBody.contains("\"jsonrpc\"") && requestBody.contains("\"method\"")) { try { processClientMessage(exchange, requestBody); return; } catch (Exception e) { log.error("处理根路径MCP请求失败", e); McpHttpServerSupport.sendError(exchange, 500, "Internal Server Error: " + e.getMessage()); return; } } } } Map info = new HashMap(); info.put("server", serverName); info.put("version", serverVersion); info.put("protocol", "MCP Streamable HTTP"); info.put("message", "MCP Server is running"); Map endpoints = new HashMap(); endpoints.put("mcp", "/mcp"); endpoints.put("health", "/health"); info.put("endpoints", endpoints); Map usage = new HashMap(); usage.put("mcp_endpoint", "POST/GET to /mcp - Streamable HTTP protocol"); usage.put("health_check", "GET /health for server status"); usage.put("documentation", "See MCP Streamable HTTP specification"); info.put("usage", usage); McpHttpServerSupport.writeJsonResponse(exchange, 200, info); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/McpTransport.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import java.util.concurrent.CompletableFuture; /** * @Author cly * @Description MCP传输层接口 */ public interface McpTransport { /** * 启动传输层 */ CompletableFuture start(); /** * 停止传输层 */ CompletableFuture stop(); /** * 发送消息 * @param message 要发送的消息 * @return 发送结果 */ CompletableFuture sendMessage(McpMessage message); /** * 设置消息接收处理器 * @param handler 消息处理器 */ void setMessageHandler(McpMessageHandler handler); /** * 检查连接状态 * @return 是否已连接 */ boolean isConnected(); /** * 指示此传输方式是否需要应用层的心跳来保持连接或会话活跃。 * 基于网络的传输(如 SSE, Streamable HTTP)通常返回 true。 * 基于本地进程的传输(如 stdio)通常返回 false。 * @return 如果需要心跳则为 true,否则为 false */ boolean needsHeartbeat(); /** * 获取传输类型 * @return 传输类型名称 */ String getTransportType(); /** * 消息处理器接口 */ interface McpMessageHandler { /** * 处理接收到的消息 * @param message 接收到的消息 */ void handleMessage(McpMessage message); /** * 处理连接事件 */ void onConnected(); /** * 处理断开连接事件 * @param reason 断开原因 */ void onDisconnected(String reason); /** * 处理错误事件 * @param error 错误信息 */ void onError(Throwable error); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/McpTransportFactory.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import io.github.lnyocly.ai4j.mcp.util.McpTypeSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * MCP传输层工厂类 * 支持创建stdio、sse、streamable_http三种传输协议的实例 * * @Author cly */ public class McpTransportFactory { private static final Logger log = LoggerFactory.getLogger(McpTransportFactory.class); /** * 传输协议类型枚举 */ public enum TransportType { STDIO("stdio"), SSE("sse"), STREAMABLE_HTTP("streamable_http"), @Deprecated HTTP("http"); // 向后兼容,映射到streamable_http private final String value; TransportType(String value) { this.value = value; } public String getValue() { return value; } /** * 从字符串创建传输类型 */ public static TransportType fromString(String value) { if (!McpTypeSupport.isKnownType(value)) { log.warn("未知的传输类型: {}, 使用默认的stdio", value); } String normalizedType = McpTypeSupport.normalizeType(value); switch (normalizedType) { case McpTypeSupport.TYPE_SSE: return SSE; case McpTypeSupport.TYPE_STREAMABLE_HTTP: return STREAMABLE_HTTP; case McpTypeSupport.TYPE_STDIO: default: return STDIO; } } } /** * 创建传输层实例 * * @param type 传输类型 * @param config 传输配置 * @return 传输层实例 */ public static McpTransport createTransport(TransportType type, TransportConfig config) { if (config == null) { throw new IllegalArgumentException("传输配置不能为空"); } log.debug("创建传输层实例: type={}, config={}", type, config); switch (type) { case STDIO: return createStdioTransport(config); case SSE: return createSseTransport(config); case STREAMABLE_HTTP: case HTTP: return createStreamableHttpTransport(config); default: throw new IllegalArgumentException("不支持的传输类型: " + type); } } /** * 便捷方法:从字符串类型创建传输层 */ public static McpTransport createTransport(String typeString, TransportConfig config) { TransportType type = TransportType.fromString(typeString); return createTransport(type, config); } /** * 创建stdio传输层 */ private static McpTransport createStdioTransport(TransportConfig config) { String command = config.getCommand(); if (command == null || command.trim().isEmpty()) { throw new IllegalArgumentException("Stdio传输需要指定command参数"); } return new StdioTransport( command.trim(), config.getArgs(), config.getEnv() ); } /** * 创建SSE传输层 */ private static McpTransport createSseTransport(TransportConfig config) { String url = config.getUrl(); if (url == null || url.trim().isEmpty()) { throw new IllegalArgumentException("SSE传输需要指定url参数"); } return new SseTransport(config); } /** * 创建Streamable HTTP传输层 */ private static McpTransport createStreamableHttpTransport(TransportConfig config) { String url = config.getUrl(); if (url == null || url.trim().isEmpty()) { throw new IllegalArgumentException("Streamable HTTP传输需要指定url参数"); } config.setUrl(url.trim()); return new StreamableHttpTransport(config); } /** * 验证传输配置 */ public static void validateConfig(TransportType type, TransportConfig config) { if (config == null) { throw new IllegalArgumentException("传输配置不能为空"); } switch (type) { case STDIO: if (config.getCommand() == null || config.getCommand().trim().isEmpty()) { throw new IllegalArgumentException("Stdio传输需要指定command参数"); } break; case SSE: case STREAMABLE_HTTP: case HTTP: if (config.getUrl() == null || config.getUrl().trim().isEmpty()) { throw new IllegalArgumentException(type + "传输需要指定url参数"); } break; } } /** * 获取所有支持的传输类型 */ public static TransportType[] getSupportedTypes() { return TransportType.values(); } /** * 检查传输类型是否支持 */ public static boolean isSupported(String typeString) { return McpTypeSupport.isKnownType(typeString); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/McpTransportSupport.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import okhttp3.Response; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.nio.charset.StandardCharsets; /** * MCP transport 辅助方法 */ public final class McpTransportSupport { private McpTransportSupport() { } public static String safeMessage(Throwable throwable) { String message = null; Throwable last = throwable; Throwable current = throwable; while (current != null) { if (!isBlank(current.getMessage())) { message = current.getMessage().trim(); } last = current; current = current.getCause(); } return !isBlank(message) ? message : (last == null ? "unknown transport error" : last.getClass().getSimpleName()); } public static String clip(String value, int maxLength) { if (value == null || value.length() <= maxLength || maxLength < 4) { return value; } return value.substring(0, maxLength - 3) + "..."; } public static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } public static String buildHttpFailureMessage(int statusCode, String responseMessage, String responseBody) { StringBuilder builder = new StringBuilder(); builder.append("HTTP请求失败: ") .append(statusCode) .append(' ') .append(responseMessage == null ? "(unknown)" : responseMessage); String detail = extractErrorDetail(responseBody); if (!isBlank(detail)) { builder.append(": ").append(detail); } return builder.toString(); } public static String buildHttpFailureMessage(Response response) throws IOException { StringBuilder builder = new StringBuilder(); builder.append("HTTP请求失败: ") .append(response == null ? "(unknown)" : response.code()) .append(' ') .append(response == null ? "(unknown)" : response.message()); String detail = extractErrorDetail(response); if (!isBlank(detail)) { builder.append(": ").append(detail); } return builder.toString(); } public static String readResponseBody(HttpURLConnection connection, int statusCode) { InputStream stream = null; try { stream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream(); if (stream == null) { return null; } ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[512]; int read; while ((read = stream.read(buffer)) != -1) { outputStream.write(buffer, 0, read); } return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); } catch (IOException e) { return null; } finally { closeQuietly(stream); } } public static void closeQuietly(InputStream inputStream) { if (inputStream == null) { return; } try { inputStream.close(); } catch (IOException ignored) { } } private static String extractErrorDetail(Response response) throws IOException { if (response == null || response.body() == null) { return null; } return extractErrorDetail(response.body().string()); } private static String extractErrorDetail(String raw) { if (isBlank(raw)) { return null; } try { JSONObject json = JSON.parseObject(raw); if (json != null) { JSONObject error = json.getJSONObject("error"); if (error != null && !isBlank(error.getString("message"))) { return error.getString("message").trim(); } if (!isBlank(json.getString("message"))) { return json.getString("message").trim(); } } } catch (Exception ignored) { } return clip(raw.trim(), 160); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/SseTransport.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.entity.*; import io.github.lnyocly.ai4j.mcp.util.McpMessageCodec; import okhttp3.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class SseTransport implements McpTransport { private static final Logger log = LoggerFactory.getLogger(SseTransport.class); private final String sseEndpointUrl; private final OkHttpClient httpClient; private final AtomicBoolean running = new AtomicBoolean(false); private final Map initialQueryParams = new java.util.LinkedHashMap<>(); private McpMessageHandler messageHandler; private String messageEndpointUrl; // 从endpoint事件中获取 private volatile boolean endpointReceived = false; private volatile HttpURLConnection sseConnection; private volatile InputStream sseInputStream; private volatile Thread sseReaderThread; /** * 自定义HTTP头(用于认证等) */ private Map headers; public SseTransport(String sseEndpointUrl) { parseInitialQueryParams(sseEndpointUrl); this.sseEndpointUrl = sseEndpointUrl; this.httpClient = createDefaultHttpClient(); } public SseTransport(TransportConfig config) { parseInitialQueryParams(config.getUrl()); this.sseEndpointUrl = config.getUrl(); this.httpClient = createDefaultHttpClient(); this.headers = config.getHeaders(); } public SseTransport(String sseEndpointUrl, OkHttpClient httpClient) { parseInitialQueryParams(sseEndpointUrl); this.sseEndpointUrl = sseEndpointUrl; this.httpClient = httpClient; } private void parseInitialQueryParams(String url) { try { HttpUrl httpUrl = HttpUrl.get(url); if (httpUrl != null) { // HttpUrl.queryParameterNames() 返回 Set for (String name : httpUrl.queryParameterNames()) { // 取第一个值(如有多值,可按需改为 list) String value = httpUrl.queryParameter(name); if (value != null) { initialQueryParams.put(name, value); } } } } catch (Exception e) { log.debug("解析 SSE URL 查询参数失败:{}", e.getMessage()); } } private static OkHttpClient createDefaultHttpClient() { return new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // SSE需要长连接 .writeTimeout(60, TimeUnit.SECONDS) .pingInterval(30, TimeUnit.SECONDS) // 每30秒发送一个 ping 帧来保持连接 .build(); } @Override public CompletableFuture start() { return CompletableFuture.runAsync(() -> { if (running.compareAndSet(false, true)) { log.debug("启动SSE传输层,连接到: {}", sseEndpointUrl); try { // 建立SSE连接 startSseConnection(); // 等待endpoint事件 waitForEndpointEvent(); if (messageHandler != null) { messageHandler.onConnected(); } } catch (Exception e) { running.set(false); closeSseResources(); waitForReaderThreadToExit(); log.debug("启动SSE传输层失败: {}", McpTransportSupport.safeMessage(e), e); if (messageHandler != null) { messageHandler.onError(e); } throw new RuntimeException(McpTransportSupport.safeMessage(e), e); } } }); } @Override public CompletableFuture stop() { return CompletableFuture.runAsync(() -> { if (running.compareAndSet(true, false)) { log.debug("停止SSE传输层"); closeSseResources(); endpointReceived = false; messageEndpointUrl = null; if (messageHandler != null) { messageHandler.onDisconnected("Transport stopped"); } log.debug("SSE传输层已停止"); } }); } @Override public CompletableFuture sendMessage(McpMessage message) { return CompletableFuture.runAsync(() -> { if (!running.get()) { throw new IllegalStateException("SSE传输层未启动"); } if (!endpointReceived || messageEndpointUrl == null) { throw new IllegalStateException("尚未收到服务器endpoint事件,无法发送消息"); } try { String jsonMessage = JSON.toJSONString(message); log.debug("通过POST发送消息到 {}: {}", messageEndpointUrl, jsonMessage); // 从消息端点URL中提取会话ID String sessionId = extractSessionId(messageEndpointUrl); HttpURLConnection connection = null; try { connection = openPostConnection(messageEndpointUrl, sessionId); byte[] requestBytes = jsonMessage.getBytes(StandardCharsets.UTF_8); OutputStream outputStream = connection.getOutputStream(); try { outputStream.write(requestBytes); outputStream.flush(); } finally { outputStream.close(); } int statusCode = connection.getResponseCode(); String responseMessage = connection.getResponseMessage(); String responseBody = McpTransportSupport.readResponseBody(connection, statusCode); int responseLength = responseBody == null ? 0 : responseBody.length(); log.debug("HTTP响应: 状态码={}, 消息={}, 响应体长度={}", statusCode, responseMessage, responseLength); if (responseLength > 0) { log.debug("HTTP响应体: {}", responseBody); } if ((statusCode < 200 || statusCode >= 300) && statusCode != 202 && statusCode != 204) { throw new IOException(McpTransportSupport.buildHttpFailureMessage(statusCode, responseMessage, responseBody)); } if (statusCode == 202) { log.debug("消息已提交异步处理,状态码: {}", statusCode); } else if (statusCode == 204) { log.debug("消息已接收,将通过SSE异步响应,状态码: {}", statusCode); } else { log.debug("消息发送成功,状态码: {}", statusCode); } } finally { disconnectQuietly(connection); } } catch (Exception e) { log.debug("发送消息失败: {}", McpTransportSupport.safeMessage(e), e); if (messageHandler != null) { messageHandler.onError(e); } throw new RuntimeException(McpTransportSupport.safeMessage(e), e); } }); } @Override public void setMessageHandler(McpMessageHandler handler) { this.messageHandler = handler; } @Override public boolean isConnected() { return running.get() && sseReaderThread != null && sseReaderThread.isAlive() && endpointReceived; } @Override public boolean needsHeartbeat() { return false; } @Override public String getTransportType() { return "sse"; } /** * 启动SSE连接 */ private void startSseConnection() { Thread readerThread = new Thread(new Runnable() { @Override public void run() { readEventStream(); } }, "mcp-sse-reader-" + Integer.toHexString(System.identityHashCode(this))); readerThread.setDaemon(true); this.sseReaderThread = readerThread; readerThread.start(); log.debug("SSE连接读取线程已启动: {}", sseEndpointUrl); } /** * 等待endpoint事件 */ private void waitForEndpointEvent() { int maxWaitSeconds = 10; int waitedSeconds = 0; while (!endpointReceived && waitedSeconds < maxWaitSeconds && running.get()) { try { Thread.sleep(1000); waitedSeconds++; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("等待endpoint事件被中断", e); } } if (!endpointReceived) { throw new RuntimeException("超时等待服务器endpoint事件"); } log.debug("已收到endpoint事件,消息端点: {}", messageEndpointUrl); } private void readEventStream() { HttpURLConnection connection = null; InputStream inputStream = null; BufferedReader reader = null; try { connection = openSseConnection(); inputStream = connection.getInputStream(); reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); this.sseConnection = connection; this.sseInputStream = inputStream; log.debug("SSE连接已建立,状态码: {}", connection.getResponseCode()); processEventStream(reader); handleSseClosure("SSE connection closed"); } catch (Exception e) { if (!running.get()) { log.debug("SSE读取线程已按停止请求退出: {}", e.getMessage()); return; } log.debug("SSE连接失败: {}", McpTransportSupport.safeMessage(e), e); if (messageHandler != null) { messageHandler.onError(e); } handleSseClosure("SSE connection failed: " + e.getMessage()); } finally { closeQuietly(reader); closeQuietly(inputStream); disconnectQuietly(connection); this.sseInputStream = null; this.sseConnection = null; if (Thread.currentThread() == this.sseReaderThread) { this.sseReaderThread = null; } } } private HttpURLConnection openSseConnection() throws IOException { HttpURLConnection connection = (HttpURLConnection) new URL(sseEndpointUrl).openConnection(); connection.setRequestMethod("GET"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setRequestProperty("Cache-Control", "no-cache"); connection.setRequestProperty("User-Agent", "ai4j-mcp-client/1.0.0"); connection.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(30)); connection.setReadTimeout(0); connection.setDoInput(true); if (headers != null) { for (Map.Entry entry : headers.entrySet()) { connection.setRequestProperty(entry.getKey(), entry.getValue()); } } int statusCode = connection.getResponseCode(); if (statusCode != HttpURLConnection.HTTP_OK) { throw new IOException("SSE连接失败,状态码: " + statusCode); } return connection; } private HttpURLConnection openPostConnection(String endpointUrl, String sessionId) throws IOException { HttpURLConnection connection = (HttpURLConnection) new URL(endpointUrl).openConnection(); connection.setRequestMethod("POST"); connection.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(30)); connection.setReadTimeout((int) TimeUnit.SECONDS.toMillis(60)); connection.setDoOutput(true); connection.setDoInput(true); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "application/json"); connection.setRequestProperty("User-Agent", "ai4j-mcp-client/1.0.0"); if (sessionId != null) { connection.setRequestProperty("mcp-session-id", sessionId); } if (headers != null) { for (Map.Entry entry : headers.entrySet()) { connection.setRequestProperty(entry.getKey(), entry.getValue()); } } return connection; } private void processEventStream(BufferedReader reader) throws IOException { String eventId = null; String eventType = null; StringBuilder dataBuilder = new StringBuilder(); while (running.get()) { String line = reader.readLine(); if (line == null) { break; } if (line.isEmpty()) { dispatchSseEvent(eventId, eventType, dataBuilder); eventId = null; eventType = null; dataBuilder.setLength(0); continue; } if (line.startsWith(":")) { continue; } int separatorIndex = line.indexOf(':'); String field = separatorIndex >= 0 ? line.substring(0, separatorIndex) : line; String value = separatorIndex >= 0 ? line.substring(separatorIndex + 1) : ""; if (value.startsWith(" ")) { value = value.substring(1); } if ("event".equals(field)) { eventType = value; } else if ("data".equals(field)) { if (dataBuilder.length() > 0) { dataBuilder.append('\n'); } dataBuilder.append(value); } else if ("id".equals(field)) { eventId = value; } } if (eventId != null || eventType != null || dataBuilder.length() > 0) { dispatchSseEvent(eventId, eventType, dataBuilder); } } private void dispatchSseEvent(String eventId, String eventType, StringBuilder dataBuilder) { String data = dataBuilder.toString(); if ((eventType == null || eventType.trim().isEmpty()) && data.trim().isEmpty()) { return; } String resolvedType = (eventType == null || eventType.trim().isEmpty()) ? "message" : eventType.trim(); log.debug("接收SSE事件: id={}, type={}, data={}", eventId, resolvedType, data); try { if ("endpoint".equals(resolvedType)) { handleEndpointEvent(data); } else if ("message".equals(resolvedType)) { handleMessageEvent(data); } else { log.debug("忽略未知事件类型: {}", resolvedType); } } catch (Exception e) { log.error("处理SSE事件失败", e); if (messageHandler != null) { messageHandler.onError(e); } } } private void handleSseClosure(String reason) { if (running.compareAndSet(true, false)) { endpointReceived = false; messageEndpointUrl = null; if (messageHandler != null) { messageHandler.onDisconnected(reason); } } } private void closeSseResources() { final HttpURLConnection connection = sseConnection; final InputStream inputStream = sseInputStream; sseConnection = null; sseInputStream = null; Thread readerThread = sseReaderThread; if (readerThread != null) { readerThread.interrupt(); } if (connection != null || inputStream != null) { Thread closerThread = new Thread(new Runnable() { @Override public void run() { disconnectQuietly(connection); closeQuietly(inputStream); } }, "mcp-sse-closer-" + Integer.toHexString(System.identityHashCode(this))); closerThread.setDaemon(true); closerThread.start(); } } private void waitForReaderThreadToExit() { Thread readerThread = sseReaderThread; if (readerThread == null || readerThread == Thread.currentThread()) { return; } try { readerThread.join(TimeUnit.SECONDS.toMillis(2)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.debug("等待SSE读取线程退出时被中断"); return; } if (readerThread.isAlive()) { log.warn("SSE读取线程未在预期时间内退出"); } } private void closeQuietly(InputStream inputStream) { McpTransportSupport.closeQuietly(inputStream); } private void closeQuietly(BufferedReader reader) { if (reader == null) { return; } try { reader.close(); } catch (IOException e) { log.debug("关闭SSE读取器失败: {}", e.getMessage()); } } private void disconnectQuietly(HttpURLConnection connection) { if (connection != null) { connection.disconnect(); } } /** * 处理endpoint事件 */ private void handleEndpointEvent(String data) { if (data != null && !data.trim().isEmpty()) { String endpointPath = data.trim(); // 将相对路径转换为完整URL messageEndpointUrl = buildFullUrl(endpointPath); endpointReceived = true; log.debug("收到endpoint事件,消息端点: {}", messageEndpointUrl); } else { log.warn("收到空的endpoint事件数据"); } } /** * 将相对路径转换为完整URL */ private String buildFullUrl(String endpointPath) { if (endpointPath == null || endpointPath.trim().isEmpty()) { throw new IllegalArgumentException("端点路径不能为空"); } // 如果已经是完整URL,则合并 initialQueryParams(避免重复 key) if (endpointPath.startsWith("http://") || endpointPath.startsWith("https://")) { return mergeInitialQueriesIntoUrl(endpointPath); } // 否则将相对路径拼接到 base 上 String baseUrl = extractBaseUrl(sseEndpointUrl); String trimmedPath = endpointPath.trim(); if (!trimmedPath.startsWith("/")) { trimmedPath = "/" + trimmedPath; } String combined = baseUrl + trimmedPath; return mergeInitialQueriesIntoUrl(combined); } private String mergeInitialQueriesIntoUrl(String urlStr) { try { HttpUrl url = HttpUrl.get(urlStr); if (url == null) return urlStr; // fallback HttpUrl.Builder builder = url.newBuilder(); // 把初始查询参数加上,前提是 endpoint URL 没有同名参数 for (Map.Entry e : initialQueryParams.entrySet()) { String key = e.getKey(); String val = e.getValue(); if (url.queryParameter(key) == null && val != null) { builder.addQueryParameter(key, val); } } return builder.build().toString(); } catch (Exception ex) { log.debug("合并初始查询参数失败,返回原始 URL: {}, 错误: {}", urlStr, ex.getMessage()); return urlStr; } } /** * 从完整URL中提取基础URL(协议+域名+端口) */ private String extractBaseUrl(String fullUrl) { try { java.net.URL url = new java.net.URL(fullUrl); return url.getProtocol() + "://" + url.getHost() + (url.getPort() != -1 ? ":" + url.getPort() : ""); } catch (Exception e) { // 简单的字符串处理作为备选 int protocolEnd = fullUrl.indexOf("://"); if (protocolEnd > 0) { int pathStart = fullUrl.indexOf("/", protocolEnd + 3); if (pathStart > 0) { return fullUrl.substring(0, pathStart); } } return fullUrl; } } /** * 从消息端点URL中提取会话ID */ private String extractSessionId(String messageUrl) { if (messageUrl == null) { return null; } try { // 从URL参数中提取session_id // 例如: https://mcp.api-inference.modelscope.net/messages/?session_id=abc123 int sessionIdIndex = messageUrl.indexOf("session_id="); if (sessionIdIndex > 0) { int startIndex = sessionIdIndex + "session_id=".length(); int endIndex = messageUrl.indexOf("&", startIndex); if (endIndex == -1) { endIndex = messageUrl.length(); } return messageUrl.substring(startIndex, endIndex); } } catch (Exception e) { log.debug("提取会话ID失败: {}", messageUrl, e); } return null; } /** * 处理message事件 */ private void handleMessageEvent(String data) { if (data == null || data.trim().isEmpty()) { log.warn("收到空的message事件数据"); return; } try { McpMessage message = parseMessage(data); if (message != null && messageHandler != null) { messageHandler.handleMessage(message); } } catch (Exception e) { log.error("解析message事件失败: {}", data, e); if (messageHandler != null) { messageHandler.onError(e); } } } /** * 解析JSON消息为McpMessage对象 */ private McpMessage parseMessage(String jsonString) { try { return McpMessageCodec.parseMessage(jsonString); } catch (Exception e) { log.error("解析消息失败: {}", jsonString, e); return null; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/StdioTransport.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.entity.*; import io.github.lnyocly.ai4j.mcp.util.McpMessageCodec; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.*; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; /** * @Author cly * @Description 进程Stdio传输层 - 启动外部MCP服务器进程 */ public class StdioTransport implements McpTransport { private static final Logger log = LoggerFactory.getLogger(StdioTransport.class); private final String command; private final List args; private final Map env; private Process mcpProcess; private BufferedReader reader; private PrintWriter writer; private final ExecutorService executor; private final AtomicBoolean running; private McpMessageHandler messageHandler; public StdioTransport(String command, List args, Map env) { this.command = command; this.args = args; this.env = env; this.executor = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "MCP-Process-Stdio-Transport"); t.setDaemon(true); return t; }); this.running = new AtomicBoolean(false); } @Override public CompletableFuture start() { return CompletableFuture.runAsync(() -> { if (running.compareAndSet(false, true)) { log.info("启动进程Stdio传输层"); try { // 启动MCP服务器进程 startMcpProcess(); if (messageHandler != null) { messageHandler.onConnected(); } // 启动消息读取循环 executor.submit(this::messageReadLoop); } catch (Exception e) { log.debug("启动MCP进程失败: {}", McpTransportSupport.safeMessage(e), e); running.set(false); if (messageHandler != null) { messageHandler.onError(e); } throw new RuntimeException(McpTransportSupport.safeMessage(e), e); } } }); } /** * 启动MCP服务器进程 */ private void startMcpProcess() throws IOException { log.info("启动MCP服务器进程: {} {}", command, args); ProcessBuilder pb = new ProcessBuilder(); // 构建完整命令 List fullCommand = new ArrayList<>(); // Windows系统需要特殊处理 if (System.getProperty("os.name").toLowerCase().contains("windows")) { if ("npx".equals(command)) { // 在Windows上使用cmd包装npx命令 fullCommand.add("cmd"); fullCommand.add("/c"); fullCommand.add("npx"); if (args != null) { fullCommand.addAll(args); } } else { fullCommand.add(command); if (args != null) { fullCommand.addAll(args); } } } else { fullCommand.add(command); if (args != null) { fullCommand.addAll(args); } } pb.command(fullCommand); log.debug("完整命令: {}", fullCommand); // 设置环境变量 if (env != null && !env.isEmpty()) { Map processEnv = pb.environment(); processEnv.putAll(env); } // 重定向错误流到标准输出 pb.redirectErrorStream(true); // 启动进程 mcpProcess = pb.start(); // 设置输入输出流 reader = new BufferedReader(new InputStreamReader(mcpProcess.getInputStream(), "UTF-8")); writer = new PrintWriter(new OutputStreamWriter(mcpProcess.getOutputStream(), "UTF-8"), true); log.info("MCP服务器进程已启动,PID: {}", getProcessId()); // 检查进程是否立即退出 try { Thread.sleep(100); // 等待100ms if (!mcpProcess.isAlive()) { int exitCode = mcpProcess.exitValue(); throw new IOException("MCP服务器进程立即退出,退出码: " + exitCode); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } /** * 获取进程ID(尽力而为) */ private String getProcessId() { try { if (mcpProcess != null) { // Java 9+ 有 pid() 方法,但我们在 JDK 8 环境 return mcpProcess.toString(); } } catch (Exception e) { // 忽略 } return "unknown"; } @Override public CompletableFuture stop() { return CompletableFuture.runAsync(() -> { if (running.compareAndSet(true, false)) { log.info("停止进程Stdio传输层"); // 关闭流 try { if (writer != null) { writer.close(); } if (reader != null) { reader.close(); } } catch (Exception e) { log.warn("关闭流时发生错误", e); } // 终止进程 if (mcpProcess != null) { try { mcpProcess.destroy(); // 等待进程结束 boolean terminated = mcpProcess.waitFor(5, java.util.concurrent.TimeUnit.SECONDS); if (!terminated) { log.warn("进程未在5秒内结束,强制终止"); mcpProcess.destroyForcibly(); } log.info("MCP服务器进程已终止"); } catch (Exception e) { log.error("终止MCP进程时发生错误", e); } } // 关闭线程池 executor.shutdown(); if (messageHandler != null) { messageHandler.onDisconnected("Transport stopped"); } } }); } @Override public CompletableFuture sendMessage(McpMessage message) { return CompletableFuture.runAsync(() -> { if (!running.get()) { throw new IllegalStateException("传输层未启动"); } if (writer == null) { throw new IllegalStateException("输出流未初始化"); } try { String jsonMessage = JSON.toJSONString(message); log.debug("发送消息: {}", jsonMessage); // 发送JSON消息并确保换行 writer.println(jsonMessage); writer.flush(); // 额外确保数据被发送 if (writer.checkError()) { throw new IOException("写入数据时发生错误"); } } catch (Exception e) { log.debug("发送消息失败: {}", McpTransportSupport.safeMessage(e), e); if (messageHandler != null) { messageHandler.onError(e); } throw new RuntimeException(McpTransportSupport.safeMessage(e), e); } }); } @Override public void setMessageHandler(McpMessageHandler handler) { this.messageHandler = handler; } @Override public boolean isConnected() { return running.get() && mcpProcess != null && mcpProcess.isAlive(); } @Override public boolean needsHeartbeat() { return false; } @Override public String getTransportType() { return "process-stdio"; } /** * 消息读取循环 */ private void messageReadLoop() { log.debug("开始消息读取循环"); while (running.get()) { try { String line = reader.readLine(); if (line == null) { // 输入流结束,检查进程状态 if (mcpProcess != null && mcpProcess.isAlive()) { log.warn("输入流结束但进程仍在运行,可能是通信问题"); } else if (mcpProcess != null) { try { int exitCode = mcpProcess.exitValue(); log.warn("MCP进程已退出,退出码: {}", exitCode); } catch (IllegalThreadStateException e) { log.warn("无法获取进程退出码"); } } log.info("输入流结束,停止传输层"); stop(); break; } if (line.trim().isEmpty()) { continue; } log.debug("接收消息: {}", line); // 检查是否是JSON消息(以{开头) if (!line.trim().startsWith("{")) { log.info("MCP服务器日志: {}", line); continue; } // 解析JSON消息 McpMessage message = parseMessage(line); if (message != null && messageHandler != null) { messageHandler.handleMessage(message); } } catch (IOException e) { if (running.get()) { log.error("读取消息时发生IO错误", e); if (messageHandler != null) { messageHandler.onError(e); } } break; } catch (Exception e) { log.error("处理消息时发生错误", e); if (messageHandler != null) { messageHandler.onError(e); } } } log.debug("消息读取循环结束"); } /** * 解析JSON消息为McpMessage对象 */ private McpMessage parseMessage(String jsonString) { try { return McpMessageCodec.parseMessage(jsonString); } catch (Exception e) { log.debug("解析消息失败: {}", jsonString, e); return null; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/StreamableHttpTransport.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.util.McpMessageCodec; import okhttp3.*; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import okhttp3.sse.EventSources; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; /** * Streamable HTTP传输层实现 * 支持MCP 2025-03-26规范的Streamable HTTP传输 */ public class StreamableHttpTransport implements McpTransport { private static final Logger log = LoggerFactory.getLogger(StreamableHttpTransport.class); private final String mcpEndpointUrl; private final OkHttpClient httpClient; private final AtomicBoolean running = new AtomicBoolean(false); private McpMessageHandler messageHandler; private EventSource eventSource; private String sessionId; private String lastEventId; /** * 自定义HTTP头(用于认证等) */ private Map headers; public StreamableHttpTransport(String mcpEndpointUrl) { this.mcpEndpointUrl = mcpEndpointUrl; this.httpClient = new OkHttpClient.Builder() .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) .build(); } public StreamableHttpTransport(TransportConfig config) { this.mcpEndpointUrl = config.getUrl(); this.httpClient = new OkHttpClient.Builder() .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) .build(); this.headers = config.getHeaders(); } @Override public CompletableFuture start() { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (running.compareAndSet(false, true)) { log.info("启动Streamable HTTP传输层,连接到: {}", mcpEndpointUrl); if (messageHandler != null) { messageHandler.onConnected(); } } } }); } @Override public CompletableFuture stop() { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (running.compareAndSet(true, false)) { log.info("停止Streamable HTTP传输层"); if (eventSource != null) { eventSource.cancel(); eventSource = null; } if (messageHandler != null) { messageHandler.onDisconnected("传输层停止"); } } } }); } @Override public CompletableFuture sendMessage(McpMessage message) { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (!running.get()) { throw new IllegalStateException("Streamable HTTP传输层未启动"); } try { String jsonMessage = JSON.toJSONString(message); log.debug("发送消息到MCP端点: {}", jsonMessage); RequestBody body = RequestBody.create( MediaType.get("application/json"), jsonMessage ); Request.Builder requestBuilder = new Request.Builder() .url(mcpEndpointUrl) .post(body) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream"); // 添加会话ID(如果存在) if (sessionId != null) { requestBuilder.header("mcp-session-id", sessionId); } // 添加Last-Event-ID用于恢复连接 if (lastEventId != null) { requestBuilder.header("last-event-id", lastEventId); } if (headers != null) { for (Map.Entry entry : headers.entrySet()) { requestBuilder.header(entry.getKey(), entry.getValue()); } } Request request = requestBuilder.build(); Response response = httpClient.newCall(request).execute(); try { if (!response.isSuccessful()) { throw new IOException(McpTransportSupport.buildHttpFailureMessage(response)); } // 检查会话ID String newSessionId = response.header("mcp-session-id"); if (newSessionId != null) { StreamableHttpTransport.this.sessionId = newSessionId; log.debug("收到会话ID: {}", StreamableHttpTransport.this.sessionId); } String contentType = response.header("Content-Type", ""); if (contentType.startsWith("text/event-stream")) { // 服务器选择SSE流响应 handleSseResponse(response); } else if (contentType.startsWith("application/json")) { // 服务器选择单一JSON响应 handleJsonResponse(response); } else { log.warn("未知的响应内容类型: {}", contentType); } } finally { response.close(); } } catch (Exception e) { log.debug("发送Streamable HTTP消息失败: {}", McpTransportSupport.safeMessage(e), e); if (messageHandler != null) { messageHandler.onError(e); } throw new RuntimeException(McpTransportSupport.safeMessage(e), e); } } }); } /** * 处理JSON响应 */ private void handleJsonResponse(Response response) throws IOException { if (response.body() == null) return; String responseBody = response.body().string(); log.debug("收到JSON响应: {}", responseBody); // 如果响应体是空或只含空白,直接跳过 // initialized 会返回202 Accepted,一个空体,不需要解析 if (responseBody == null || responseBody.trim().isEmpty()) { log.debug("空的 JSON 响应,忽略"); return; } try { McpMessage message = parseMcpMessage(responseBody); if (messageHandler != null) { messageHandler.handleMessage(message); } } catch (Exception e) { log.debug("解析JSON响应失败: {}", McpTransportSupport.safeMessage(e), e); if (messageHandler != null) { messageHandler.onError(e); } } } /** * 处理SSE流响应 */ private void handleSseResponse(Response response) { log.debug("服务器升级到SSE流"); try { // 直接处理当前响应的SSE流 BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream())); String line; while ((line = reader.readLine()) != null) { if (line.startsWith("data: ")) { String data = line.substring(6); try { McpMessage message = parseMcpMessage(data); if (messageHandler != null) { messageHandler.handleMessage(message); } } catch (Exception e) { log.debug("解析SSE数据失败: {} -> {}", McpTransportSupport.clip(data, 120), McpTransportSupport.safeMessage(e), e); } } else if (line.startsWith("id: ")) { lastEventId = line.substring(4); } } } catch (Exception e) { log.debug("处理SSE响应失败: {}", McpTransportSupport.safeMessage(e), e); if (messageHandler != null) { messageHandler.onError(e); } } } @Override public void setMessageHandler(McpMessageHandler handler) { this.messageHandler = handler; } @Override public boolean isConnected() { return running.get(); } @Override public boolean needsHeartbeat() { return true; } @Override public String getTransportType() { return "streamable_http"; } /** * 获取会话ID */ public String getSessionId() { return sessionId; } /** * 终止会话 */ public CompletableFuture terminateSession() { return CompletableFuture.runAsync(new Runnable() { @Override public void run() { if (sessionId != null) { try { Request.Builder builder = new Request.Builder(); if (headers != null) { for (Map.Entry entry : headers.entrySet()) { builder.header(entry.getKey(), entry.getValue()); } } Request request = builder .url(mcpEndpointUrl) .delete() .header("mcp-session-id", sessionId) .build(); Response response = httpClient.newCall(request).execute(); try { log.info("会话已终止: {}", sessionId); } finally { response.close(); } } catch (Exception e) { log.warn("终止会话失败", e); } finally { sessionId = null; } } } }); } public static McpMessage parseMcpMessage(String jsonString) { return McpMessageCodec.parseMessage(jsonString); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/TransportConfig.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import io.github.lnyocly.ai4j.mcp.config.McpServerConfig; import io.github.lnyocly.ai4j.mcp.util.McpTypeSupport; import java.util.List; import java.util.Map; /** * MCP传输层配置类 * 统一管理不同传输协议的配置参数 * * @Author cly */ public class TransportConfig { // 通用配置 private String type; // HTTP/SSE相关配置 private String url; private Integer connectTimeout; private Integer readTimeout; private Integer writeTimeout; // Stdio相关配置 private String command; private List args; private Map env; /** * 自定义HTTP头(用于认证等) */ private Map headers; // 高级配置 private Boolean enableRetry; private Integer maxRetries; private Long retryDelay; private Boolean enableHeartbeat; private Long heartbeatInterval; public TransportConfig() { // 设置默认值 this.connectTimeout = 30; this.readTimeout = 60; this.writeTimeout = 60; this.enableRetry = true; this.maxRetries = 3; this.retryDelay = 1000L; this.enableHeartbeat = false; this.heartbeatInterval = 30000L; } // 静态工厂方法 /** * 创建stdio传输配置 */ public static TransportConfig stdio(String command, List args) { TransportConfig config = new TransportConfig(); config.type = "stdio"; config.command = command; config.args = args; return config; } /** * 创建stdio传输配置(带环境变量) */ public static TransportConfig stdio(String command, List args, Map env) { TransportConfig config = stdio(command, args); config.env = env; return config; } /** * 创建SSE传输配置 */ public static TransportConfig sse(String url) { TransportConfig config = new TransportConfig(); config.type = "sse"; config.url = url; return config; } /** * 创建SSE传输配置 */ public static TransportConfig sse(McpServerConfig.McpServerInfo serverInfo) { TransportConfig config = new TransportConfig(); config.type = McpTypeSupport.TYPE_SSE; config.url = serverInfo.getUrl(); config.headers = serverInfo.getHeaders(); return config; } /** * 创建Streamable HTTP传输配置 */ public static TransportConfig streamableHttp(String url) { TransportConfig config = new TransportConfig(); config.type = McpTypeSupport.TYPE_STREAMABLE_HTTP; config.url = url; return config; } /** * 创建 Streamable HTTP 传输配置 */ public static TransportConfig streamableHttp(McpServerConfig.McpServerInfo serverInfo) { TransportConfig config = streamableHttp(serverInfo.getUrl()); config.headers = serverInfo.getHeaders(); return config; } /** * 创建HTTP传输配置(向后兼容) */ public static TransportConfig http(String url) { return streamableHttp(url); } /** * 根据 MCP server 配置创建传输配置 */ public static TransportConfig fromServerInfo(McpServerConfig.McpServerInfo serverInfo) { String normalizedType = McpTypeSupport.resolveType(serverInfo); if (McpTypeSupport.isStdio(normalizedType)) { return stdio(serverInfo.getCommand(), serverInfo.getArgs(), serverInfo.getEnv()); } if (McpTypeSupport.isSse(normalizedType)) { return sse(serverInfo); } return streamableHttp(serverInfo); } // Builder模式支持 public TransportConfig withTimeout(int connectTimeout, int readTimeout, int writeTimeout) { this.connectTimeout = connectTimeout; this.readTimeout = readTimeout; this.writeTimeout = writeTimeout; return this; } public TransportConfig withRetry(boolean enableRetry, int maxRetries, long retryDelay) { this.enableRetry = enableRetry; this.maxRetries = maxRetries; this.retryDelay = retryDelay; return this; } public TransportConfig withHeartbeat(boolean enableHeartbeat, long heartbeatInterval) { this.enableHeartbeat = enableHeartbeat; this.heartbeatInterval = heartbeatInterval; return this; } // Getter和Setter方法 public String getType() { return type; } public void setType(String type) { this.type = type; } public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public Integer getConnectTimeout() { return connectTimeout; } public void setConnectTimeout(Integer connectTimeout) { this.connectTimeout = connectTimeout; } public Integer getReadTimeout() { return readTimeout; } public void setReadTimeout(Integer readTimeout) { this.readTimeout = readTimeout; } public Integer getWriteTimeout() { return writeTimeout; } public void setWriteTimeout(Integer writeTimeout) { this.writeTimeout = writeTimeout; } public String getCommand() { return command; } public void setCommand(String command) { this.command = command; } public List getArgs() { return args; } public void setArgs(List args) { this.args = args; } public Map getEnv() { return env; } public void setEnv(Map env) { this.env = env; } public Boolean getEnableRetry() { return enableRetry; } public void setEnableRetry(Boolean enableRetry) { this.enableRetry = enableRetry; } public Integer getMaxRetries() { return maxRetries; } public void setMaxRetries(Integer maxRetries) { this.maxRetries = maxRetries; } public Long getRetryDelay() { return retryDelay; } public void setRetryDelay(Long retryDelay) { this.retryDelay = retryDelay; } public Boolean getEnableHeartbeat() { return enableHeartbeat; } public void setEnableHeartbeat(Boolean enableHeartbeat) { this.enableHeartbeat = enableHeartbeat; } public Long getHeartbeatInterval() { return heartbeatInterval; } public void setHeartbeatInterval(Long heartbeatInterval) { this.heartbeatInterval = heartbeatInterval; } public Map getHeaders() { return headers; } public void setHeaders(Map headers) { this.headers = headers; } @Override public String toString() { return "TransportConfig{" + "type='" + type + '\'' + ", url='" + url + '\'' + ", command='" + command + '\'' + ", args=" + args + ", connectTimeout=" + connectTimeout + ", readTimeout=" + readTimeout + ", writeTimeout=" + writeTimeout + ", enableRetry=" + enableRetry + ", maxRetries=" + maxRetries + ", retryDelay=" + retryDelay + ", enableHeartbeat=" + enableHeartbeat + ", heartbeatInterval=" + heartbeatInterval + '}'; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpMessageCodec.java ================================================ package io.github.lnyocly.ai4j.mcp.util; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.entity.McpNotification; import io.github.lnyocly.ai4j.mcp.entity.McpRequest; import io.github.lnyocly.ai4j.mcp.entity.McpResponse; import java.util.Map; /** * MCP 消息编解码辅助类 */ public final class McpMessageCodec { private McpMessageCodec() { } public static McpMessage parseMessage(String jsonMessage) { if (jsonMessage == null || jsonMessage.trim().isEmpty()) { throw new IllegalArgumentException("MCP message must not be empty"); } @SuppressWarnings("unchecked") Map messageMap = JSON.parseObject(jsonMessage, Map.class); if (messageMap == null) { throw new IllegalArgumentException("Unable to parse MCP message"); } String method = stringValue(messageMap.get("method")); Object id = messageMap.get("id"); Object result = messageMap.get("result"); Object error = messageMap.get("error"); if (method != null && id != null && result == null && error == null) { return JSON.parseObject(jsonMessage, McpRequest.class); } if (method != null && id == null) { return JSON.parseObject(jsonMessage, McpNotification.class); } if (method == null && id != null && (result != null || error != null)) { return JSON.parseObject(jsonMessage, McpResponse.class); } if (method != null) { return JSON.parseObject(jsonMessage, McpRequest.class); } throw new IllegalArgumentException("Unrecognized MCP message: " + jsonMessage); } private static String stringValue(Object value) { return value != null ? String.valueOf(value) : null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpPromptAdapter.java ================================================ package io.github.lnyocly.ai4j.mcp.util; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.annotation.McpPrompt; import io.github.lnyocly.ai4j.mcp.annotation.McpPromptParameter; import io.github.lnyocly.ai4j.mcp.annotation.McpService; import io.github.lnyocly.ai4j.mcp.entity.McpPromptResult; import lombok.extern.slf4j.Slf4j; import org.reflections.Reflections; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.*; import java.util.concurrent.ConcurrentHashMap; /** * @Author cly * @Description MCP提示词适配器,用于管理和调用MCP提示词 */ @Slf4j public class McpPromptAdapter { private static final Reflections reflections = new Reflections("io.github.lnyocly"); // 提示词缓存:提示词名称 -> 提示词定义 private static final Map mcpPromptCache = new ConcurrentHashMap<>(); // 提示词类映射:提示词名称 -> 服务类 private static final Map> promptClassMap = new ConcurrentHashMap<>(); // 提示词方法映射:提示词名称 -> 方法 private static final Map promptMethodMap = new ConcurrentHashMap<>(); /** * 扫描并注册所有MCP服务中的提示词 */ public static void scanAndRegisterMcpPrompts() { // 扫描@McpService注解的类 Set> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class); for (Class serviceClass : mcpServiceClasses) { registerMcpServicePrompts(serviceClass); } log.info("MCP提示词扫描完成,共注册 {} 个提示词", mcpPromptCache.size()); } /** * 注册指定服务类中的提示词 */ private static void registerMcpServicePrompts(Class serviceClass) { McpService mcpService = serviceClass.getAnnotation(McpService.class); String serviceName = mcpService.name(); // 扫描类中标记了@McpPrompt的方法 Method[] methods = serviceClass.getDeclaredMethods(); for (Method method : methods) { McpPrompt mcpPrompt = method.getAnnotation(McpPrompt.class); if (mcpPrompt != null) { String promptName = mcpPrompt.name().isEmpty() ? method.getName() : mcpPrompt.name(); String fullPromptName = serviceName + "." + promptName; io.github.lnyocly.ai4j.mcp.entity.McpPrompt promptDefinition = createPromptDefinitionFromMethod(method, mcpPrompt, serviceClass, fullPromptName); mcpPromptCache.put(fullPromptName, promptDefinition); promptClassMap.put(fullPromptName, serviceClass); promptMethodMap.put(fullPromptName, method); log.debug("注册MCP提示词: {} -> {}", fullPromptName, method.getName()); } } } /** * 从方法创建提示词定义 */ private static io.github.lnyocly.ai4j.mcp.entity.McpPrompt createPromptDefinitionFromMethod( Method method, McpPrompt mcpPrompt, Class serviceClass, String fullPromptName) { // 构建参数Schema Map arguments = new HashMap<>(); Parameter[] parameters = method.getParameters(); for (Parameter parameter : parameters) { McpPromptParameter promptParam = parameter.getAnnotation(McpPromptParameter.class); if (promptParam != null) { String paramName = promptParam.name().isEmpty() ? parameter.getName() : promptParam.name(); Map paramSchema = new HashMap<>(); paramSchema.put("name", paramName); paramSchema.put("description", promptParam.description()); paramSchema.put("required", promptParam.required()); paramSchema.put("type", getJsonSchemaType(parameter.getType())); if (!promptParam.defaultValue().isEmpty()) { paramSchema.put("default", promptParam.defaultValue()); } arguments.put(paramName, paramSchema); } } return io.github.lnyocly.ai4j.mcp.entity.McpPrompt.builder() .name(fullPromptName) .description(mcpPrompt.description()) .arguments(arguments) .build(); } /** * 获取所有MCP提示词列表 */ public static List getAllMcpPrompts() { return new ArrayList<>(mcpPromptCache.values()); } /** * 获取指定提示词 */ public static McpPromptResult getMcpPrompt(String promptName, Map arguments) { try { // 检查提示词是否存在 if (!mcpPromptCache.containsKey(promptName)) { throw new IllegalArgumentException("提示词不存在: " + promptName); } // 获取提示词方法和类 Method method = promptMethodMap.get(promptName); Class promptClass = promptClassMap.get(promptName); if (method == null || promptClass == null) { throw new IllegalArgumentException("提示词方法未找到: " + promptName); } // 调用提示词方法 Object result = invokeMcpPromptMethod(promptClass, method, arguments); // 构建提示词结果 io.github.lnyocly.ai4j.mcp.entity.McpPrompt promptDef = mcpPromptCache.get(promptName); return McpPromptResult.builder() .name(promptName) .content(result != null ? result.toString() : "") .description(promptDef.getDescription()) .build(); } catch (Exception e) { log.error("获取MCP提示词失败: {}", promptName, e); throw new RuntimeException("获取提示词失败: " + e.getMessage(), e); } } /** * 调用MCP提示词方法 */ private static Object invokeMcpPromptMethod(Class promptClass, Method method, Map arguments) throws Exception { // 创建服务实例 Object serviceInstance = promptClass.getDeclaredConstructor().newInstance(); // 准备方法参数 Object[] methodArgs = preparePromptMethodArguments(method, arguments); // 调用方法 method.setAccessible(true); return method.invoke(serviceInstance, methodArgs); } /** * 准备提示词方法参数 */ private static Object[] preparePromptMethodArguments(Method method, Map arguments) { Class[] paramTypes = method.getParameterTypes(); Parameter[] parameters = method.getParameters(); Object[] result = new Object[paramTypes.length]; for (int i = 0; i < parameters.length; i++) { McpPromptParameter promptParam = parameters[i].getAnnotation(McpPromptParameter.class); String paramName = promptParam != null && !promptParam.name().isEmpty() ? promptParam.name() : parameters[i].getName(); Object value = arguments != null ? arguments.get(paramName) : null; // 如果没有提供值,使用默认值 if (value == null && promptParam != null && !promptParam.defaultValue().isEmpty()) { value = promptParam.defaultValue(); } result[i] = convertValue(value, paramTypes[i]); } return result; } /** * 获取Java类型对应的JSON Schema类型 */ private static String getJsonSchemaType(Class javaType) { if (javaType == String.class) { return "string"; } else if (javaType == Integer.class || javaType == int.class || javaType == Long.class || javaType == long.class) { return "integer"; } else if (javaType == Double.class || javaType == double.class || javaType == Float.class || javaType == float.class) { return "number"; } else if (javaType == Boolean.class || javaType == boolean.class) { return "boolean"; } else if (javaType.isArray() || java.util.List.class.isAssignableFrom(javaType)) { return "array"; } else { return "object"; } } /** * 转换参数值类型 */ private static Object convertValue(Object value, Class targetType) { if (value == null) { return null; } String stringValue = value.toString(); if (targetType == String.class) { return stringValue; } else if (targetType == Integer.class || targetType == int.class) { return Integer.parseInt(stringValue); } else if (targetType == Long.class || targetType == long.class) { return Long.parseLong(stringValue); } else if (targetType == Boolean.class || targetType == boolean.class) { return Boolean.parseBoolean(stringValue); } else if (targetType == Double.class || targetType == double.class) { return Double.parseDouble(stringValue); } else { // 尝试JSON反序列化 return JSON.parseObject(stringValue, targetType); } } /** * 检查提示词是否存在 */ public static boolean promptExists(String promptName) { return mcpPromptCache.containsKey(promptName); } /** * 获取提示词定义 */ public static io.github.lnyocly.ai4j.mcp.entity.McpPrompt getPromptDefinition(String promptName) { return mcpPromptCache.get(promptName); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpResourceAdapter.java ================================================ package io.github.lnyocly.ai4j.mcp.util; import io.github.lnyocly.ai4j.mcp.annotation.McpResource; import io.github.lnyocly.ai4j.mcp.annotation.McpResourceParameter; import io.github.lnyocly.ai4j.mcp.annotation.McpService; import io.github.lnyocly.ai4j.mcp.entity.McpResourceContent; import lombok.extern.slf4j.Slf4j; import org.reflections.Reflections; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * @Author cly * @Description MCP资源适配器,用于管理和调用MCP资源 */ @Slf4j public class McpResourceAdapter { private static final Reflections reflections = new Reflections("io.github.lnyocly"); // 资源缓存:URI模板 -> 资源定义 private static final Map mcpResourceCache = new ConcurrentHashMap<>(); // 资源类映射:URI模板 -> 服务类 private static final Map> resourceClassMap = new ConcurrentHashMap<>(); // 资源方法映射:URI模板 -> 方法 private static final Map resourceMethodMap = new ConcurrentHashMap<>(); // URI模板参数提取正则 private static final Pattern URI_PARAM_PATTERN = Pattern.compile("\\{([^}]+)\\}"); /** * 扫描并注册所有MCP服务中的资源 */ public static void scanAndRegisterMcpResources() { // 扫描@McpService注解的类 Set> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class); for (Class serviceClass : mcpServiceClasses) { registerMcpServiceResources(serviceClass); } log.info("MCP资源扫描完成,共注册 {} 个资源", mcpResourceCache.size()); } /** * 注册指定服务类中的资源 */ private static void registerMcpServiceResources(Class serviceClass) { McpService mcpService = serviceClass.getAnnotation(McpService.class); String serviceName = mcpService.name(); // 扫描类中标记了@McpResource的方法 Method[] methods = serviceClass.getDeclaredMethods(); for (Method method : methods) { McpResource mcpResource = method.getAnnotation(McpResource.class); if (mcpResource != null) { String uriTemplate = mcpResource.uri(); io.github.lnyocly.ai4j.mcp.entity.McpResource resourceDefinition = createResourceDefinitionFromMethod(method, mcpResource, serviceClass, uriTemplate); mcpResourceCache.put(uriTemplate, resourceDefinition); resourceClassMap.put(uriTemplate, serviceClass); resourceMethodMap.put(uriTemplate, method); log.debug("注册MCP资源: {} -> {}", uriTemplate, method.getName()); } } } /** * 从方法创建资源定义 */ private static io.github.lnyocly.ai4j.mcp.entity.McpResource createResourceDefinitionFromMethod( Method method, McpResource mcpResource, Class serviceClass, String uriTemplate) { return io.github.lnyocly.ai4j.mcp.entity.McpResource.builder() .uri(uriTemplate) .name(mcpResource.name()) .description(mcpResource.description()) .mimeType(mcpResource.mimeType().isEmpty() ? null : mcpResource.mimeType()) .size(mcpResource.size() == -1L ? null : mcpResource.size()) .build(); } /** * 获取所有MCP资源列表 */ public static List getAllMcpResources() { return new ArrayList<>(mcpResourceCache.values()); } /** * 读取指定URI的资源内容 */ public static McpResourceContent readMcpResource(String uri) { try { // 查找匹配的URI模板 String matchedTemplate = findMatchingTemplate(uri); if (matchedTemplate == null) { throw new IllegalArgumentException("资源不存在: " + uri); } // 获取资源方法和类 Method method = resourceMethodMap.get(matchedTemplate); Class resourceClass = resourceClassMap.get(matchedTemplate); if (method == null || resourceClass == null) { throw new IllegalArgumentException("资源方法未找到: " + uri); } // 提取URI参数 Map uriParams = extractUriParameters(matchedTemplate, uri); // 调用资源方法 Object result = invokeMcpResourceMethod(resourceClass, method, uriParams); // 构建资源内容 io.github.lnyocly.ai4j.mcp.entity.McpResource resourceDef = mcpResourceCache.get(matchedTemplate); return McpResourceContent.builder() .uri(uri) .contents(result) .mimeType(resourceDef.getMimeType()) .build(); } catch (Exception e) { log.error("读取MCP资源失败: {}", uri, e); throw new RuntimeException("读取资源失败: " + e.getMessage(), e); } } /** * 查找匹配URI的模板 */ private static String findMatchingTemplate(String uri) { for (String template : mcpResourceCache.keySet()) { if (uriMatchesTemplate(uri, template)) { return template; } } return null; } /** * 检查URI是否匹配模板 */ private static boolean uriMatchesTemplate(String uri, String template) { // 将模板转换为正则表达式 String regex = template.replaceAll("\\{[^}]+\\}", "[^/]+"); return uri.matches(regex); } /** * 从URI中提取参数 */ private static Map extractUriParameters(String template, String uri) { Map params = new HashMap<>(); // 提取模板中的参数名 List paramNames = new ArrayList<>(); Matcher matcher = URI_PARAM_PATTERN.matcher(template); while (matcher.find()) { paramNames.add(matcher.group(1)); } // 构建正则表达式来提取参数值 String regex = template; for (String paramName : paramNames) { regex = regex.replace("{" + paramName + "}", "([^/]+)"); } // 提取参数值 Pattern pattern = Pattern.compile(regex); Matcher uriMatcher = pattern.matcher(uri); if (uriMatcher.matches()) { for (int i = 0; i < paramNames.size(); i++) { params.put(paramNames.get(i), uriMatcher.group(i + 1)); } } return params; } /** * 调用MCP资源方法 */ private static Object invokeMcpResourceMethod(Class resourceClass, Method method, Map uriParams) throws Exception { // 创建服务实例 Object serviceInstance = resourceClass.getDeclaredConstructor().newInstance(); // 准备方法参数 Object[] methodArgs = prepareResourceMethodArguments(method, uriParams); // 调用方法 method.setAccessible(true); return method.invoke(serviceInstance, methodArgs); } /** * 准备资源方法参数 */ private static Object[] prepareResourceMethodArguments(Method method, Map uriParams) { Class[] paramTypes = method.getParameterTypes(); Parameter[] parameters = method.getParameters(); Object[] result = new Object[paramTypes.length]; for (int i = 0; i < parameters.length; i++) { McpResourceParameter resourceParam = parameters[i].getAnnotation(McpResourceParameter.class); String paramName = resourceParam != null && !resourceParam.name().isEmpty() ? resourceParam.name() : parameters[i].getName(); String value = uriParams.get(paramName); result[i] = convertValue(value, paramTypes[i]); } return result; } /** * 转换参数值类型 */ private static Object convertValue(Object value, Class targetType) { if (value == null) { return null; } String stringValue = value.toString(); if (targetType == String.class) { return stringValue; } else if (targetType == Integer.class || targetType == int.class) { return Integer.parseInt(stringValue); } else if (targetType == Long.class || targetType == long.class) { return Long.parseLong(stringValue); } else if (targetType == Boolean.class || targetType == boolean.class) { return Boolean.parseBoolean(stringValue); } else if (targetType == Double.class || targetType == double.class) { return Double.parseDouble(stringValue); } else { // 尝试JSON反序列化 return com.alibaba.fastjson2.JSON.parseObject(stringValue, targetType); } } /** * 检查资源是否存在 */ public static boolean resourceExists(String uri) { return findMatchingTemplate(uri) != null; } /** * 获取资源定义 */ public static io.github.lnyocly.ai4j.mcp.entity.McpResource getResourceDefinition(String uri) { String template = findMatchingTemplate(uri); return template != null ? mcpResourceCache.get(template) : null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpToolAdapter.java ================================================ package io.github.lnyocly.ai4j.mcp.util; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.mcp.annotation.McpParameter; import io.github.lnyocly.ai4j.mcp.annotation.McpService; import io.github.lnyocly.ai4j.mcp.annotation.McpTool; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.reflections.Reflections; import org.reflections.scanners.Scanners; import org.reflections.util.ClasspathHelper; import org.reflections.util.ConfigurationBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.*; import java.util.concurrent.ConcurrentHashMap; /** * @Author cly * @Description Local MCP tool adapter for scanning, registering and invoking local MCP tools */ public class McpToolAdapter { private static final Logger log = LoggerFactory.getLogger(McpToolAdapter.class); // Reflection tool for scanning annotations private static final Reflections reflections = new Reflections(new ConfigurationBuilder() .setUrls(ClasspathHelper.forPackage("")) .setScanners(Scanners.TypesAnnotated, Scanners.MethodsAnnotated)); // Local MCP tool cache private static final Map localMcpToolCache = new ConcurrentHashMap<>(); private static final Map localMcpMethodCache = new ConcurrentHashMap<>(); private static final Map> localMcpClassCache = new ConcurrentHashMap<>(); // Initialization flag private static volatile boolean initialized = false; private static final Object initLock = new Object(); /** * Scan and register all local MCP tools */ public static void scanAndRegisterMcpTools() { if (initialized) { return; } synchronized (initLock) { if (initialized) { return; } log.info("Starting to scan local MCP tools..."); long startTime = System.currentTimeMillis(); try { // Get all classes marked with @McpService Set> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class); int toolCount = 0; for (Class serviceClass : mcpServiceClasses) { McpService mcpService = serviceClass.getAnnotation(McpService.class); log.debug("Found MCP service: {} - {}", mcpService.name(), serviceClass.getName()); // Scan tool methods in service class Method[] methods = serviceClass.getDeclaredMethods(); for (Method method : methods) { McpTool mcpTool = method.getAnnotation(McpTool.class); if (mcpTool != null) { String toolName = mcpTool.name().isEmpty() ? method.getName() : mcpTool.name(); // Create Tool object Tool tool = createToolFromMethod(method, mcpTool, serviceClass); if (tool != null) { // Cache tool information localMcpToolCache.put(toolName, tool); localMcpMethodCache.put(toolName, method); localMcpClassCache.put(toolName, serviceClass); toolCount++; log.debug("Registered local MCP tool: {} -> {}.{}", toolName, serviceClass.getSimpleName(), method.getName()); } } } } initialized = true; log.info("Local MCP tool scanning completed, registered {} tools, took {} ms", toolCount, System.currentTimeMillis() - startTime); } catch (Exception e) { log.error("Failed to scan local MCP tools", e); throw new RuntimeException("Failed to scan local MCP tools", e); } } } /** * Get all local MCP tools */ public static List getAllMcpTools() { ensureInitialized(); return new ArrayList<>(localMcpToolCache.values()); } /** * Invoke local MCP tool */ public static String invokeMcpTool(String toolName, String arguments) { ensureInitialized(); long startTime = System.currentTimeMillis(); log.info("Invoking local MCP tool: {}, arguments: {}", toolName, arguments); try { Method method = localMcpMethodCache.get(toolName); Class serviceClass = localMcpClassCache.get(toolName); if (method == null || serviceClass == null) { throw new IllegalArgumentException("Local MCP tool not found: " + toolName); } // Create service instance Object serviceInstance = serviceClass.getDeclaredConstructor().newInstance(); // Parse arguments Object[] methodArgs = parseMethodArguments(method, arguments); // Invoke method Object result = method.invoke(serviceInstance, methodArgs); // Convert result to JSON string String response = JSON.toJSONString(result); log.info("Local MCP tool invocation successful: {}, response: {}, took: {} ms", toolName, response, System.currentTimeMillis() - startTime); return response; } catch (Exception e) { log.error("Local MCP tool invocation failed: {} - {}", toolName, e.getMessage(), e); throw new RuntimeException("Local MCP tool invocation failed: " + e.getMessage(), e); } } /** * Check if local MCP tool exists */ public static boolean mcpToolExists(String toolName) { ensureInitialized(); return localMcpToolCache.containsKey(toolName); } /** * Get detailed information of local MCP tool */ public static Tool getMcpTool(String toolName) { ensureInitialized(); return localMcpToolCache.get(toolName); } /** * Get all local MCP tool names list */ public static Set getAllMcpToolNames() { ensureInitialized(); return new HashSet<>(localMcpToolCache.keySet()); } /** * Clear cache and rescan */ public static void refresh() { synchronized (initLock) { localMcpToolCache.clear(); localMcpMethodCache.clear(); localMcpClassCache.clear(); initialized = false; } scanAndRegisterMcpTools(); } /** * Ensure initialized */ private static void ensureInitialized() { if (!initialized) { scanAndRegisterMcpTools(); } } /** * Create Tool object from method */ private static Tool createToolFromMethod(Method method, McpTool mcpTool, Class serviceClass) { try { String toolName = mcpTool.name().isEmpty() ? method.getName() : mcpTool.name(); String description = mcpTool.description(); // Create Function object Tool.Function function = new Tool.Function(); function.setName(toolName); function.setDescription(description); // Create parameter definition Tool.Function.Parameter parameters = createParametersFromMethod(method); function.setParameters(parameters); // Create Tool object Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); return tool; } catch (Exception e) { log.error("Failed to create tool definition: {}.{} - {}", serviceClass.getSimpleName(), method.getName(), e.getMessage()); return null; } } /** * Create parameter definition from method */ private static Tool.Function.Parameter createParametersFromMethod(Method method) { Map properties = new HashMap<>(); List requiredParameters = new ArrayList<>(); Parameter[] parameters = method.getParameters(); for (Parameter parameter : parameters) { McpParameter mcpParam = parameter.getAnnotation(McpParameter.class); String paramName = (mcpParam != null && !mcpParam.name().isEmpty()) ? mcpParam.name() : parameter.getName(); String paramDescription = (mcpParam != null) ? mcpParam.description() : ""; boolean required = (mcpParam == null) || mcpParam.required(); // Create property object Tool.Function.Property property = createPropertyFromParameter(parameter.getType(), paramDescription); properties.put(paramName, property); if (required) { requiredParameters.add(paramName); } } return new Tool.Function.Parameter("object", properties, requiredParameters); } /** * Create property object from parameter type */ private static Tool.Function.Property createPropertyFromParameter(Class paramType, String description) { Tool.Function.Property property = new Tool.Function.Property(); property.setDescription(description); if (paramType.isEnum()) { property.setType("string"); property.setEnumValues(getEnumValues(paramType)); } else if (paramType.equals(String.class)) { property.setType("string"); } else if (paramType.equals(int.class) || paramType.equals(Integer.class) || paramType.equals(long.class) || paramType.equals(Long.class) || paramType.equals(short.class) || paramType.equals(Short.class)) { property.setType("integer"); } else if (paramType.equals(float.class) || paramType.equals(Float.class) || paramType.equals(double.class) || paramType.equals(Double.class)) { property.setType("number"); } else if (paramType.equals(boolean.class) || paramType.equals(Boolean.class)) { property.setType("boolean"); } else if (paramType.isArray() || Collection.class.isAssignableFrom(paramType)) { property.setType("array"); // Add items definition for array type Tool.Function.Property items = new Tool.Function.Property(); items.setType("object"); // Default to object type property.setItems(items); } else if (Map.class.isAssignableFrom(paramType)) { property.setType("object"); } else { property.setType("object"); } return property; } /** * Get all possible values of enum type */ private static List getEnumValues(Class enumType) { List enumValues = new ArrayList<>(); for (Object enumConstant : enumType.getEnumConstants()) { enumValues.add(enumConstant.toString()); } return enumValues; } /** * Parse method arguments */ private static Object[] parseMethodArguments(Method method, String arguments) { Parameter[] parameters = method.getParameters(); if (parameters.length == 0) { return new Object[0]; } try { // Parse JSON arguments Map argMap = JSON.parseObject(arguments); Object[] methodArgs = new Object[parameters.length]; for (int i = 0; i < parameters.length; i++) { Parameter parameter = parameters[i]; McpParameter mcpParam = parameter.getAnnotation(McpParameter.class); String paramName = (mcpParam != null && !mcpParam.name().isEmpty()) ? mcpParam.name() : parameter.getName(); Object value = argMap.get(paramName); // If parameter is null and has default value, use default value if (value == null && mcpParam != null && !mcpParam.defaultValue().isEmpty()) { value = mcpParam.defaultValue(); } // Type conversion methodArgs[i] = convertValue(value, parameter.getType()); } return methodArgs; } catch (Exception e) { log.error("Failed to parse method arguments: {} - {}", method.getName(), e.getMessage()); throw new RuntimeException("Failed to parse method arguments: " + e.getMessage(), e); } } /** * Value type conversion */ private static Object convertValue(Object value, Class targetType) { if (value == null) { return null; } if (targetType.isAssignableFrom(value.getClass())) { return value; } try { if (targetType == String.class) { return value.toString(); } else if (targetType == int.class || targetType == Integer.class) { return Integer.valueOf(value.toString()); } else if (targetType == long.class || targetType == Long.class) { return Long.valueOf(value.toString()); } else if (targetType == double.class || targetType == Double.class) { return Double.valueOf(value.toString()); } else if (targetType == float.class || targetType == Float.class) { return Float.valueOf(value.toString()); } else if (targetType == boolean.class || targetType == Boolean.class) { return Boolean.valueOf(value.toString()); } else { // For complex objects, try JSON conversion return JSON.parseObject(JSON.toJSONString(value), targetType); } } catch (Exception e) { log.warn("Type conversion failed: {} -> {}, using original value", value.getClass(), targetType); return value; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpToolConversionSupport.java ================================================ package io.github.lnyocly.ai4j.mcp.util; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.HashMap; import java.util.List; import java.util.Map; /** * MCP Tool 与 OpenAI Tool.Function 转换辅助类 */ public final class McpToolConversionSupport { private McpToolConversionSupport() { } public static Tool.Function convertToOpenAiTool(McpToolDefinition mcpTool) { Tool.Function function = new Tool.Function(); function.setName(mcpTool.getName()); function.setDescription(mcpTool.getDescription()); if (mcpTool.getInputSchema() != null) { function.setParameters(convertInputSchema(mcpTool.getInputSchema())); } return function; } public static Tool.Function.Parameter convertInputSchema(Map inputSchema) { Tool.Function.Parameter parameter = new Tool.Function.Parameter(); parameter.setType("object"); Object properties = inputSchema.get("properties"); Object required = inputSchema.get("required"); if (properties instanceof Map) { Map convertedProperties = new HashMap(); @SuppressWarnings("unchecked") Map propsMap = (Map) properties; propsMap.forEach((key, value) -> { if (value instanceof Map) { @SuppressWarnings("unchecked") Map propMap = (Map) value; convertedProperties.put(key, convertToProperty(propMap)); } }); parameter.setProperties(convertedProperties); } if (required instanceof List) { @SuppressWarnings("unchecked") List requiredList = (List) required; parameter.setRequired(requiredList); } return parameter; } public static Tool.Function.Property convertToProperty(Map propMap) { Tool.Function.Property property = new Tool.Function.Property(); property.setType((String) propMap.get("type")); property.setDescription((String) propMap.get("description")); Object enumObj = propMap.get("enum"); if (enumObj instanceof List) { @SuppressWarnings("unchecked") List enumValues = (List) enumObj; property.setEnumValues(enumValues); } Object itemsObj = propMap.get("items"); if (itemsObj instanceof Map) { @SuppressWarnings("unchecked") Map itemsMap = (Map) itemsObj; property.setItems(convertToProperty(itemsMap)); } return property; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpTypeSupport.java ================================================ package io.github.lnyocly.ai4j.mcp.util; import io.github.lnyocly.ai4j.mcp.config.McpServerConfig; import io.github.lnyocly.ai4j.mcp.entity.McpServerReference; /** * MCP 传输/服务端类型辅助类 */ public final class McpTypeSupport { public static final String TYPE_STDIO = "stdio"; public static final String TYPE_SSE = "sse"; public static final String TYPE_STREAMABLE_HTTP = "streamable_http"; private McpTypeSupport() { } public static String resolveType(String type, String legacyTransport) { return normalizeType(type != null ? type : legacyTransport); } public static String resolveType(McpServerConfig.McpServerInfo serverInfo) { if (serverInfo == null) { return TYPE_STDIO; } return resolveType(serverInfo.getType(), serverInfo.getTransport()); } public static String resolveType(McpServerReference serverReference) { if (serverReference == null) { return TYPE_STDIO; } return resolveType(serverReference.getType(), serverReference.getTransport()); } public static String normalizeType(String value) { if (value == null || value.trim().isEmpty()) { return TYPE_STDIO; } String normalizedValue = value.trim().toLowerCase(); if (TYPE_STDIO.equals(normalizedValue) || "process".equals(normalizedValue) || "local".equals(normalizedValue)) { return TYPE_STDIO; } if (TYPE_SSE.equals(normalizedValue) || "server-sent-events".equals(normalizedValue) || "event-stream".equals(normalizedValue)) { return TYPE_SSE; } if (TYPE_STREAMABLE_HTTP.equals(normalizedValue) || "http".equals(normalizedValue) || "mcp".equals(normalizedValue) || "streamable-http".equals(normalizedValue) || "http-streamable".equals(normalizedValue)) { return TYPE_STREAMABLE_HTTP; } return TYPE_STDIO; } public static boolean isKnownType(String value) { if (value == null || value.trim().isEmpty()) { return true; } String normalizedValue = value.trim().toLowerCase(); return TYPE_STDIO.equals(normalizedValue) || "process".equals(normalizedValue) || "local".equals(normalizedValue) || TYPE_SSE.equals(normalizedValue) || "server-sent-events".equals(normalizedValue) || "event-stream".equals(normalizedValue) || TYPE_STREAMABLE_HTTP.equals(normalizedValue) || "http".equals(normalizedValue) || "mcp".equals(normalizedValue) || "streamable-http".equals(normalizedValue) || "http-streamable".equals(normalizedValue); } public static boolean isStdio(String value) { return TYPE_STDIO.equals(normalizeType(value)); } public static boolean isSse(String value) { return TYPE_SSE.equals(normalizeType(value)); } public static boolean isStreamableHttp(String value) { return TYPE_STREAMABLE_HTTP.equals(normalizeType(value)); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemory.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import java.util.List; public interface ChatMemory { void addSystem(String text); void addUser(String text); void addUser(String text, String... imageUrls); void addAssistant(String text); void addAssistant(String text, List toolCalls); void addAssistantToolCalls(List toolCalls); void addToolOutput(String toolCallId, String output); void add(ChatMemoryItem item); void addAll(List items); List getItems(); List toChatMessages(); List toResponsesInput(); ChatMemorySnapshot snapshot(); void restore(ChatMemorySnapshot snapshot); void clear(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemoryItem.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Content; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class ChatMemoryItem { private String role; private String text; private List imageUrls; private String toolCallId; private List toolCalls; private boolean summary; public static ChatMemoryItem system(String text) { return ChatMemoryItem.builder() .role(ChatMessageType.SYSTEM.getRole()) .text(text) .build(); } public static ChatMemoryItem user(String text) { return ChatMemoryItem.builder() .role(ChatMessageType.USER.getRole()) .text(text) .build(); } public static ChatMemoryItem user(String text, String... imageUrls) { List urls = new ArrayList(); if (imageUrls != null) { for (String imageUrl : imageUrls) { if (hasText(imageUrl)) { urls.add(imageUrl); } } } return ChatMemoryItem.builder() .role(ChatMessageType.USER.getRole()) .text(text) .imageUrls(urls) .build(); } public static ChatMemoryItem assistant(String text) { return ChatMemoryItem.builder() .role(ChatMessageType.ASSISTANT.getRole()) .text(text) .build(); } public static ChatMemoryItem assistant(String text, List toolCalls) { return ChatMemoryItem.builder() .role(ChatMessageType.ASSISTANT.getRole()) .text(text) .toolCalls(copyToolCalls(toolCalls)) .build(); } public static ChatMemoryItem assistantToolCalls(List toolCalls) { return ChatMemoryItem.builder() .role(ChatMessageType.ASSISTANT.getRole()) .toolCalls(copyToolCalls(toolCalls)) .build(); } public static ChatMemoryItem tool(String toolCallId, String output) { return ChatMemoryItem.builder() .role(ChatMessageType.TOOL.getRole()) .toolCallId(toolCallId) .text(output) .build(); } public static ChatMemoryItem summary(String role, String text) { return ChatMemoryItem.builder() .role(role) .text(text) .summary(true) .build(); } public ChatMessage toChatMessage() { if (ChatMessageType.USER.getRole().equals(role) && imageUrls != null && !imageUrls.isEmpty()) { return ChatMessage.builder() .role(ChatMessageType.USER.getRole()) .content(Content.ofMultiModals(toMultiModalContent(text, imageUrls))) .build(); } if (ChatMessageType.SYSTEM.getRole().equals(role)) { return ChatMessage.withSystem(text); } if (ChatMessageType.USER.getRole().equals(role)) { return ChatMessage.withUser(text); } if (ChatMessageType.ASSISTANT.getRole().equals(role)) { List copiedToolCalls = copyToolCalls(toolCalls); if (copiedToolCalls != null && !copiedToolCalls.isEmpty()) { if (hasText(text)) { return ChatMessage.withAssistant(text, copiedToolCalls); } return ChatMessage.withAssistant(copiedToolCalls); } return ChatMessage.withAssistant(text); } if (ChatMessageType.TOOL.getRole().equals(role)) { return ChatMessage.withTool(text, toolCallId); } return new ChatMessage(role, text); } public Object toResponsesInput() { if (ChatMessageType.TOOL.getRole().equals(role)) { Map item = new LinkedHashMap(); item.put("type", "function_call_output"); item.put("call_id", toolCallId); item.put("output", text); return item; } Map item = new LinkedHashMap(); item.put("type", "message"); item.put("role", role); List> content = new ArrayList>(); if (hasText(text)) { content.add(inputText(text)); } if (imageUrls != null) { for (String imageUrl : imageUrls) { if (hasText(imageUrl)) { content.add(inputImage(imageUrl)); } } } if (!content.isEmpty()) { item.put("content", content); } List> serializedToolCalls = serializeToolCalls(toolCalls); if (!serializedToolCalls.isEmpty()) { item.put("tool_calls", serializedToolCalls); } return item; } public boolean isEmpty() { boolean hasText = hasText(text); boolean hasImages = imageUrls != null && !imageUrls.isEmpty(); boolean hasToolCalls = toolCalls != null && !toolCalls.isEmpty(); boolean isToolOutput = ChatMessageType.TOOL.getRole().equals(role) && hasText(toolCallId); return !hasText && !hasImages && !hasToolCalls && !isToolOutput; } public static ChatMemoryItem copyOf(ChatMemoryItem source) { if (source == null) { return null; } return ChatMemoryItem.builder() .role(source.getRole()) .text(source.getText()) .imageUrls(copyStrings(source.getImageUrls())) .toolCallId(source.getToolCallId()) .toolCalls(copyToolCalls(source.getToolCalls())) .summary(source.isSummary()) .build(); } private static List toMultiModalContent(String text, List imageUrls) { List parts = new ArrayList(); if (hasText(text)) { parts.add(new Content.MultiModal(Content.MultiModal.Type.TEXT.getType(), text, null)); } if (imageUrls != null) { for (String imageUrl : imageUrls) { if (hasText(imageUrl)) { parts.add(new Content.MultiModal( Content.MultiModal.Type.IMAGE_URL.getType(), null, new Content.MultiModal.ImageUrl(imageUrl) )); } } } return parts; } private static Map inputText(String text) { Map part = new LinkedHashMap(); part.put("type", "input_text"); part.put("text", text); return part; } private static Map inputImage(String imageUrl) { Map part = new LinkedHashMap(); part.put("type", "input_image"); Map image = new LinkedHashMap(); image.put("url", imageUrl); part.put("image_url", image); return part; } private static List> serializeToolCalls(List toolCalls) { List> serialized = new ArrayList>(); if (toolCalls == null) { return serialized; } for (ToolCall toolCall : toolCalls) { if (toolCall == null) { continue; } Map item = new LinkedHashMap(); item.put("id", toolCall.getId()); item.put("type", hasText(toolCall.getType()) ? toolCall.getType() : "function"); if (toolCall.getFunction() != null) { Map function = new LinkedHashMap(); function.put("name", toolCall.getFunction().getName()); function.put("arguments", toolCall.getFunction().getArguments()); item.put("function", function); } serialized.add(item); } return serialized; } private static List copyToolCalls(List source) { if (source == null) { return null; } List copied = new ArrayList(source.size()); for (ToolCall toolCall : source) { if (toolCall == null) { copied.add(null); continue; } ToolCall.Function copiedFunction = null; if (toolCall.getFunction() != null) { copiedFunction = new ToolCall.Function( toolCall.getFunction().getName(), toolCall.getFunction().getArguments() ); } copied.add(new ToolCall(toolCall.getId(), toolCall.getType(), copiedFunction)); } return copied; } private static List copyStrings(List source) { return source == null ? null : new ArrayList(source); } private static boolean hasText(String value) { return value != null && !value.trim().isEmpty(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemoryPolicy.java ================================================ package io.github.lnyocly.ai4j.memory; import java.util.List; public interface ChatMemoryPolicy { List apply(List items); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemorySnapshot.java ================================================ package io.github.lnyocly.ai4j.memory; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ChatMemorySnapshot { private List items; public static ChatMemorySnapshot from(List items) { List copied = new ArrayList(); if (items != null) { for (ChatMemoryItem item : items) { copied.add(ChatMemoryItem.copyOf(item)); } } return ChatMemorySnapshot.builder() .items(copied) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemorySummarizer.java ================================================ package io.github.lnyocly.ai4j.memory; public interface ChatMemorySummarizer { String summarize(ChatMemorySummaryRequest request); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemorySummaryRequest.java ================================================ package io.github.lnyocly.ai4j.memory; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class ChatMemorySummaryRequest { private String existingSummary; private List itemsToSummarize; public static ChatMemorySummaryRequest from(String existingSummary, List itemsToSummarize) { List copied = new ArrayList(); if (itemsToSummarize != null) { for (ChatMemoryItem item : itemsToSummarize) { if (item != null) { copied.add(ChatMemoryItem.copyOf(item)); } } } return ChatMemorySummaryRequest.builder() .existingSummary(existingSummary) .itemsToSummarize(copied) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/InMemoryChatMemory.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import java.util.ArrayList; import java.util.List; public class InMemoryChatMemory implements ChatMemory { private final List items = new ArrayList(); private ChatMemoryPolicy policy; public InMemoryChatMemory() { this(new UnboundedChatMemoryPolicy()); } public InMemoryChatMemory(ChatMemoryPolicy policy) { this.policy = policy == null ? new UnboundedChatMemoryPolicy() : policy; } public void setPolicy(ChatMemoryPolicy policy) { this.policy = policy == null ? new UnboundedChatMemoryPolicy() : policy; applyPolicy(); } @Override public void addSystem(String text) { add(ChatMemoryItem.system(text)); } @Override public void addUser(String text) { add(ChatMemoryItem.user(text)); } @Override public void addUser(String text, String... imageUrls) { add(ChatMemoryItem.user(text, imageUrls)); } @Override public void addAssistant(String text) { add(ChatMemoryItem.assistant(text)); } @Override public void addAssistant(String text, List toolCalls) { add(ChatMemoryItem.assistant(text, toolCalls)); } @Override public void addAssistantToolCalls(List toolCalls) { add(ChatMemoryItem.assistantToolCalls(toolCalls)); } @Override public void addToolOutput(String toolCallId, String output) { add(ChatMemoryItem.tool(toolCallId, output)); } @Override public void add(ChatMemoryItem item) { if (item == null || item.isEmpty()) { return; } items.add(ChatMemoryItem.copyOf(item)); applyPolicy(); } @Override public void addAll(List items) { if (items == null || items.isEmpty()) { return; } for (ChatMemoryItem item : items) { add(item); } } @Override public List getItems() { List copied = new ArrayList(items.size()); for (ChatMemoryItem item : items) { copied.add(ChatMemoryItem.copyOf(item)); } return copied; } @Override public List toChatMessages() { List messages = new ArrayList(items.size()); for (ChatMemoryItem item : items) { messages.add(item.toChatMessage()); } return messages; } @Override public List toResponsesInput() { List input = new ArrayList(items.size()); for (ChatMemoryItem item : items) { input.add(item.toResponsesInput()); } return input; } @Override public ChatMemorySnapshot snapshot() { return ChatMemorySnapshot.from(items); } @Override public void restore(ChatMemorySnapshot snapshot) { items.clear(); if (snapshot != null && snapshot.getItems() != null) { for (ChatMemoryItem item : snapshot.getItems()) { if (item != null && !item.isEmpty()) { items.add(ChatMemoryItem.copyOf(item)); } } } applyPolicy(); } @Override public void clear() { items.clear(); } private void applyPolicy() { List applied = policy == null ? new UnboundedChatMemoryPolicy().apply(items) : policy.apply(items); items.clear(); if (applied != null) { items.addAll(applied); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/JdbcChatMemory.java ================================================ package io.github.lnyocly.ai4j.memory; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import javax.sql.DataSource; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class JdbcChatMemory implements ChatMemory { private final DataSource dataSource; private final String jdbcUrl; private final String username; private final String password; private final String sessionId; private final String tableName; private ChatMemoryPolicy policy; public JdbcChatMemory(JdbcChatMemoryConfig config) { if (config == null) { throw new IllegalArgumentException("config is required"); } this.dataSource = config.getDataSource(); this.jdbcUrl = trimToNull(config.getJdbcUrl()); this.username = trimToNull(config.getUsername()); this.password = config.getPassword(); this.sessionId = requiredText(config.getSessionId(), "sessionId"); this.tableName = validIdentifier(config.getTableName()); this.policy = config.getPolicy() == null ? new UnboundedChatMemoryPolicy() : config.getPolicy(); if (this.dataSource == null && this.jdbcUrl == null) { throw new IllegalArgumentException("dataSource or jdbcUrl is required"); } if (config.isInitializeSchema()) { initializeSchema(); } } public JdbcChatMemory(String jdbcUrl, String sessionId) { this(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId(sessionId) .build()); } public JdbcChatMemory(String jdbcUrl, String username, String password, String sessionId) { this(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl) .username(username) .password(password) .sessionId(sessionId) .build()); } public JdbcChatMemory(DataSource dataSource, String sessionId) { this(JdbcChatMemoryConfig.builder() .dataSource(dataSource) .sessionId(sessionId) .build()); } public void setPolicy(ChatMemoryPolicy policy) { this.policy = policy == null ? new UnboundedChatMemoryPolicy() : policy; synchronized (this) { replaceItems(applyPolicy(loadItems())); } } @Override public void addSystem(String text) { add(ChatMemoryItem.system(text)); } @Override public void addUser(String text) { add(ChatMemoryItem.user(text)); } @Override public void addUser(String text, String... imageUrls) { add(ChatMemoryItem.user(text, imageUrls)); } @Override public void addAssistant(String text) { add(ChatMemoryItem.assistant(text)); } @Override public void addAssistant(String text, List toolCalls) { add(ChatMemoryItem.assistant(text, toolCalls)); } @Override public void addAssistantToolCalls(List toolCalls) { add(ChatMemoryItem.assistantToolCalls(toolCalls)); } @Override public void addToolOutput(String toolCallId, String output) { add(ChatMemoryItem.tool(toolCallId, output)); } @Override public synchronized void add(ChatMemoryItem item) { if (item == null || item.isEmpty()) { return; } List items = loadItems(); items.add(ChatMemoryItem.copyOf(item)); replaceItems(applyPolicy(items)); } @Override public synchronized void addAll(List items) { if (items == null || items.isEmpty()) { return; } List merged = loadItems(); for (ChatMemoryItem item : items) { if (item != null && !item.isEmpty()) { merged.add(ChatMemoryItem.copyOf(item)); } } replaceItems(applyPolicy(merged)); } @Override public synchronized List getItems() { return copyItems(loadItems()); } @Override public synchronized List toChatMessages() { List items = loadItems(); List messages = new ArrayList(items.size()); for (ChatMemoryItem item : items) { messages.add(item.toChatMessage()); } return messages; } @Override public synchronized List toResponsesInput() { List items = loadItems(); List input = new ArrayList(items.size()); for (ChatMemoryItem item : items) { input.add(item.toResponsesInput()); } return input; } @Override public synchronized ChatMemorySnapshot snapshot() { return ChatMemorySnapshot.from(loadItems()); } @Override public synchronized void restore(ChatMemorySnapshot snapshot) { List items = new ArrayList(); if (snapshot != null && snapshot.getItems() != null) { for (ChatMemoryItem item : snapshot.getItems()) { if (item != null && !item.isEmpty()) { items.add(ChatMemoryItem.copyOf(item)); } } } replaceItems(applyPolicy(items)); } @Override public synchronized void clear() { replaceItems(Collections.emptyList()); } private void initializeSchema() { String sql = "create table if not exists " + tableName + " (" + "session_id varchar(191) not null, " + "item_index integer not null, " + "item_json text not null, " + "created_at bigint not null, " + "primary key (session_id, item_index)" + ")"; try (Connection connection = openConnection(); Statement statement = connection.createStatement()) { statement.executeUpdate(sql); } catch (Exception e) { throw new IllegalStateException("Failed to initialize chat memory schema", e); } } private List loadItems() { String sql = "select item_json from " + tableName + " where session_id = ? order by item_index asc"; try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, sessionId); try (ResultSet resultSet = statement.executeQuery()) { List items = new ArrayList(); while (resultSet.next()) { ChatMemoryItem item = JSON.parseObject(resultSet.getString("item_json"), ChatMemoryItem.class); if (item != null && !item.isEmpty()) { items.add(item); } } return items; } } catch (Exception e) { throw new IllegalStateException("Failed to load chat memory", e); } } private void replaceItems(List items) { String deleteSql = "delete from " + tableName + " where session_id = ?"; String insertSql = "insert into " + tableName + " (session_id, item_index, item_json, created_at) values (?, ?, ?, ?)"; try (Connection connection = openConnection()) { boolean autoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); try { try (PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) { deleteStatement.setString(1, sessionId); deleteStatement.executeUpdate(); } if (items != null && !items.isEmpty()) { try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) { long now = System.currentTimeMillis(); for (int i = 0; i < items.size(); i++) { insertStatement.setString(1, sessionId); insertStatement.setInt(2, i); insertStatement.setString(3, JSON.toJSONString(items.get(i))); insertStatement.setLong(4, now); insertStatement.addBatch(); } insertStatement.executeBatch(); } } connection.commit(); } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(autoCommit); } } catch (Exception e) { throw new IllegalStateException("Failed to persist chat memory", e); } } private List applyPolicy(List items) { List copied = copyItems(items); List applied = (policy == null ? new UnboundedChatMemoryPolicy() : policy).apply(copied); return copyItems(applied); } private List copyItems(List items) { List copied = new ArrayList(); if (items == null) { return copied; } for (ChatMemoryItem item : items) { if (item != null) { copied.add(ChatMemoryItem.copyOf(item)); } } return copied; } private Connection openConnection() throws Exception { if (dataSource != null) { return dataSource.getConnection(); } if (username == null) { return DriverManager.getConnection(jdbcUrl); } return DriverManager.getConnection(jdbcUrl, username, password); } private String validIdentifier(String value) { String identifier = requiredText(value, "tableName"); if (!identifier.matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?")) { throw new IllegalArgumentException("Invalid sql identifier: " + value); } return identifier; } private String requiredText(String value, String fieldName) { String text = trimToNull(value); if (text == null) { throw new IllegalArgumentException(fieldName + " is required"); } return text; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/JdbcChatMemoryConfig.java ================================================ package io.github.lnyocly.ai4j.memory; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import javax.sql.DataSource; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class JdbcChatMemoryConfig { private DataSource dataSource; private String jdbcUrl; private String username; private String password; private String sessionId; @Builder.Default private String tableName = "ai4j_chat_memory"; @Builder.Default private boolean initializeSchema = true; @Builder.Default private ChatMemoryPolicy policy = new UnboundedChatMemoryPolicy(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/MessageWindowChatMemoryPolicy.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import java.util.ArrayList; import java.util.List; public class MessageWindowChatMemoryPolicy implements ChatMemoryPolicy { private final int maxMessages; public MessageWindowChatMemoryPolicy(int maxMessages) { if (maxMessages < 0) { throw new IllegalArgumentException("maxMessages must be >= 0"); } this.maxMessages = maxMessages; } @Override public List apply(List items) { List result = new ArrayList(); if (items == null || items.isEmpty()) { return result; } boolean[] keep = new boolean[items.size()]; int remaining = maxMessages; for (int i = items.size() - 1; i >= 0; i--) { ChatMemoryItem item = items.get(i); if (item == null) { continue; } if (ChatMessageType.SYSTEM.getRole().equals(item.getRole())) { keep[i] = true; continue; } if (remaining > 0) { keep[i] = true; remaining--; } } for (int i = 0; i < items.size(); i++) { if (keep[i]) { result.add(ChatMemoryItem.copyOf(items.get(i))); } } return result; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/SummaryChatMemoryPolicy.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; public class SummaryChatMemoryPolicy implements ChatMemoryPolicy { private final ChatMemorySummarizer summarizer; private final int maxRecentMessages; private final int summaryTriggerMessages; private final String summaryRole; private final String summaryTextPrefix; public SummaryChatMemoryPolicy(SummaryChatMemoryPolicyConfig config) { if (config == null) { throw new IllegalArgumentException("config is required"); } if (config.getSummarizer() == null) { throw new IllegalArgumentException("summarizer is required"); } if (config.getMaxRecentMessages() < 0) { throw new IllegalArgumentException("maxRecentMessages must be >= 0"); } if (config.getSummaryTriggerMessages() <= config.getMaxRecentMessages()) { throw new IllegalArgumentException("summaryTriggerMessages must be > maxRecentMessages"); } String role = trimToNull(config.getSummaryRole()); if (!ChatMessageType.SYSTEM.getRole().equals(role) && !ChatMessageType.ASSISTANT.getRole().equals(role)) { throw new IllegalArgumentException("summaryRole must be system or assistant"); } this.summarizer = config.getSummarizer(); this.maxRecentMessages = config.getMaxRecentMessages(); this.summaryTriggerMessages = config.getSummaryTriggerMessages(); this.summaryRole = role; this.summaryTextPrefix = config.getSummaryTextPrefix(); } public SummaryChatMemoryPolicy(ChatMemorySummarizer summarizer, int maxRecentMessages, int summaryTriggerMessages) { this(SummaryChatMemoryPolicyConfig.builder() .summarizer(summarizer) .maxRecentMessages(maxRecentMessages) .summaryTriggerMessages(summaryTriggerMessages) .build()); } @Override public List apply(List items) { List copied = copyItems(items); if (copied.isEmpty()) { return copied; } List summaryEligibleIndices = collectSummaryEligibleIndices(copied); if (summaryEligibleIndices.size() <= summaryTriggerMessages) { return copied; } int keepRecent = Math.min(maxRecentMessages, summaryEligibleIndices.size()); int summarizeCount = summaryEligibleIndices.size() - keepRecent; if (summarizeCount <= 0) { return copied; } String existingSummary = mergeExistingSummary(copied, summaryEligibleIndices, summarizeCount); List itemsToSummarize = collectItemsToSummarize(copied, summaryEligibleIndices, summarizeCount); if (itemsToSummarize.isEmpty()) { return copied; } String summaryText = summarizer.summarize(ChatMemorySummaryRequest.from(existingSummary, itemsToSummarize)); String renderedSummary = renderSummary(summaryText); if (renderedSummary == null) { return copied; } ChatMemoryItem summaryItem = ChatMemoryItem.summary(summaryRole, renderedSummary); return rebuildWithSummary(copied, summaryEligibleIndices, summarizeCount, summaryItem); } private List collectSummaryEligibleIndices(List items) { List indices = new ArrayList(); for (int i = 0; i < items.size(); i++) { ChatMemoryItem item = items.get(i); if (item == null) { continue; } if (isPinnedSystemItem(item)) { continue; } indices.add(i); } return indices; } private boolean isPinnedSystemItem(ChatMemoryItem item) { return ChatMessageType.SYSTEM.getRole().equals(item.getRole()) && !item.isSummary(); } private String mergeExistingSummary(List items, List eligibleIndices, int summarizeCount) { StringBuilder merged = new StringBuilder(); for (int i = 0; i < summarizeCount; i++) { ChatMemoryItem item = items.get(eligibleIndices.get(i)); if (item == null || !item.isSummary()) { continue; } String text = stripSummaryPrefix(item.getText()); if (!hasText(text)) { continue; } if (merged.length() > 0) { merged.append("\n\n"); } merged.append(text); } return trimToNull(merged.toString()); } private List collectItemsToSummarize(List items, List eligibleIndices, int summarizeCount) { List collected = new ArrayList(); for (int i = 0; i < summarizeCount; i++) { ChatMemoryItem item = items.get(eligibleIndices.get(i)); if (item == null || item.isSummary()) { continue; } collected.add(ChatMemoryItem.copyOf(item)); } return collected; } private String stripSummaryPrefix(String text) { String value = trimToNull(text); if (value == null) { return null; } if (hasText(summaryTextPrefix) && value.startsWith(summaryTextPrefix)) { return trimToNull(value.substring(summaryTextPrefix.length())); } return value; } private String renderSummary(String summaryText) { String value = trimToNull(summaryText); if (value == null) { return null; } if (hasText(summaryTextPrefix) && value.startsWith(summaryTextPrefix)) { return value; } return hasText(summaryTextPrefix) ? summaryTextPrefix + value : value; } private List rebuildWithSummary(List items, List eligibleIndices, int summarizeCount, ChatMemoryItem summaryItem) { Set summarizedIndices = new HashSet(); for (int i = 0; i < summarizeCount; i++) { summarizedIndices.add(eligibleIndices.get(i)); } List rebuilt = new ArrayList(); int insertionIndex = eligibleIndices.get(0); for (int i = 0; i < items.size(); i++) { if (i == insertionIndex) { rebuilt.add(ChatMemoryItem.copyOf(summaryItem)); } if (summarizedIndices.contains(i)) { continue; } ChatMemoryItem item = items.get(i); if (item != null) { rebuilt.add(ChatMemoryItem.copyOf(item)); } } return rebuilt; } private List copyItems(List items) { List copied = new ArrayList(); if (items == null) { return copied; } for (ChatMemoryItem item : items) { if (item != null) { copied.add(ChatMemoryItem.copyOf(item)); } } return copied; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private boolean hasText(String value) { return trimToNull(value) != null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/SummaryChatMemoryPolicyConfig.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class SummaryChatMemoryPolicyConfig { private ChatMemorySummarizer summarizer; @Builder.Default private int maxRecentMessages = 12; @Builder.Default private int summaryTriggerMessages = 20; @Builder.Default private String summaryRole = ChatMessageType.ASSISTANT.getRole(); @Builder.Default private String summaryTextPrefix = "Summary of earlier conversation:\n"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/memory/UnboundedChatMemoryPolicy.java ================================================ package io.github.lnyocly.ai4j.memory; import java.util.ArrayList; import java.util.List; public class UnboundedChatMemoryPolicy implements ChatMemoryPolicy { @Override public List apply(List items) { List copied = new ArrayList(); if (items == null) { return copied; } for (ChatMemoryItem item : items) { copied.add(ChatMemoryItem.copyOf(item)); } return copied; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/network/ConnectionPoolProvider.java ================================================ package io.github.lnyocly.ai4j.network; import okhttp3.ConnectionPool; /** * @Author cly * @Description ConnectionPool提供器 * @Date 2024/10/16 23:10 */ public interface ConnectionPoolProvider { ConnectionPool getConnectionPool(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/network/DispatcherProvider.java ================================================ package io.github.lnyocly.ai4j.network; import okhttp3.Dispatcher; /** * @Author cly * @Description Dispatcher提供器 * @Date 2024/10/16 23:09 */ public interface DispatcherProvider { Dispatcher getDispatcher(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/network/OkHttpUtil.java ================================================ package io.github.lnyocly.ai4j.network; import javax.net.ssl.*; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.cert.X509Certificate; /** * * @author Vania * */ public class OkHttpUtil { /** * X509TrustManager instance which ignored SSL certification */ public static final X509TrustManager IGNORE_SSL_TRUST_MANAGER_X509 = new X509TrustManager() { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) { } @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[] {}; } }; /** * Get initialized SSLContext instance which ignored SSL certification * * @return * @throws NoSuchAlgorithmException * @throws KeyManagementException */ public static SSLContext getIgnoreInitedSslContext() throws NoSuchAlgorithmException, KeyManagementException { SSLContext sslContext = SSLContext.getInstance("SSL"); sslContext.init(null, new TrustManager[] { IGNORE_SSL_TRUST_MANAGER_X509 }, new SecureRandom()); return sslContext; } /** * Get HostnameVerifier which ignored SSL certification * * @return */ public static HostnameVerifier getIgnoreSslHostnameVerifier() { return new HostnameVerifier() { @Override public boolean verify(String arg0, SSLSession arg1) { return true; } }; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/network/UrlUtils.java ================================================ package io.github.lnyocly.ai4j.network; /** * @Author cly * @Description 用于验证、处理 * @Date 2024/9/19 14:40 */ public final class UrlUtils { private UrlUtils() { } public static String concatUrl(String... params){ if(params.length == 0) { throw new IllegalArgumentException("url params is empty"); } // 拼接字符串 StringBuilder sb = new StringBuilder(); for (int i = 0; i < params.length; i++) { if (params[i].startsWith("/")) { params[i] = params[i].substring(1); } if(params[i].startsWith("?") || params[i].startsWith("&")){ // 如果sb的末尾是“/”,则删除末尾 if(sb.length() > 0 && sb.charAt(sb.length()-1) == '/') { sb.deleteCharAt(sb.length() - 1); } } sb.append(params[i]); if(!params[i].endsWith("/")){ sb.append('/'); } } // 去掉最后一个/ if(sb.length() > 0 && sb.charAt(sb.length()-1) == '/'){ sb.deleteCharAt(sb.length()-1); } return sb.toString(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/network/impl/DefaultConnectionPoolProvider.java ================================================ package io.github.lnyocly.ai4j.network.impl; import io.github.lnyocly.ai4j.network.ConnectionPoolProvider; import okhttp3.ConnectionPool; /** * @Author cly * @Description ConnectionPool默认实现 * @Date 2024/10/16 23:11 */ public class DefaultConnectionPoolProvider implements ConnectionPoolProvider { @Override public ConnectionPool getConnectionPool() { return new ConnectionPool(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/network/impl/DefaultDispatcherProvider.java ================================================ package io.github.lnyocly.ai4j.network.impl; import io.github.lnyocly.ai4j.network.DispatcherProvider; import okhttp3.Dispatcher; /** * @Author cly * @Description Dispatcher默认实现 * @Date 2024/10/16 23:11 */ public class DefaultDispatcherProvider implements DispatcherProvider { @Override public Dispatcher getDispatcher() { return new Dispatcher(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/baichuan/chat/BaichuanChatService.java ================================================ package io.github.lnyocly.ai4j.platform.baichuan.chat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.BaichuanConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.baichuan.chat.entity.BaichuanChatCompletion; import io.github.lnyocly.ai4j.platform.baichuan.chat.entity.BaichuanChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description 百川chat服务 * @Date 2024/8/27 17:29 */ public class BaichuanChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final String TOOL_CALLS_FINISH_REASON = "tool_calls"; private static final String FIRST_FINISH_REASON = "first"; private final BaichuanConfig baichuanConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public BaichuanChatService(Configuration configuration) { this.baichuanConfig = configuration.getBaichuanConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } public BaichuanChatService(Configuration configuration, BaichuanConfig baichuanConfig) { this.baichuanConfig = baichuanConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, false); BaichuanChatCompletion baichuanChatCompletion = convertChatCompletionObject(chatCompletion); Usage allUsage = new Usage(); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { BaichuanChatCompletionResponse response = executeChatCompletionRequest( resolvedBaseUrl, resolvedApiKey, baichuanChatCompletion ); if (response == null) { break; } Choice choice = response.getChoices().get(0); finishReason = choice.getFinishReason(); mergeUsage(allUsage, response.getUsage()); if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) { if (passThroughToolCalls) { response.setUsage(allUsage); response.setObject("chat.completion"); restoreOriginalRequest(chatCompletion, baichuanChatCompletion); return this.convertChatCompletionResponse(response); } baichuanChatCompletion.setMessages(appendToolMessages( baichuanChatCompletion.getMessages(), choice.getMessage(), choice.getMessage().getToolCalls() )); continue; } response.setUsage(allUsage); response.setObject("chat.completion"); restoreOriginalRequest(chatCompletion, baichuanChatCompletion); return this.convertChatCompletionResponse(response); } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, true); BaichuanChatCompletion baichuanChatCompletion = convertChatCompletionObject(chatCompletion); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, baichuanChatCompletion); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) { continue; } if (passThroughToolCalls) { return; } baichuanChatCompletion.setMessages(appendStreamToolMessages( baichuanChatCompletion.getMessages(), toolCalls )); resetToolCallState(eventSourceListener); } restoreOriginalRequest(chatCompletion, baichuanChatCompletion); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } @Override public BaichuanChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { BaichuanChatCompletion baichuanChatCompletion = new BaichuanChatCompletion(); baichuanChatCompletion.setModel(chatCompletion.getModel()); baichuanChatCompletion.setMessages(chatCompletion.getMessages()); baichuanChatCompletion.setStream(chatCompletion.getStream()); baichuanChatCompletion.setTemperature(chatCompletion.getTemperature() / 2); baichuanChatCompletion.setTopP(chatCompletion.getTopP()); baichuanChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion)); baichuanChatCompletion.setStop(chatCompletion.getStop()); baichuanChatCompletion.setTools(chatCompletion.getTools()); baichuanChatCompletion.setFunctions(chatCompletion.getFunctions()); baichuanChatCompletion.setToolChoice(chatCompletion.getToolChoice()); baichuanChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return baichuanChatCompletion; } @Override public EventSourceListener convertEventSource(final SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(BaichuanChatCompletionResponse baichuanChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(baichuanChatCompletionResponse.getId()); chatCompletionResponse.setCreated(baichuanChatCompletionResponse.getCreated()); chatCompletionResponse.setModel(baichuanChatCompletionResponse.getModel()); chatCompletionResponse.setChoices(baichuanChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(baichuanChatCompletionResponse.getUsage()); return chatCompletionResponse; } private String serializeStreamResponse(String data) { try { BaichuanChatCompletionResponse chatCompletionResponse = objectMapper.readValue(data, BaichuanChatCompletionResponse.class); chatCompletionResponse.setObject("chat.completion.chunk"); ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse); return objectMapper.writeValueAsString(response); } catch (JsonProcessingException e) { throw new CommonException("Baichuan Chat 对象JSON序列化出错"); } } private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) { chatCompletion.setStream(stream); if (!stream) { chatCompletion.setStreamOptions(null); } attachTools(chatCompletion); } private void attachTools(ChatCompletion chatCompletion) { if (hasPendingTools(chatCompletion)) { List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if (tools == null) { chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) { chatCompletion.setParallelToolCalls(null); } } private boolean hasPendingTools(ChatCompletion chatCompletion) { return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty()); } private boolean requiresFollowUp(String finishReason) { return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason); } private BaichuanChatCompletionResponse executeChatCompletionRequest( String baseUrl, String apiKey, BaichuanChatCompletion baichuanChatCompletion ) throws Exception { Request request = buildChatCompletionRequest(baseUrl, apiKey, baichuanChatCompletion); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), BaichuanChatCompletionResponse.class); } } return null; } private Request buildChatCompletionRequest(String baseUrl, String apiKey, BaichuanChatCompletion baichuanChatCompletion) throws JsonProcessingException { String requestBody = objectMapper.writeValueAsString(baichuanChatCompletion); return new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, baichuanConfig.getChatCompletionUrl())) .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) .build(); } private void mergeUsage(Usage target, Usage usage) { if (usage == null) { return; } target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens()); target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens()); target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens()); } private List appendToolMessages( List messages, ChatMessage assistantMessage, List toolCalls ) { List updatedMessages = new ArrayList(messages); updatedMessages.add(assistantMessage); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private List appendStreamToolMessages(List messages, List toolCalls) { List updatedMessages = new ArrayList(messages); updatedMessages.add(ChatMessage.withAssistant(toolCalls)); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private void appendToolResponses(List messages, List toolCalls) { for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } private void resetToolCallState(SseListener eventSourceListener) { eventSourceListener.setToolCalls(new ArrayList()); eventSourceListener.setToolCall(null); } private void restoreOriginalRequest(ChatCompletion chatCompletion, BaichuanChatCompletion baichuanChatCompletion) { chatCompletion.setMessages(baichuanChatCompletion.getMessages()); chatCompletion.setTools(baichuanChatCompletion.getTools()); } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? baichuanConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? baichuanConfig.getApiKey() : apiKey; } @SuppressWarnings("deprecation") private Integer resolveMaxTokens(ChatCompletion chatCompletion) { if (chatCompletion.getMaxCompletionTokens() != null) { return chatCompletion.getMaxCompletionTokens(); } return chatCompletion.getMaxTokens(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/baichuan/chat/entity/BaichuanChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.baichuan.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class BaichuanChatCompletion { @NonNull private String model; @NonNull private List messages; @JsonProperty("request_id") private String requestId; @Builder.Default @JsonProperty("do_sample") private Boolean doSample = true; @Builder.Default private Boolean stream = false; /** * 采样温度,控制输出的随机性,必须为正数。值越大,会使输出更随机 * [0.0, 1.0] */ @Builder.Default private Float temperature = 0.95f; /** * 核取样 * [0.0, 1.0] */ @Builder.Default @JsonProperty("top_p") private Float topP = 0.7f; @JsonProperty("max_tokens") private Integer maxTokens; private List stop; private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; @JsonProperty("tool_choice") private String toolChoice; @JsonProperty("user_id") private String userId; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class ZhipuChatCompletionBuilder { private List functions; public ZhipuChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public ZhipuChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } if (functions != null) { this.functions.addAll(functions); } return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/baichuan/chat/entity/BaichuanChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.baichuan.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class BaichuanChatCompletionResponse { private String id; private String object; private Long created; private String model; private List choices; private Usage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/DashScopeChatService.java ================================================ package io.github.lnyocly.ai4j.platform.dashscope; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjUtil; import com.alibaba.dashscope.aigc.generation.*; import com.alibaba.dashscope.common.Message; import com.alibaba.dashscope.common.ResponseFormat; import com.alibaba.dashscope.common.ResultCallback; import com.alibaba.dashscope.tools.*; import com.alibaba.dashscope.utils.JsonUtils; import com.alibaba.fastjson2.JSON; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.DashScopeConfig; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.dashscope.entity.DashScopeResult; import io.github.lnyocly.ai4j.platform.dashscope.util.MessageUtil; import io.github.lnyocly.ai4j.platform.openai.chat.entity.*; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import okhttp3.Request; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; /** * @Author cly * @Description OpenAi 聊天服务 * @Date 2024/8/2 23:16 */ @Slf4j public class DashScopeChatService implements IChatService, ParameterConvert, ResultConvert { private final DashScopeConfig dashScopeConfig; // 创建一个空的 EventSource 对象,用于占位 private final EventSource eventSource = new EventSource() { @NotNull @Override public Request request() { return null; } @Override public void cancel() { } }; public DashScopeChatService(Configuration configuration) { this.dashScopeConfig = configuration.getDashScopeConfig(); } public DashScopeChatService(Configuration configuration, DashScopeConfig dashScopeConfig) { this.dashScopeConfig = dashScopeConfig; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if (apiKey == null || "".equals(apiKey)) apiKey = dashScopeConfig.getApiKey(); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); chatCompletion.setStream(false); chatCompletion.setStreamOptions(null); if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){ List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if(tools == null){ chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){ }else{ chatCompletion.setParallelToolCalls(null); } // 总token消耗 Usage allUsage = new Usage(); String finishReason = "first"; while ("first".equals(finishReason) || "tool_calls".equals(finishReason)) { finishReason = null; Generation gen = new Generation(); GenerationParam param = convertChatCompletionObject(chatCompletion); param.setApiKey(apiKey); GenerationResult result = gen.call(param); DashScopeResult dashScopeResult = new DashScopeResult(); dashScopeResult.setGenerationResult(result); dashScopeResult.setObject("chat.completion"); dashScopeResult.setModel(chatCompletion.getModel()); dashScopeResult.setCreated(System.currentTimeMillis() / 1000); GenerationOutput.Choice choice = result.getOutput().getChoices().get(0); finishReason = choice.getFinishReason(); GenerationUsage usage = result.getUsage(); allUsage.setCompletionTokens(allUsage.getCompletionTokens() + usage.getOutputTokens()); allUsage.setTotalTokens(allUsage.getTotalTokens() + usage.getTotalTokens()); allUsage.setPromptTokens(allUsage.getPromptTokens() + usage.getInputTokens()); // 判断是否为函数调用返回 if ("tool_calls".equals(finishReason)) { if (passThroughToolCalls) { ChatCompletionResponse chatCompletionResponse = convertChatCompletionResponse(dashScopeResult); chatCompletionResponse.setUsage(allUsage); return chatCompletionResponse; } Message message = choice.getMessage(); List toolCalls = message.getToolCalls(); List messages = new ArrayList<>(chatCompletion.getMessages()); messages.add(MessageUtil.convert(message)); // 添加 tool 消息 for (ToolCallBase toolCall : toolCalls) { if (toolCall.getType().equals("function")) { String functionName = ((ToolCallFunction)toolCall).getFunction().getName(); String arguments = ((ToolCallFunction) toolCall).getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } chatCompletion.setMessages(messages); } else { ChatCompletionResponse chatCompletionResponse = convertChatCompletionResponse(dashScopeResult); // 其他情况直接返回 chatCompletionResponse.setUsage(allUsage); return chatCompletionResponse; } } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if (apiKey == null || "".equals(apiKey)) apiKey = dashScopeConfig.getApiKey(); chatCompletion.setStream(true); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); StreamOptions streamOptions = chatCompletion.getStreamOptions(); if (streamOptions == null) { chatCompletion.setStreamOptions(new StreamOptions(true)); } if ((chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty())) { //List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if (tools == null) { chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools() != null && !chatCompletion.getTools().isEmpty()) { } else { chatCompletion.setParallelToolCalls(null); } String finishReason = "first"; while ("first".equals(finishReason) || "tool_calls".equals(finishReason)) { finishReason = null; eventSourceListener.setFinishReason(null); Generation gen = new Generation(); GenerationParam param = convertChatCompletionObject(chatCompletion); param.setApiKey(apiKey); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> gen.streamCall(param, new ResultCallback() { @SneakyThrows @Override public void onEvent(GenerationResult message) { log.info("{}", JSON.toJSONString(message)); DashScopeResult dashScopeResult = new DashScopeResult(); dashScopeResult.setGenerationResult(message); dashScopeResult.setObject("chat.completion.chunk"); dashScopeResult.setCreated(System.currentTimeMillis() / 1000); dashScopeResult.setModel(chatCompletion.getModel()); ObjectMapper objectMapper = new ObjectMapper(); eventSourceListener.onEvent(eventSource, message.getRequestId(), null, objectMapper.writeValueAsString(convertChatCompletionResponse(dashScopeResult))); } @Override public void onComplete() { eventSourceListener.onClosed(eventSource); } @Override public void onError(Exception e) { eventSourceListener.onFailure(eventSource, e, null); } }) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); // 需要调用函数 if ("tool_calls".equals(finishReason) && !toolCalls.isEmpty()) { if (passThroughToolCalls) { return; } // 创建tool响应消息 ChatMessage responseMessage = ChatMessage.withAssistant(eventSourceListener.getToolCalls()); List messages = new ArrayList<>(chatCompletion.getMessages()); messages.add(responseMessage); // 封装tool结果消息 for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } eventSourceListener.setToolCalls(new ArrayList<>()); eventSourceListener.setToolCall(null); chatCompletion.setMessages(messages); } } } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { chatCompletionStream(null, null, chatCompletion, eventSourceListener); } @Override public GenerationParam convertChatCompletionObject(ChatCompletion chatCompletion) { GenerationParam.GenerationParamBuilder builder = GenerationParam.builder(); builder.model(chatCompletion.getModel()) .messages(MessageUtil.convertToMessage(chatCompletion.getMessages())) .resultFormat(GenerationParam.ResultFormat.MESSAGE) .temperature(chatCompletion.getTemperature()) .topP(Double.valueOf(chatCompletion.getTopP())) .maxTokens(chatCompletion.getMaxCompletionTokens()) .toolChoice(chatCompletion.getToolChoice()) .parameters(chatCompletion.getParameters()); if (ObjUtil.isNotNull(chatCompletion.getResponseFormat()) && String.valueOf(chatCompletion.getResponseFormat()).contains("json_object")) { builder.responseFormat(ResponseFormat.from(ResponseFormat.JSON_OBJECT)); } if (CollUtil.isNotEmpty(chatCompletion.getTools())) { List toolBaseList = chatCompletion.getTools().stream().map(tool -> { if ("function".equals(tool.getType())) { Tool.Function function = tool.getFunction(); return ToolFunction.builder().function(FunctionDefinition.builder().name(function.getName()).description(function.getDescription()).parameters(JsonUtils.parse(JsonUtils.toJson(function.getParameters()))).build()).build(); } return null; } ).filter(ObjUtil::isNotNull).collect(Collectors.toList()); builder.tools(toolBaseList); } return builder.build(); } @Override public EventSourceListener convertEventSource(SseListener eventSourceListener) { return null; } @Override public ChatCompletionResponse convertChatCompletionResponse(DashScopeResult dashScopeResult) { GenerationResult generationResult = dashScopeResult.getGenerationResult(); ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(generationResult.getRequestId()); chatCompletionResponse.setCreated(dashScopeResult.getCreated()); chatCompletionResponse.setObject(dashScopeResult.getObject()); GenerationOutput.Choice srcChoice = generationResult.getOutput().getChoices().get(0); Choice choice = new Choice(); ChatMessage chatMessage = MessageUtil.convert(srcChoice.getMessage()); choice.setMessage(chatMessage); choice.setDelta(chatMessage); choice.setIndex(srcChoice.getIndex()); choice.setFinishReason(srcChoice.getFinishReason()); chatCompletionResponse.setChoices(CollUtil.newArrayList(choice)); GenerationUsage usage = generationResult.getUsage(); chatCompletionResponse.setUsage(new Usage(usage.getInputTokens(), usage.getOutputTokens(), usage.getTotalTokens())); return chatCompletionResponse; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/entity/DashScopeResult.java ================================================ package io.github.lnyocly.ai4j.platform.dashscope.entity; import com.alibaba.dashscope.aigc.generation.GenerationResult; import lombok.Data; @Data public class DashScopeResult { private String model; /** * 创建聊天完成时的 Unix 时间戳(以秒为单位)。 */ private Long created; /** * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk */ private String object; private GenerationResult generationResult; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/response/DashScopeResponsesService.java ================================================ package io.github.lnyocly.ai4j.platform.dashscope.response; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.DashScopeConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.ResponseSseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions; import io.github.lnyocly.ai4j.platform.openai.response.ResponseEventParser; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IResponsesService; import io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** * @Author cly * @Description DashScope Responses API service * @Date 2026/2/1 */ public class DashScopeResponsesService implements IResponsesService { private final DashScopeConfig dashScopeConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; public DashScopeResponsesService(Configuration configuration) { this.dashScopeConfig = configuration.getDashScopeConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } @Override public Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception { String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl()); String key = resolveApiKey(apiKey); request.setStream(false); request.setStreamOptions(null); request = ResponseRequestToolResolver.resolve(request); ObjectMapper mapper = new ObjectMapper(); String body = mapper.writeValueAsString(request); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE))) .build(); try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), Response.class); } } throw new CommonException("DashScope Responses request failed"); } @Override public Response create(ResponseRequest request) throws Exception { return create(null, null, request); } @Override public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception { String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl()); String key = resolveApiKey(apiKey); if (request.getStream() == null || !request.getStream()) { request.setStream(true); } if (request.getStreamOptions() == null) { request.setStreamOptions(new StreamOptions(true)); } request = ResponseRequestToolResolver.resolve(request); ObjectMapper mapper = new ObjectMapper(); String body = mapper.writeValueAsString(request); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE))) .build(); StreamExecutionSupport.execute( listener, request.getStreamExecution(), () -> factory.newEventSource(httpRequest, convertEventSource(mapper, listener)) ); } @Override public void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception { createStream(null, null, request, listener); } @Override public Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception { String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl() + "/" + responseId); String key = resolveApiKey(apiKey); ObjectMapper mapper = new ObjectMapper(); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .get() .build(); try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), Response.class); } } throw new CommonException("DashScope Responses retrieve failed"); } @Override public Response retrieve(String responseId) throws Exception { return retrieve(null, null, responseId); } @Override public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception { String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl() + "/" + responseId); String key = resolveApiKey(apiKey); ObjectMapper mapper = new ObjectMapper(); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .delete() .build(); try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), ResponseDeleteResponse.class); } } throw new CommonException("DashScope Responses delete failed"); } @Override public ResponseDeleteResponse delete(String responseId) throws Exception { return delete(null, null, responseId); } private String resolveUrl(String baseUrl, String path) { String host = (baseUrl == null || "".equals(baseUrl)) ? dashScopeConfig.getApiHost() : baseUrl; return UrlUtils.concatUrl(host, path); } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? dashScopeConfig.getApiKey() : apiKey; } private EventSourceListener convertEventSource(ObjectMapper mapper, ResponseSseListener listener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull okhttp3.Response response) { listener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable okhttp3.Response response) { listener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { listener.complete(); return; } try { ResponseStreamEvent event = ResponseEventParser.parse(mapper, data); listener.accept(event); if (isTerminalEvent(event.getType())) { listener.complete(); } } catch (Exception e) { listener.onError(e, null); listener.complete(); } } @Override public void onClosed(@NotNull EventSource eventSource) { listener.onClosed(eventSource); } }; } private boolean isTerminalEvent(String type) { if (type == null) { return false; } return "response.completed".equals(type) || "response.failed".equals(type) || "response.incomplete".equals(type); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/util/MessageUtil.java ================================================ package io.github.lnyocly.ai4j.platform.dashscope.util; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.dashscope.common.*; import com.alibaba.dashscope.tools.ToolCallFunction; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Content; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import java.util.List; import java.util.stream.Collectors; public class MessageUtil { public static ChatMessage convert(Message message) { ChatMessage chatMessage = BeanUtil.copyProperties(message, ChatMessage.class, "content", "toolCalls"); if (StrUtil.isNotBlank(message.getContent())) { chatMessage.setContent(Content.ofText(message.getContent())); } if (CollUtil.isNotEmpty(message.getContents())) { List list = message.getContents().stream().map(content -> { if ("image_url".equals(content.getType())) { return Content.MultiModal.builder().type(Content.MultiModal.Type.IMAGE_URL.getType()).imageUrl(new Content.MultiModal.ImageUrl(((MessageContentImageURL) content).getImageURL().getUrl())).build(); } else { return Content.MultiModal.builder().type(Content.MultiModal.Type.TEXT.getType()).text(((MessageContentText) content).getText()).build(); } }).collect(Collectors.toList()); chatMessage.setContent(Content.ofMultiModals(list)); } if (CollUtil.isNotEmpty(message.getToolCalls())) { chatMessage.setToolCalls(message.getToolCalls().stream().map(toolCall -> { if ("function".equals(toolCall.getType())) { ToolCallFunction.CallFunction src = ((ToolCallFunction) toolCall).getFunction(); ToolCall tool = new ToolCall(); tool.setId(toolCall.getId()); tool.setType("function"); tool.setFunction(new ToolCall.Function(src.getName(), src.getArguments())); return tool; } else { return null; } }).filter(ObjUtil::isNotNull).collect(Collectors.toList())); } return chatMessage; } public static List convertToChatMessage(List messageList) { return messageList.stream().map(MessageUtil::convert).collect(Collectors.toList()); } public static Message convert(ChatMessage message) { Message target = BeanUtil.copyProperties(message, Message.class, "content", "toolCalls"); if (ObjUtil.isNotNull(message.getContent())) { if (StrUtil.isNotBlank(message.getContent().getText())) { target.setContent(message.getContent().getText()); } if (CollUtil.isNotEmpty(message.getContent().getMultiModals())) { target.setContents(message.getContent().getMultiModals().stream().map(multiModal -> { if (Content.MultiModal.Type.IMAGE_URL.getType().equals(multiModal.getType())) { return MessageContentImageURL.builder().imageURL(ImageURL.builder().url(multiModal.getImageUrl().getUrl()).build()).build(); } else { return MessageContentText.builder().text(multiModal.getText()).build(); } }).collect(Collectors.toList())); } } if (CollUtil.isNotEmpty(message.getToolCalls())) { target.setToolCalls(message.getToolCalls().stream().map(toolCall -> { if ("function".equals(toolCall.getType())) { ToolCall.Function src = toolCall.getFunction(); ToolCallFunction toolCallFunction = new ToolCallFunction(); ToolCallFunction.CallFunction callFunction = toolCallFunction.new CallFunction(); callFunction.setName(src.getName()); callFunction.setArguments(src.getArguments()); toolCallFunction.setFunction(callFunction); toolCallFunction.setId(toolCall.getId()); return toolCallFunction; } else { return null; } }).filter(ObjUtil::isNotNull).collect(Collectors.toList())); } return target; } public static List convertToMessage(List messageList) { return messageList.stream().map(MessageUtil::convert).collect(Collectors.toList()); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/deepseek/chat/DeepSeekChatService.java ================================================ package io.github.lnyocly.ai4j.platform.deepseek.chat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.deepseek.chat.entity.DeepSeekChatCompletion; import io.github.lnyocly.ai4j.platform.deepseek.chat.entity.DeepSeekChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description DeepSeek Chat服务 * @Date 2024/8/29 10:26 */ public class DeepSeekChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final String TOOL_CALLS_FINISH_REASON = "tool_calls"; private static final String FIRST_FINISH_REASON = "first"; private final DeepSeekConfig deepSeekConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public DeepSeekChatService(Configuration configuration) { this.deepSeekConfig = configuration.getDeepSeekConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } public DeepSeekChatService(Configuration configuration, DeepSeekConfig deepSeekConfig) { this.deepSeekConfig = deepSeekConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public DeepSeekChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { DeepSeekChatCompletion deepSeekChatCompletion = new DeepSeekChatCompletion(); deepSeekChatCompletion.setModel(chatCompletion.getModel()); deepSeekChatCompletion.setMessages(chatCompletion.getMessages()); deepSeekChatCompletion.setFrequencyPenalty(chatCompletion.getFrequencyPenalty()); deepSeekChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion)); deepSeekChatCompletion.setPresencePenalty(chatCompletion.getPresencePenalty()); deepSeekChatCompletion.setResponseFormat(chatCompletion.getResponseFormat()); deepSeekChatCompletion.setStop(chatCompletion.getStop()); deepSeekChatCompletion.setStream(chatCompletion.getStream()); deepSeekChatCompletion.setStreamOptions(chatCompletion.getStreamOptions()); deepSeekChatCompletion.setTemperature(chatCompletion.getTemperature()); deepSeekChatCompletion.setTopP(chatCompletion.getTopP()); deepSeekChatCompletion.setTools(chatCompletion.getTools()); deepSeekChatCompletion.setFunctions(chatCompletion.getFunctions()); deepSeekChatCompletion.setToolChoice(chatCompletion.getToolChoice()); deepSeekChatCompletion.setLogprobs(chatCompletion.getLogprobs()); deepSeekChatCompletion.setTopLogprobs(chatCompletion.getTopLogprobs()); deepSeekChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return deepSeekChatCompletion; } @Override public EventSourceListener convertEventSource(final SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(DeepSeekChatCompletionResponse deepSeekChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(deepSeekChatCompletionResponse.getId()); chatCompletionResponse.setObject(deepSeekChatCompletionResponse.getObject()); chatCompletionResponse.setCreated(deepSeekChatCompletionResponse.getCreated()); chatCompletionResponse.setModel(deepSeekChatCompletionResponse.getModel()); chatCompletionResponse.setSystemFingerprint(deepSeekChatCompletionResponse.getSystemFingerprint()); chatCompletionResponse.setChoices(deepSeekChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(deepSeekChatCompletionResponse.getUsage()); return chatCompletionResponse; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, false); DeepSeekChatCompletion deepSeekChatCompletion = convertChatCompletionObject(chatCompletion); Usage allUsage = new Usage(); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { DeepSeekChatCompletionResponse response = executeChatCompletionRequest( resolvedBaseUrl, resolvedApiKey, deepSeekChatCompletion ); if (response == null) { break; } Choice choice = response.getChoices().get(0); finishReason = choice.getFinishReason(); mergeUsage(allUsage, response.getUsage()); if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) { if (passThroughToolCalls) { response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, deepSeekChatCompletion); return convertChatCompletionResponse(response); } deepSeekChatCompletion.setMessages(appendToolMessages( deepSeekChatCompletion.getMessages(), choice.getMessage(), choice.getMessage().getToolCalls() )); continue; } response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, deepSeekChatCompletion); return convertChatCompletionResponse(response); } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return this.chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, true); DeepSeekChatCompletion deepSeekChatCompletion = convertChatCompletionObject(chatCompletion); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, deepSeekChatCompletion); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) { continue; } if (passThroughToolCalls) { return; } deepSeekChatCompletion.setMessages(appendStreamToolMessages( deepSeekChatCompletion.getMessages(), toolCalls )); resetToolCallState(eventSourceListener); } restoreOriginalRequest(chatCompletion, deepSeekChatCompletion); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } private String serializeStreamResponse(String data) { try { DeepSeekChatCompletionResponse chatCompletionResponse = objectMapper.readValue(data, DeepSeekChatCompletionResponse.class); ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse); return objectMapper.writeValueAsString(response); } catch (JsonProcessingException e) { throw new CommonException("读取DeepSeek Chat 对象JSON序列化出错"); } } private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) { chatCompletion.setStream(stream); if (!stream) { chatCompletion.setStreamOptions(null); } attachTools(chatCompletion); } private void attachTools(ChatCompletion chatCompletion) { if (hasPendingTools(chatCompletion)) { List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if (tools == null) { chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) { chatCompletion.setParallelToolCalls(null); } } private boolean hasPendingTools(ChatCompletion chatCompletion) { return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty()); } private boolean requiresFollowUp(String finishReason) { return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason); } private DeepSeekChatCompletionResponse executeChatCompletionRequest( String baseUrl, String apiKey, DeepSeekChatCompletion deepSeekChatCompletion ) throws Exception { Request request = buildChatCompletionRequest(baseUrl, apiKey, deepSeekChatCompletion); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), DeepSeekChatCompletionResponse.class); } } return null; } private Request buildChatCompletionRequest(String baseUrl, String apiKey, DeepSeekChatCompletion deepSeekChatCompletion) throws JsonProcessingException { String requestBody = objectMapper.writeValueAsString(deepSeekChatCompletion); return new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, deepSeekConfig.getChatCompletionUrl())) .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) .build(); } private void mergeUsage(Usage target, Usage usage) { if (usage == null) { return; } target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens()); target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens()); target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens()); } private List appendToolMessages( List messages, ChatMessage assistantMessage, List toolCalls ) { List updatedMessages = new ArrayList(messages); updatedMessages.add(assistantMessage); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private List appendStreamToolMessages(List messages, List toolCalls) { List updatedMessages = new ArrayList(messages); updatedMessages.add(ChatMessage.withAssistant(toolCalls)); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private void appendToolResponses(List messages, List toolCalls) { for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } private void resetToolCallState(SseListener eventSourceListener) { eventSourceListener.setToolCalls(new ArrayList()); eventSourceListener.setToolCall(null); } private void restoreOriginalRequest(ChatCompletion chatCompletion, DeepSeekChatCompletion deepSeekChatCompletion) { chatCompletion.setMessages(deepSeekChatCompletion.getMessages()); chatCompletion.setTools(deepSeekChatCompletion.getTools()); } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? deepSeekConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? deepSeekConfig.getApiKey() : apiKey; } @SuppressWarnings("deprecation") private Integer resolveMaxTokens(ChatCompletion chatCompletion) { if (chatCompletion.getMaxCompletionTokens() != null) { return chatCompletion.getMaxCompletionTokens(); } return chatCompletion.getMaxTokens(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/deepseek/chat/entity/DeepSeekChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.deepseek.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.zhipu.chat.entity.ZhipuChatCompletion; import lombok.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * @Author cly * @Description DeepSeek对话请求实体 * @Date 2024/8/29 10:27 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class DeepSeekChatCompletion { @NonNull private String model; @NonNull private List messages; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。 */ @Builder.Default @JsonProperty("frequency_penalty") private Float frequencyPenalty = 0f; /** * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。 */ @JsonProperty("max_tokens") private Integer maxTokens; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。 */ @Builder.Default @JsonProperty("presence_penalty") private Float presencePenalty = 0f; /** * 一个 object,指定模型必须输出的格式。 * * 设置为 { "type": "json_object" } 以启用 JSON 模式,该模式保证模型生成的消息是有效的 JSON。 * * 注意: 使用 JSON 模式时,你还必须通过系统或用户消息指示模型生成 JSON。 * 否则,模型可能会生成不断的空白字符,直到生成达到令牌限制,从而导致请求长时间运行并显得“卡住”。 * 此外,如果 finish_reason="length",这表示生成超过了 max_tokens 或对话超过了最大上下文长度,消息内容可能会被部分截断。 */ @JsonProperty("response_format") private Object responseFormat; /** * 在遇到这些词时,API 将停止生成更多的 token。 */ private List stop; /** * 如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾 */ @Builder.Default private Boolean stream = false; /** * 流式输出相关选项。只有在 stream 参数为 true 时,才可设置此参数。 */ @Builder.Default @JsonProperty("stream_options") private StreamOptions streamOptions = new StreamOptions(); /** * 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ @Builder.Default private Float temperature = 1f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @Builder.Default @JsonProperty("top_p") private Float topP = 1f; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; /** * 控制模型调用 tool 的行为。 * none 意味着模型不会调用任何 tool,而是生成一条消息。 * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。 * 当没有 tool 时,默认值为 none。如果有 tool 存在,默认值为 auto。 */ @JsonProperty("tool_choice") private String toolChoice; /** * 是否返回所输出 token 的对数概率。如果为 true,则在 message 的 content 中返回每个输出 token 的对数概率。 */ @Builder.Default private Boolean logprobs = false; /** * 一个介于 0 到 20 之间的整数 N,指定每个输出位置返回输出概率 top N 的 token,且返回这些 token 的对数概率。指定此参数时,logprobs 必须为 true。 */ @JsonProperty("top_logprobs") private Integer topLogprobs; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class DeepSeekChatCompletionBuilder { private List functions; public DeepSeekChatCompletion.DeepSeekChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public DeepSeekChatCompletion.DeepSeekChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } if (functions != null) { this.functions.addAll(functions); } return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/deepseek/chat/entity/DeepSeekChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.deepseek.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description DeepSeek对话响应实体 * @Date 2024/8/29 10:28 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class DeepSeekChatCompletionResponse { /** * 该对话的唯一标识符。 */ private String id; /** * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk */ private String object; /** * 创建聊天完成时的 Unix 时间戳(以秒为单位)。 */ private Long created; /** * 生成该 completion 的模型名。 */ private String model; /** * 模型生成的 completion 的选择列表。 */ private List choices; /** * 该对话补全请求的用量信息。 */ private Usage usage; /** * 该指纹代表模型运行时使用的后端配置。 */ @JsonProperty("system_fingerprint") private String systemFingerprint; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/chat/DoubaoChatService.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.chat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.doubao.chat.entity.DoubaoChatCompletion; import io.github.lnyocly.ai4j.platform.doubao.chat.entity.DoubaoChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; public class DoubaoChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final String TOOL_CALLS_FINISH_REASON = "tool_calls"; private static final String FIRST_FINISH_REASON = "first"; private final DoubaoConfig doubaoConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public DoubaoChatService(Configuration configuration) { this.doubaoConfig = configuration.getDoubaoConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } public DoubaoChatService(Configuration configuration, DoubaoConfig doubaoConfig) { this.doubaoConfig = doubaoConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public DoubaoChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { DoubaoChatCompletion doubaoChatCompletion = new DoubaoChatCompletion(); doubaoChatCompletion.setModel(chatCompletion.getModel()); doubaoChatCompletion.setMessages(chatCompletion.getMessages()); doubaoChatCompletion.setFrequencyPenalty(chatCompletion.getFrequencyPenalty()); doubaoChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion)); doubaoChatCompletion.setPresencePenalty(chatCompletion.getPresencePenalty()); doubaoChatCompletion.setResponseFormat(chatCompletion.getResponseFormat()); doubaoChatCompletion.setStop(chatCompletion.getStop()); doubaoChatCompletion.setStream(chatCompletion.getStream()); doubaoChatCompletion.setStreamOptions(chatCompletion.getStreamOptions()); doubaoChatCompletion.setTemperature(chatCompletion.getTemperature()); doubaoChatCompletion.setTopP(chatCompletion.getTopP()); doubaoChatCompletion.setTools(chatCompletion.getTools()); doubaoChatCompletion.setFunctions(chatCompletion.getFunctions()); doubaoChatCompletion.setToolChoice(chatCompletion.getToolChoice()); doubaoChatCompletion.setLogprobs(chatCompletion.getLogprobs()); doubaoChatCompletion.setTopLogprobs(chatCompletion.getTopLogprobs()); doubaoChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return doubaoChatCompletion; } @Override public EventSourceListener convertEventSource(final SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(DoubaoChatCompletionResponse doubaoChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(doubaoChatCompletionResponse.getId()); chatCompletionResponse.setObject(doubaoChatCompletionResponse.getObject()); chatCompletionResponse.setCreated(doubaoChatCompletionResponse.getCreated()); chatCompletionResponse.setModel(doubaoChatCompletionResponse.getModel()); chatCompletionResponse.setSystemFingerprint(doubaoChatCompletionResponse.getSystemFingerprint()); chatCompletionResponse.setChoices(doubaoChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(doubaoChatCompletionResponse.getUsage()); return chatCompletionResponse; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, false); DoubaoChatCompletion doubaoChatCompletion = convertChatCompletionObject(chatCompletion); Usage allUsage = new Usage(); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { DoubaoChatCompletionResponse response = executeChatCompletionRequest( resolvedBaseUrl, resolvedApiKey, doubaoChatCompletion ); if (response == null) { break; } Choice choice = response.getChoices().get(0); finishReason = choice.getFinishReason(); mergeUsage(allUsage, response.getUsage()); if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) { if (passThroughToolCalls) { response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, doubaoChatCompletion); return convertChatCompletionResponse(response); } doubaoChatCompletion.setMessages(appendToolMessages( doubaoChatCompletion.getMessages(), choice.getMessage(), choice.getMessage().getToolCalls() )); continue; } response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, doubaoChatCompletion); return convertChatCompletionResponse(response); } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return this.chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, true); DoubaoChatCompletion doubaoChatCompletion = convertChatCompletionObject(chatCompletion); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, doubaoChatCompletion); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) { continue; } if (passThroughToolCalls) { return; } doubaoChatCompletion.setMessages(appendStreamToolMessages( doubaoChatCompletion.getMessages(), toolCalls )); resetToolCallState(eventSourceListener); } restoreOriginalRequest(chatCompletion, doubaoChatCompletion); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } private String serializeStreamResponse(String data) { try { DoubaoChatCompletionResponse chatCompletionResponse = objectMapper.readValue(data, DoubaoChatCompletionResponse.class); ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse); return objectMapper.writeValueAsString(response); } catch (JsonProcessingException e) { throw new CommonException("读取豆包 Chat 对象JSON序列化出错"); } } private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) { chatCompletion.setStream(stream); if (stream) { if (chatCompletion.getStreamOptions() == null) { chatCompletion.setStreamOptions(new StreamOptions(true)); } } else { chatCompletion.setStreamOptions(null); } attachTools(chatCompletion); } private void attachTools(ChatCompletion chatCompletion) { if (hasPendingTools(chatCompletion)) { List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if (tools == null) { chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) { chatCompletion.setParallelToolCalls(null); } } private boolean hasPendingTools(ChatCompletion chatCompletion) { return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty()); } private boolean requiresFollowUp(String finishReason) { return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason); } private DoubaoChatCompletionResponse executeChatCompletionRequest( String baseUrl, String apiKey, DoubaoChatCompletion doubaoChatCompletion ) throws Exception { Request request = buildChatCompletionRequest(baseUrl, apiKey, doubaoChatCompletion); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), DoubaoChatCompletionResponse.class); } } return null; } private Request buildChatCompletionRequest(String baseUrl, String apiKey, DoubaoChatCompletion doubaoChatCompletion) throws JsonProcessingException { String requestBody = objectMapper.writeValueAsString(doubaoChatCompletion); return new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, doubaoConfig.getChatCompletionUrl())) .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) .build(); } private void mergeUsage(Usage target, Usage usage) { if (usage == null) { return; } target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens()); target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens()); target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens()); } private List appendToolMessages( List messages, ChatMessage assistantMessage, List toolCalls ) { List updatedMessages = new ArrayList(messages); updatedMessages.add(assistantMessage); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private List appendStreamToolMessages(List messages, List toolCalls) { List updatedMessages = new ArrayList(messages); updatedMessages.add(ChatMessage.withAssistant(toolCalls)); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private void appendToolResponses(List messages, List toolCalls) { for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } private void resetToolCallState(SseListener eventSourceListener) { eventSourceListener.setToolCalls(new ArrayList()); eventSourceListener.setToolCall(null); } private void restoreOriginalRequest(ChatCompletion chatCompletion, DoubaoChatCompletion doubaoChatCompletion) { chatCompletion.setMessages(doubaoChatCompletion.getMessages()); chatCompletion.setTools(doubaoChatCompletion.getTools()); } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? doubaoConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? doubaoConfig.getApiKey() : apiKey; } @SuppressWarnings("deprecation") private Integer resolveMaxTokens(ChatCompletion chatCompletion) { if (chatCompletion.getMaxCompletionTokens() != null) { return chatCompletion.getMaxCompletionTokens(); } return chatCompletion.getMaxTokens(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/chat/entity/DoubaoChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * @Author cly * @Description 豆包(火山引擎方舟)对话请求实体 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class DoubaoChatCompletion { @NonNull private String model; @NonNull private List messages; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。 */ @Builder.Default @JsonProperty("frequency_penalty") private Float frequencyPenalty = 0f; /** * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。 */ @JsonProperty("max_tokens") private Integer maxTokens; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。 */ @Builder.Default @JsonProperty("presence_penalty") private Float presencePenalty = 0f; /** * 一个 object,指定模型必须输出的格式。 * * 设置为 { "type": "json_object" } 以启用 JSON 模式,该模式保证模型生成的消息是有效的 JSON。 */ @JsonProperty("response_format") private Object responseFormat; /** * 在遇到这些词时,API 将停止生成更多的 token。 */ private List stop; /** * 如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾 */ @Builder.Default private Boolean stream = false; /** * 流式输出相关选项。只有在 stream 参数为 true 时,才可设置此参数。 */ @Builder.Default @JsonProperty("stream_options") private StreamOptions streamOptions = new StreamOptions(); /** * 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ @Builder.Default private Float temperature = 1f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @Builder.Default @JsonProperty("top_p") private Float topP = 1f; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; /** * 控制模型调用 tool 的行为。 * none 意味着模型不会调用任何 tool,而是生成一条消息。 * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。 * 当没有 tool 时,默认值为 none。如果有 tool 存在,默认值为 auto。 */ @JsonProperty("tool_choice") private String toolChoice; /** * 是否返回所输出 token 的对数概率。如果为 true,则在 message 的 content 中返回每个输出 token 的对数概率。 */ @Builder.Default private Boolean logprobs = false; /** * 一个介于 0 到 20 之间的整数 N,指定每个输出位置返回输出概率 top N 的 token,且返回这些 token 的对数概率。指定此参数时,logprobs 必须为 true。 */ @JsonProperty("top_logprobs") private Integer topLogprobs; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class DoubaoChatCompletionBuilder { private List functions; public DoubaoChatCompletion.DoubaoChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public DoubaoChatCompletion.DoubaoChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } if (functions != null) { this.functions.addAll(functions); } return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/chat/entity/DoubaoChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class DoubaoChatCompletionResponse { /** * 该对话的唯一标识符。 */ private String id; /** * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk */ private String object; /** * 创建聊天完成时的 Unix 时间戳(以秒为单位)。 */ private Long created; /** * 生成该 completion 的模型名。 */ private String model; /** * 模型生成的 completion 的选择列表。 */ private List choices; /** * 该对话补全请求的用量信息。 */ private Usage usage; /** * 该指纹代表模型运行时使用的后端配置。 */ @JsonProperty("system_fingerprint") private String systemFingerprint; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/image/DoubaoImageService.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.image; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.ImageSseListener; import io.github.lnyocly.ai4j.platform.doubao.image.entity.DoubaoImageGenerationRequest; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamError; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamEvent; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageUsage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IImageService; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import okhttp3.sse.EventSources; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import com.fasterxml.jackson.databind.JsonNode; /** * @Author cly * @Description 豆包图片生成服务 * @Date 2026/1/31 */ public class DoubaoImageService implements IImageService { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final DoubaoConfig doubaoConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; public DoubaoImageService(Configuration configuration) { this.doubaoConfig = configuration.getDoubaoConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = EventSources.createFactory(okHttpClient); } private DoubaoImageGenerationRequest convert(ImageGeneration imageGeneration) { return DoubaoImageGenerationRequest.builder() .model(imageGeneration.getModel()) .prompt(imageGeneration.getPrompt()) .n(imageGeneration.getN()) .size(imageGeneration.getSize()) .responseFormat(imageGeneration.getResponseFormat()) .stream(imageGeneration.getStream()) .extraBody(imageGeneration.getExtraBody()) .build(); } @Override public ImageGenerationResponse generate(String baseUrl, String apiKey, ImageGeneration imageGeneration) throws Exception { if (baseUrl == null || "".equals(baseUrl)) { baseUrl = doubaoConfig.getApiHost(); } if (apiKey == null || "".equals(apiKey)) { apiKey = doubaoConfig.getApiKey(); } DoubaoImageGenerationRequest requestBody = convert(imageGeneration); ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(requestBody); Request request = new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, doubaoConfig.getImageGenerationUrl())) .post(RequestBody.create(requestString, JSON_MEDIA_TYPE)) .build(); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), ImageGenerationResponse.class); } } throw new CommonException("豆包图片生成请求失败"); } @Override public ImageGenerationResponse generate(ImageGeneration imageGeneration) throws Exception { return this.generate(null, null, imageGeneration); } @Override public void generateStream(String baseUrl, String apiKey, ImageGeneration imageGeneration, ImageSseListener listener) throws Exception { if (baseUrl == null || "".equals(baseUrl)) { baseUrl = doubaoConfig.getApiHost(); } if (apiKey == null || "".equals(apiKey)) { apiKey = doubaoConfig.getApiKey(); } if (imageGeneration.getStream() == null || !imageGeneration.getStream()) { imageGeneration.setStream(true); } DoubaoImageGenerationRequest requestBody = convert(imageGeneration); ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(requestBody); Request request = new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, doubaoConfig.getImageGenerationUrl())) .post(RequestBody.create(requestString, JSON_MEDIA_TYPE)) .build(); factory.newEventSource(request, convertEventSource(mapper, listener)); listener.getCountDownLatch().await(); } @Override public void generateStream(ImageGeneration imageGeneration, ImageSseListener listener) throws Exception { this.generateStream(null, null, imageGeneration, listener); } private EventSourceListener convertEventSource(ObjectMapper mapper, ImageSseListener listener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { // no-op } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { listener.onError(t, response); listener.complete(); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { listener.complete(); return; } try { ImageStreamEvent event = parseDoubaoEvent(mapper, data); listener.accept(event); if ("image_generation.completed".equals(event.getType())) { listener.complete(); } } catch (Exception e) { listener.onError(e, null); listener.complete(); } } @Override public void onClosed(@NotNull EventSource eventSource) { listener.complete(); } }; } private ImageStreamEvent parseDoubaoEvent(ObjectMapper mapper, String data) throws Exception { JsonNode node = mapper.readTree(data); ImageStreamEvent event = new ImageStreamEvent(); event.setType(asText(node, "type")); event.setModel(asText(node, "model")); Long createdAt = asLong(node, "created"); if (createdAt == null) { createdAt = asLong(node, "created_at"); } event.setCreatedAt(createdAt); event.setImageIndex(asInt(node, "image_index")); event.setPartialImageIndex(asInt(node, "image_index")); event.setUrl(asText(node, "url")); event.setB64Json(asText(node, "b64_json")); event.setSize(asText(node, "size")); if (node.has("usage")) { event.setUsage(mapper.treeToValue(node.get("usage"), ImageUsage.class)); } if (node.has("error")) { event.setError(mapper.treeToValue(node.get("error"), ImageStreamError.class)); } return event; } private String asText(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asText(); } private Integer asInt(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asInt(); } private Long asLong(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asLong(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/image/entity/DoubaoImageGenerationRequest.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.image.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.*; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class DoubaoImageGenerationRequest { @NonNull private String model; @NonNull private String prompt; private Integer n; private String size; @JsonProperty("response_format") private String responseFormat; private Boolean stream; @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/rerank/DoubaoRerankService.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.rerank; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.network.UrlUtils; import io.github.lnyocly.ai4j.rerank.entity.RerankDocument; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; import io.github.lnyocly.ai4j.rerank.entity.RerankResult; import io.github.lnyocly.ai4j.rerank.entity.RerankUsage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IRerankService; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import org.apache.commons.lang3.StringUtils; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class DoubaoRerankService implements IRerankService { private final DoubaoConfig doubaoConfig; private final OkHttpClient okHttpClient; private final ObjectMapper objectMapper = new ObjectMapper(); public DoubaoRerankService(Configuration configuration) { this(configuration, configuration == null ? null : configuration.getDoubaoConfig()); } public DoubaoRerankService(Configuration configuration, DoubaoConfig doubaoConfig) { this.doubaoConfig = doubaoConfig; this.okHttpClient = configuration == null ? null : configuration.getOkHttpClient(); } @Override public RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) throws Exception { String host = resolveBaseUrl(baseUrl); String key = resolveApiKey(apiKey); String path = resolveRerankUrl(); Map body = new LinkedHashMap(); body.put("rerank_model", request.getModel()); if (StringUtils.isNotBlank(request.getInstruction())) { body.put("rerank_instruction", request.getInstruction()); } List> datas = new ArrayList>(); List documents = request.getDocuments() == null ? Collections.emptyList() : request.getDocuments(); for (RerankDocument document : documents) { if (document == null) { continue; } Map item = new LinkedHashMap(); item.put("query", request.getQuery()); String content = firstNonBlank(document.getContent(), document.getText()); if (StringUtils.isNotBlank(content)) { item.put("content", content); } if (StringUtils.isNotBlank(document.getTitle())) { item.put("title", document.getTitle()); } if (document.getImage() != null) { item.put("image", document.getImage()); } datas.add(item); } body.put("datas", datas); if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) { body.putAll(request.getExtraBody()); } Request.Builder builder = new Request.Builder() .url(UrlUtils.concatUrl(host, path)) .post(RequestBody.create(objectMapper.writeValueAsString(body), MediaType.get(Constants.JSON_CONTENT_TYPE))); if (StringUtils.isNotBlank(key)) { builder.header("Authorization", "Bearer " + key); } try (okhttp3.Response response = okHttpClient.newCall(builder.build()).execute()) { if (response.isSuccessful() && response.body() != null) { return parseResponse(objectMapper.readTree(response.body().string()), request); } } throw new CommonException("Doubao rerank request failed"); } @Override public RerankResponse rerank(RerankRequest request) throws Exception { return rerank(null, null, request); } private RerankResponse parseResponse(JsonNode root, RerankRequest request) { JsonNode dataNode = root == null ? null : root.get("data"); JsonNode scoreArray = dataNode; if (dataNode != null && dataNode.isObject() && dataNode.has("scores")) { scoreArray = dataNode.get("scores"); } List results = new ArrayList(); List documents = request.getDocuments() == null ? Collections.emptyList() : request.getDocuments(); if (scoreArray != null && scoreArray.isArray()) { for (int i = 0; i < scoreArray.size(); i++) { JsonNode item = scoreArray.get(i); float relevanceScore; Integer index = i; if (item != null && item.isObject()) { relevanceScore = item.has("score") ? (float) item.get("score").asDouble() : 0.0f; if (item.has("index")) { index = item.get("index").asInt(); } } else { relevanceScore = item == null || item.isNull() ? 0.0f : (float) item.asDouble(); } RerankDocument document = index != null && index >= 0 && index < documents.size() ? documents.get(index) : null; results.add(RerankResult.builder() .index(index) .relevanceScore(relevanceScore) .document(document == null ? null : document.toBuilder().build()) .build()); } } Collections.sort(results, new Comparator() { @Override public int compare(RerankResult left, RerankResult right) { float l = left == null || left.getRelevanceScore() == null ? 0.0f : left.getRelevanceScore(); float r = right == null || right.getRelevanceScore() == null ? 0.0f : right.getRelevanceScore(); return Float.compare(r, l); } }); return RerankResponse.builder() .id(text(root, "request_id")) .model(request == null ? null : request.getModel()) .results(results) .usage(RerankUsage.builder() .inputTokens(intValue(root == null ? null : root.get("token_usage"))) .build()) .build(); } private String resolveBaseUrl(String baseUrl) { if (StringUtils.isNotBlank(baseUrl)) { return baseUrl; } if (doubaoConfig != null && StringUtils.isNotBlank(doubaoConfig.getRerankApiHost())) { return doubaoConfig.getRerankApiHost(); } if (doubaoConfig != null && StringUtils.isNotBlank(doubaoConfig.getApiHost())) { return doubaoConfig.getApiHost(); } throw new IllegalArgumentException("doubao rerank apiHost is required"); } private String resolveApiKey(String apiKey) { if (StringUtils.isNotBlank(apiKey)) { return apiKey; } return doubaoConfig == null ? null : doubaoConfig.getApiKey(); } private String resolveRerankUrl() { if (doubaoConfig == null || StringUtils.isBlank(doubaoConfig.getRerankUrl())) { throw new IllegalArgumentException("doubao rerankUrl is required"); } return doubaoConfig.getRerankUrl(); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (StringUtils.isNotBlank(value)) { return value.trim(); } } return null; } private Integer intValue(JsonNode node) { if (node == null || node.isNull()) { return null; } return node.asInt(); } private String text(JsonNode node, String fieldName) { if (node == null || fieldName == null || !node.has(fieldName) || node.get(fieldName).isNull()) { return null; } String text = node.get(fieldName).asText(); return StringUtils.isBlank(text) ? null : text; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/response/DoubaoResponsesService.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.response; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.ResponseSseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.openai.response.ResponseEventParser; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IResponsesService; import io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** * @Author cly * @Description Doubao Responses API service * @Date 2026/2/1 */ public class DoubaoResponsesService implements IResponsesService { private final DoubaoConfig doubaoConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; public DoubaoResponsesService(Configuration configuration) { this.doubaoConfig = configuration.getDoubaoConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } @Override public Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception { String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl()); String key = resolveApiKey(apiKey); request.setStream(false); request.setStreamOptions(null); request = ResponseRequestToolResolver.resolve(request); ObjectMapper mapper = new ObjectMapper(); String body = mapper.writeValueAsString(request); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE))) .build(); try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), Response.class); } } throw new CommonException("Doubao Responses request failed"); } @Override public Response create(ResponseRequest request) throws Exception { return create(null, null, request); } @Override public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception { String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl()); String key = resolveApiKey(apiKey); if (request.getStream() == null || !request.getStream()) { request.setStream(true); } request.setStreamOptions(null); request = ResponseRequestToolResolver.resolve(request); ObjectMapper mapper = new ObjectMapper(); String body = mapper.writeValueAsString(request); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE))) .build(); StreamExecutionSupport.execute( listener, request.getStreamExecution(), () -> factory.newEventSource(httpRequest, convertEventSource(mapper, listener)) ); } @Override public void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception { createStream(null, null, request, listener); } @Override public Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception { String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl() + "/" + responseId); String key = resolveApiKey(apiKey); ObjectMapper mapper = new ObjectMapper(); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .get() .build(); try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), Response.class); } } throw new CommonException("Doubao Responses retrieve failed"); } @Override public Response retrieve(String responseId) throws Exception { return retrieve(null, null, responseId); } @Override public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception { String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl() + "/" + responseId); String key = resolveApiKey(apiKey); ObjectMapper mapper = new ObjectMapper(); Request httpRequest = new Request.Builder() .header("Authorization", "Bearer " + key) .url(url) .delete() .build(); try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), ResponseDeleteResponse.class); } } throw new CommonException("Doubao Responses delete failed"); } @Override public ResponseDeleteResponse delete(String responseId) throws Exception { return delete(null, null, responseId); } private String resolveUrl(String baseUrl, String path) { String host = (baseUrl == null || "".equals(baseUrl)) ? doubaoConfig.getApiHost() : baseUrl; return UrlUtils.concatUrl(host, path); } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? doubaoConfig.getApiKey() : apiKey; } private EventSourceListener convertEventSource(ObjectMapper mapper, ResponseSseListener listener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull okhttp3.Response response) { listener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable okhttp3.Response response) { listener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { listener.complete(); return; } try { ResponseStreamEvent event = ResponseEventParser.parse(mapper, data); listener.accept(event); if (isTerminalEvent(event.getType())) { listener.complete(); } } catch (Exception e) { listener.onError(e, null); listener.complete(); } } @Override public void onClosed(@NotNull EventSource eventSource) { listener.onClosed(eventSource); } }; } private boolean isTerminalEvent(String type) { if (type == null) { return false; } return "response.completed".equals(type) || "response.failed".equals(type) || "response.incomplete".equals(type); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/HunyuanConstant.java ================================================ package io.github.lnyocly.ai4j.platform.hunyuan; /** * @Author cly * @Description 腾讯混元常量 * @Date 2024/8/30 19:30 */ public class HunyuanConstant { public static final String Version = "2023-09-01"; public static final String ChatCompletions = "ChatCompletions"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/chat/HunyuanChatService.java ================================================ package io.github.lnyocly.ai4j.platform.hunyuan.chat; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.HunyuanConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.hunyuan.HunyuanConstant; import io.github.lnyocly.ai4j.platform.hunyuan.chat.entity.HunyuanChatCompletion; import io.github.lnyocly.ai4j.platform.hunyuan.chat.entity.HunyuanChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.*; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.auth.BearerTokenUtils; import io.github.lnyocly.ai4j.platform.hunyuan.support.HunyuanJsonUtil; import io.github.lnyocly.ai4j.tool.ToolUtil; import okhttp3.*; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description 腾讯混元 Chat 服务 * @Date 2024/8/30 19:24 */ public class HunyuanChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final HunyuanConfig hunyuanConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; public HunyuanChatService(Configuration configuration) { this.hunyuanConfig = configuration.getHunyuanConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } public HunyuanChatService(Configuration configuration, HunyuanConfig hunyuanConfig) { this.hunyuanConfig = hunyuanConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } @Override public HunyuanChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { HunyuanChatCompletion hunyuanChatCompletion = new HunyuanChatCompletion(); hunyuanChatCompletion.setModel(chatCompletion.getModel()); hunyuanChatCompletion.setMessages(chatCompletion.getMessages()); hunyuanChatCompletion.setStream(chatCompletion.getStream()); hunyuanChatCompletion.setTemperature(chatCompletion.getTemperature()); hunyuanChatCompletion.setTopP(chatCompletion.getTopP()); hunyuanChatCompletion.setTools(chatCompletion.getTools()); hunyuanChatCompletion.setFunctions(chatCompletion.getFunctions()); hunyuanChatCompletion.setToolChoice(chatCompletion.getToolChoice()); hunyuanChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return hunyuanChatCompletion; } @Override public EventSourceListener convertEventSource(SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } ObjectMapper mapper = new ObjectMapper(); HunyuanChatCompletionResponse hunyuanChatCompletionResponse = null; try { hunyuanChatCompletionResponse = mapper.readValue(HunyuanJsonUtil.toSnakeCaseJson(data), HunyuanChatCompletionResponse.class); } catch (JsonProcessingException e) { throw new CommonException("解析混元Hunyuan Chat Completion Response失败"); } ChatCompletionResponse response = convertChatCompletionResponse(hunyuanChatCompletionResponse); response.setObject("chat.completion.chunk"); Choice choice = response.getChoices().get(0); if(eventSourceListener.getToolCall()!=null){ if(choice.getDelta().getToolCalls()!=null){ choice.getDelta().getToolCalls().get(0).setId(null); } } if(StringUtils.isBlank(choice.getFinishReason())){ response.setUsage(null); } if("tool_calls".equals(choice.getFinishReason())){ //eventSourceListener.setToolCall(null); //this.onClosed(eventSource); } eventSourceListener.onEvent(eventSource, id, type, JSON.toJSONString(response)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(HunyuanChatCompletionResponse hunyuanChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(hunyuanChatCompletionResponse.getId()); chatCompletionResponse.setObject(hunyuanChatCompletionResponse.getObject()); chatCompletionResponse.setCreated(Long.valueOf(hunyuanChatCompletionResponse.getCreated())); chatCompletionResponse.setModel(hunyuanChatCompletionResponse.getModel()); chatCompletionResponse.setChoices(hunyuanChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(hunyuanChatCompletionResponse.getUsage()); return chatCompletionResponse; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if(baseUrl == null || "".equals(baseUrl)) baseUrl = hunyuanConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = hunyuanConfig.getApiKey(); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); chatCompletion.setStream(false); if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){ //List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if(tools == null){ chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){ }else{ chatCompletion.setParallelToolCalls(null); } // 转换 请求参数 HunyuanChatCompletion hunyuanChatCompletion = this.convertChatCompletionObject(chatCompletion); // 如含有function,则添加tool /* if(hunyuanChatCompletion.getFunctions()!=null && !hunyuanChatCompletion.getFunctions().isEmpty()){ List tools = ToolUtil.getAllFunctionTools(hunyuanChatCompletion.getFunctions()); hunyuanChatCompletion.setTools(tools); }*/ // 总token消耗 Usage allUsage = new Usage(); String finishReason = "first"; while("first".equals(finishReason) || "tool_calls".equals(finishReason)){ finishReason = null; // 构造请求 ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(hunyuanChatCompletion); // 整理tools JSONObject jsonObject = JSON.parseObject(requestString); // 获取 Tools 数组 JSONArray toolsArray = jsonObject.getJSONArray("tools"); if(toolsArray!= null && !toolsArray.isEmpty()){ // 遍历并修改 Tools 中的每个对象 for (int i = 0; i < toolsArray.size(); i++) { JSONObject tool = toolsArray.getJSONObject(i); // 重新构建 Function 对象 JSONObject function = tool.getJSONObject("function"); JSONObject newFunction = new JSONObject(); newFunction.put("name", function.getString("name")); newFunction.put("description", function.getString("description")); newFunction.put("parameters", function.getJSONObject("parameters").toJSONString()); // 替换旧的 Function 对象 tool.put("function", newFunction); tool.put("type", "function"); } } /** * Messages 中 Contents 字段仅 hunyuan-vision 模型支持 * hunyuan模型,识图多模态类只能放在Contents字段 */ if("hunyuan-vision".equals(chatCompletion.getModel())){ // 获取所有的content字段 JSONArray messagesArray = jsonObject.getJSONArray("messages"); if(messagesArray!= null && !messagesArray.isEmpty()){ for (int i = 0; i < messagesArray.size(); i++) { JSONObject message = messagesArray.getJSONObject(i); // 获取当前message的content字段 String content = message.getString("content"); // 将content内容,判断是否可以转换为ChatMessage.MultiModal类型 if(content!=null && content.startsWith("[") && content.endsWith("]")) { List multiModals = JSON.parseArray(content, Content.MultiModal.class); if(multiModals!=null && !multiModals.isEmpty()){ // 将当前的content转换为contents message.put("contents", multiModals); // 删除原来的content key message.remove("content"); } } } } } // 将修改后的 JSON 对象转为字符串 requestString = jsonObject.toJSONString(); requestString = HunyuanJsonUtil.toCamelCaseWithUppercaseJson(requestString); String authorization = BearerTokenUtils.getAuthorization(apiKey,HunyuanConstant.ChatCompletions,requestString); Request request = new Request.Builder() .header("Authorization", authorization) .header("X-TC-Action", HunyuanConstant.ChatCompletions) .header("X-TC-Version", HunyuanConstant.Version) .header("X-TC-Timestamp", String.valueOf(System.currentTimeMillis() / 1000)) .url(baseUrl) .post(RequestBody.create(requestString, JSON_MEDIA_TYPE)) .build(); Response execute = okHttpClient.newCall(request).execute(); if (execute.isSuccessful() && execute.body() != null){ String responseString = execute.body().string(); responseString = HunyuanJsonUtil.toSnakeCaseJson(responseString); responseString = JSON.parseObject(responseString).get("response").toString(); HunyuanChatCompletionResponse hunyuanChatCompletionResponse = mapper.readValue(responseString, HunyuanChatCompletionResponse.class); Choice choice = hunyuanChatCompletionResponse.getChoices().get(0); finishReason = choice.getFinishReason(); Usage usage = hunyuanChatCompletionResponse.getUsage(); allUsage.setCompletionTokens(allUsage.getCompletionTokens() + usage.getCompletionTokens()); allUsage.setTotalTokens(allUsage.getTotalTokens() + usage.getTotalTokens()); allUsage.setPromptTokens(allUsage.getPromptTokens() + usage.getPromptTokens()); // 判断是否为函数调用返回 if("tool_calls".equals(finishReason)){ if (passThroughToolCalls) { hunyuanChatCompletionResponse.setUsage(allUsage); hunyuanChatCompletionResponse.setObject("chat.completion"); hunyuanChatCompletionResponse.setModel(hunyuanChatCompletion.getModel()); return this.convertChatCompletionResponse(hunyuanChatCompletionResponse); } ChatMessage message = choice.getMessage(); List toolCalls = message.getToolCalls(); List messages = new ArrayList<>(hunyuanChatCompletion.getMessages()); messages.add(message); // 添加 tool 消息 for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } hunyuanChatCompletion.setMessages(messages); }else{// 其他情况直接返回 // 设置包含tool的总token数 hunyuanChatCompletionResponse.setUsage(allUsage); hunyuanChatCompletionResponse.setObject("chat.completion"); hunyuanChatCompletionResponse.setModel(hunyuanChatCompletion.getModel()); // 恢复原始请求数据 chatCompletion.setMessages(hunyuanChatCompletion.getMessages()); chatCompletion.setTools(hunyuanChatCompletion.getTools()); return this.convertChatCompletionResponse(hunyuanChatCompletionResponse); } } } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return this.chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if(baseUrl == null || "".equals(baseUrl)) baseUrl = hunyuanConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = hunyuanConfig.getApiKey(); chatCompletion.setStream(true); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){ //List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if(tools == null){ chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){ }else{ chatCompletion.setParallelToolCalls(null); } // 转换 请求参数 HunyuanChatCompletion hunyuanChatCompletion = this.convertChatCompletionObject(chatCompletion); /* // 如含有function,则添加tool if(hunyuanChatCompletion.getFunctions()!=null && !hunyuanChatCompletion.getFunctions().isEmpty()){ List tools = ToolUtil.getAllFunctionTools(hunyuanChatCompletion.getFunctions()); hunyuanChatCompletion.setTools(tools); }*/ String finishReason = "first"; while("first".equals(finishReason) || "tool_calls".equals(finishReason)){ finishReason = null; // 构造请求 ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(hunyuanChatCompletion); // 整理tools JSONObject jsonObject = JSON.parseObject(requestString); // 获取 Tools 数组 JSONArray toolsArray = jsonObject.getJSONArray("tools"); if(toolsArray!= null && !toolsArray.isEmpty()){ // 遍历并修改 Tools 中的每个对象 for (int i = 0; i < toolsArray.size(); i++) { JSONObject tool = toolsArray.getJSONObject(i); // 重新构建 Function 对象 JSONObject function = tool.getJSONObject("function"); JSONObject newFunction = new JSONObject(); newFunction.put("name", function.getString("name")); newFunction.put("description", function.getString("description")); newFunction.put("parameters", function.getJSONObject("parameters").toJSONString()); // 替换旧的 Function 对象 tool.put("function", newFunction); tool.put("type", "function"); } } /** * Messages 中 Contents 字段仅 hunyuan-vision 模型支持 * hunyuan模型,识图多模态类只能放在Contents字段 */ if("hunyuan-vision".equals(chatCompletion.getModel())){ // 获取所有的content字段 JSONArray messagesArray = jsonObject.getJSONArray("messages"); if(messagesArray!= null && !messagesArray.isEmpty()){ for (int i = 0; i < messagesArray.size(); i++) { JSONObject message = messagesArray.getJSONObject(i); // 获取当前message的content字段 String content = message.getString("content"); // 将content内容,判断是否可以转换为ChatMessage.MultiModal类型 if(content!=null && content.startsWith("[") && content.endsWith("]")) { List multiModals = JSON.parseArray(content, Content.MultiModal.class); if(multiModals!=null && !multiModals.isEmpty()){ // 将当前的content转换为contents message.put("contents", multiModals); // 删除原来的content key message.remove("content"); } } } } } // 将修改后的 JSON 对象转为字符串 requestString = jsonObject.toJSONString(); requestString = HunyuanJsonUtil.toCamelCaseWithUppercaseJson(requestString); String authorization = BearerTokenUtils.getAuthorization(apiKey,HunyuanConstant.ChatCompletions,requestString); Request request = new Request.Builder() .header("Authorization", authorization) .header("X-TC-Action", HunyuanConstant.ChatCompletions) .header("X-TC-Version", HunyuanConstant.Version) .header("X-TC-Timestamp", String.valueOf(System.currentTimeMillis() / 1000)) .header("Accept", Constants.SSE_CONTENT_TYPE) .url(baseUrl) .post(RequestBody.create(requestString, JSON_MEDIA_TYPE)) .build(); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); // 需要调用函数 if("tool_calls".equals(finishReason) && !toolCalls.isEmpty()){ if (passThroughToolCalls) { return; } // 创建tool响应消息 ChatMessage responseMessage = ChatMessage.withAssistant(eventSourceListener.getToolCalls()); responseMessage.setContent(Content.ofText(" ")); List messages = new ArrayList<>(hunyuanChatCompletion.getMessages()); messages.add(responseMessage); // 封装tool结果消息 for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } eventSourceListener.setToolCalls(new ArrayList<>()); eventSourceListener.setToolCall(null); hunyuanChatCompletion.setMessages(messages); } } // 补全原始请求 chatCompletion.setMessages(hunyuanChatCompletion.getMessages()); chatCompletion.setTools(hunyuanChatCompletion.getTools()); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/chat/entity/HunyuanChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.hunyuan.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.deepseek.chat.entity.DeepSeekChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.Singular; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * @Author cly * @Description 腾讯混元 chat请求实体类 * @Date 2024/8/30 19:26 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class HunyuanChatCompletion { private String model; private List messages; @Builder.Default private Boolean stream = false; /** * 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ @Builder.Default private Float temperature = 1f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @Builder.Default @JsonProperty("top_p") private Float topP = 1f; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; /** * 控制模型调用 tool 的行为。 * none 意味着模型不会调用任何 tool,而是生成一条消息。 * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。 * 当没有 tool 时,默认值为 none。如果有 tool 存在,默认值为 auto。 */ @JsonProperty("tool_choice") private String toolChoice; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class HunyuanChatCompletionBuilder { private List functions; public HunyuanChatCompletion.HunyuanChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public HunyuanChatCompletion.HunyuanChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(functions); return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/chat/entity/HunyuanChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.hunyuan.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description 腾讯混元响应实体类 * @Date 2024/8/30 19:27 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class HunyuanChatCompletionResponse { /** * Unix 时间戳,单位为秒。 */ private String created; /** * Token 统计信息。 * 按照总 Token 数量计费。 */ private Usage usage; /** * 免责声明。 * 示例值:以上内容为AI生成,不代表开发者立场,请勿删除或修改本标记 */ private String note; /** * 本次请求的 RequestId。 */ private String id; /** * 回复内容 */ private List choices; @JsonProperty("request_id") private String requestId; // 下面为额外补充 private String object; private String model; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/support/HunyuanJsonUtil.java ================================================ package io.github.lnyocly.ai4j.platform.hunyuan.support; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import java.util.Map; import java.util.Set; /** * @Author cly * @Description 用于JSON字符串驼峰转换的工具类 * @Date 2024/8/30 23:12 */ public final class HunyuanJsonUtil { // 将 JSON 字符串的字段转换为大写驼峰形式 public static String toCamelCaseWithUppercaseJson(String json) { return convertJsonString(json, true); } // 将 JSON 字符串的字段转换为下划线形式 public static String toSnakeCaseJson(String json) { return convertJsonString(json, false); } // 根据参数 isCamelCase 决定转换为驼峰命名还是下划线命名 private static String convertJsonString(String json, boolean isCamelCase) { JSONObject jsonObject = JSON.parseObject(json); JSONObject newJsonObject = processJsonObject(jsonObject, isCamelCase); return newJsonObject.toJSONString(); } private static JSONObject processJsonObject(JSONObject jsonObject, boolean isCamelCase) { JSONObject newJsonObject = new JSONObject(); Set> entries = jsonObject.entrySet(); for (Map.Entry entry : entries) { String newKey = isCamelCase ? toCamelCaseWithUppercase(entry.getKey()) : toSnakeCase(entry.getKey()); Object value = entry.getValue(); if (value instanceof JSONObject) { value = processJsonObject((JSONObject) value, isCamelCase); } else if (value instanceof JSONArray) { value = processJsonArray((JSONArray) value, isCamelCase); } newJsonObject.put(newKey, value); } return newJsonObject; } private static JSONArray processJsonArray(JSONArray jsonArray, boolean isCamelCase) { JSONArray newArray = new JSONArray(); for (Object element : jsonArray) { if (element instanceof JSONObject) { newArray.add(processJsonObject((JSONObject) element, isCamelCase)); } else if (element instanceof JSONArray) { newArray.add(processJsonArray((JSONArray) element, isCamelCase)); } else { newArray.add(element); } } return newArray; } private static String toCamelCaseWithUppercase(String key) { String[] parts = key.split("_"); StringBuilder camelCaseKey = new StringBuilder(); for (String part : parts) { if (part.length() > 0) { camelCaseKey.append(part.substring(0, 1).toUpperCase()) .append(part.substring(1).toLowerCase()); } } return camelCaseKey.toString(); } private static String toSnakeCase(String key) { StringBuilder result = new StringBuilder(); for (int i = 0; i < key.length(); i++) { char c = key.charAt(i); if (Character.isUpperCase(c)) { if (i > 0) { result.append("_"); } result.append(Character.toLowerCase(c)); } else { result.append(c); } } return result.toString(); } private HunyuanJsonUtil() { } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/jina/rerank/JinaRerankService.java ================================================ package io.github.lnyocly.ai4j.platform.jina.rerank; import io.github.lnyocly.ai4j.config.JinaConfig; import io.github.lnyocly.ai4j.platform.standard.rerank.StandardRerankService; import io.github.lnyocly.ai4j.service.Configuration; public class JinaRerankService extends StandardRerankService { public JinaRerankService(Configuration configuration) { this(configuration, configuration == null ? null : configuration.getJinaConfig()); } public JinaRerankService(Configuration configuration, JinaConfig jinaConfig) { super(configuration == null ? null : configuration.getOkHttpClient(), jinaConfig == null ? null : jinaConfig.getApiHost(), jinaConfig == null ? null : jinaConfig.getApiKey(), jinaConfig == null ? null : jinaConfig.getRerankUrl()); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/lingyi/chat/LingyiChatService.java ================================================ package io.github.lnyocly.ai4j.platform.lingyi.chat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.LingyiConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.lingyi.chat.entity.LingyiChatCompletion; import io.github.lnyocly.ai4j.platform.lingyi.chat.entity.LingyiChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description 零一万物 chat服务 * @Date 2024/9/9 23:00 */ public class LingyiChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final String TOOL_CALLS_FINISH_REASON = "tool_calls"; private static final String FIRST_FINISH_REASON = "first"; private final LingyiConfig lingyiConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public LingyiChatService(Configuration configuration) { this.lingyiConfig = configuration.getLingyiConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } public LingyiChatService(Configuration configuration, LingyiConfig lingyiConfig) { this.lingyiConfig = lingyiConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public LingyiChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { LingyiChatCompletion lingyiChatCompletion = new LingyiChatCompletion(); lingyiChatCompletion.setModel(chatCompletion.getModel()); lingyiChatCompletion.setMessages(chatCompletion.getMessages()); lingyiChatCompletion.setTools(chatCompletion.getTools()); lingyiChatCompletion.setFunctions(chatCompletion.getFunctions()); lingyiChatCompletion.setToolChoice(chatCompletion.getToolChoice()); lingyiChatCompletion.setTemperature(chatCompletion.getTemperature()); lingyiChatCompletion.setTopP(chatCompletion.getTopP()); lingyiChatCompletion.setStream(chatCompletion.getStream()); lingyiChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion)); lingyiChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return lingyiChatCompletion; } @Override public EventSourceListener convertEventSource(final SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(LingyiChatCompletionResponse lingyiChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(lingyiChatCompletionResponse.getId()); chatCompletionResponse.setObject(lingyiChatCompletionResponse.getObject()); chatCompletionResponse.setCreated(lingyiChatCompletionResponse.getCreated()); chatCompletionResponse.setModel(lingyiChatCompletionResponse.getModel()); chatCompletionResponse.setChoices(lingyiChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(lingyiChatCompletionResponse.getUsage()); return chatCompletionResponse; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, false); LingyiChatCompletion lingyiChatCompletion = convertChatCompletionObject(chatCompletion); Usage allUsage = new Usage(); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { LingyiChatCompletionResponse response = executeChatCompletionRequest( resolvedBaseUrl, resolvedApiKey, lingyiChatCompletion ); if (response == null) { break; } Choice choice = response.getChoices().get(0); finishReason = choice.getFinishReason(); mergeUsage(allUsage, response.getUsage()); if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) { if (passThroughToolCalls) { response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, lingyiChatCompletion); return convertChatCompletionResponse(response); } lingyiChatCompletion.setMessages(appendToolMessages( lingyiChatCompletion.getMessages(), choice.getMessage(), choice.getMessage().getToolCalls() )); continue; } response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, lingyiChatCompletion); return convertChatCompletionResponse(response); } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return this.chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, true); LingyiChatCompletion lingyiChatCompletion = convertChatCompletionObject(chatCompletion); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, lingyiChatCompletion); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) { continue; } if (passThroughToolCalls) { return; } lingyiChatCompletion.setMessages(appendStreamToolMessages( lingyiChatCompletion.getMessages(), toolCalls )); resetToolCallState(eventSourceListener); } restoreOriginalRequest(chatCompletion, lingyiChatCompletion); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } private String serializeStreamResponse(String data) { try { LingyiChatCompletionResponse chatCompletionResponse = objectMapper.readValue(data, LingyiChatCompletionResponse.class); ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse); return objectMapper.writeValueAsString(response); } catch (JsonProcessingException e) { throw new CommonException("Lingyi Chat 对象JSON序列化出错"); } } private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) { chatCompletion.setStream(stream); if (!stream) { chatCompletion.setStreamOptions(null); } attachFunctionTools(chatCompletion); } private void attachFunctionTools(ChatCompletion chatCompletion) { if (chatCompletion.getFunctions() == null || chatCompletion.getFunctions().isEmpty()) { return; } List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); chatCompletion.setTools(tools); } private boolean requiresFollowUp(String finishReason) { return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason); } private LingyiChatCompletionResponse executeChatCompletionRequest( String baseUrl, String apiKey, LingyiChatCompletion lingyiChatCompletion ) throws Exception { Request request = buildChatCompletionRequest(baseUrl, apiKey, lingyiChatCompletion); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), LingyiChatCompletionResponse.class); } } return null; } private Request buildChatCompletionRequest(String baseUrl, String apiKey, LingyiChatCompletion lingyiChatCompletion) throws JsonProcessingException { String requestBody = objectMapper.writeValueAsString(lingyiChatCompletion); return new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, lingyiConfig.getChatCompletionUrl())) .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) .build(); } private void mergeUsage(Usage target, Usage usage) { if (usage == null) { return; } target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens()); target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens()); target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens()); } private List appendToolMessages( List messages, ChatMessage assistantMessage, List toolCalls ) { List updatedMessages = new ArrayList(messages); updatedMessages.add(assistantMessage); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private List appendStreamToolMessages(List messages, List toolCalls) { List updatedMessages = new ArrayList(messages); updatedMessages.add(ChatMessage.withAssistant(toolCalls)); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private void appendToolResponses(List messages, List toolCalls) { for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } private void resetToolCallState(SseListener eventSourceListener) { eventSourceListener.setToolCalls(new ArrayList()); eventSourceListener.setToolCall(null); } private void restoreOriginalRequest(ChatCompletion chatCompletion, LingyiChatCompletion lingyiChatCompletion) { chatCompletion.setMessages(lingyiChatCompletion.getMessages()); chatCompletion.setTools(lingyiChatCompletion.getTools()); } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? lingyiConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? lingyiConfig.getApiKey() : apiKey; } @SuppressWarnings("deprecation") private Integer resolveMaxTokens(ChatCompletion chatCompletion) { if (chatCompletion.getMaxCompletionTokens() != null) { return chatCompletion.getMaxCompletionTokens(); } return chatCompletion.getMaxTokens(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/lingyi/chat/entity/LingyiChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.lingyi.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * @Author cly * @Description 零一万物对话请求实体 * @Date 2024/9/9 23:01 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class LingyiChatCompletion { @NonNull private String model; @NonNull private List messages; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; /** * 控制模型调用 tool 的行为。 * none 意味着模型不会调用任何 tool,而是生成一条消息。 * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。 * 当没有 tool 时,默认值为 none。如果有 tool 存在,默认值为 auto。 */ @JsonProperty("tool_choice") private String toolChoice; /** * 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ @Builder.Default private Float temperature = 1f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @Builder.Default @JsonProperty("top_p") private Float topP = 1f; /** * 如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾 */ @Builder.Default private Boolean stream = false; /** * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。 */ @JsonProperty("max_tokens") private Integer maxTokens; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class LingyiChatCompletionBuilder { private List functions; public LingyiChatCompletion.LingyiChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public LingyiChatCompletion.LingyiChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } if (functions != null) { this.functions.addAll(functions); } return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/lingyi/chat/entity/LingyiChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.lingyi.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description 零一万物对话响应实体 * @Date 2024/9/9 23:02 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class LingyiChatCompletionResponse { /** * 该对话的唯一标识符。 */ private String id; /** * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk */ private String object; /** * 创建聊天完成时的 Unix 时间戳(以秒为单位)。 */ private Long created; /** * 生成该 completion 的模型名。 */ private String model; /** * 模型生成的 completion 的选择列表。 */ private List choices; /** * 该对话补全请求的用量信息。 */ private Usage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/minimax/chat/MinimaxChatService.java ================================================ package io.github.lnyocly.ai4j.platform.minimax.chat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.MinimaxConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletion; import io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.Collections; /** * @Author : isxuwl * @Date: 2024/10/15 16:24 * @Model Description: * @Description: Minimax */ public class MinimaxChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final String TOOL_CALLS_FINISH_REASON = "tool_calls"; private static final String FIRST_FINISH_REASON = "first"; private final MinimaxConfig minimaxConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public MinimaxChatService(Configuration configuration) { this.minimaxConfig = configuration.getMinimaxConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } public MinimaxChatService(Configuration configuration, MinimaxConfig minimaxConfig) { this.minimaxConfig = minimaxConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public MinimaxChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { MinimaxChatCompletion minimaxChatCompletion = new MinimaxChatCompletion(); minimaxChatCompletion.setModel(chatCompletion.getModel()); minimaxChatCompletion.setMessages(chatCompletion.getMessages()); minimaxChatCompletion.setTools(chatCompletion.getTools()); minimaxChatCompletion.setFunctions(chatCompletion.getFunctions()); minimaxChatCompletion.setToolChoice(chatCompletion.getToolChoice()); minimaxChatCompletion.setTemperature(chatCompletion.getTemperature()); minimaxChatCompletion.setTopP(chatCompletion.getTopP()); minimaxChatCompletion.setStream(chatCompletion.getStream()); minimaxChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion)); minimaxChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return minimaxChatCompletion; } @Override public EventSourceListener convertEventSource(final SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(MinimaxChatCompletionResponse minimaxChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(minimaxChatCompletionResponse.getId()); chatCompletionResponse.setObject(minimaxChatCompletionResponse.getObject()); chatCompletionResponse.setCreated(minimaxChatCompletionResponse.getCreated()); chatCompletionResponse.setModel(minimaxChatCompletionResponse.getModel()); chatCompletionResponse.setChoices(minimaxChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(minimaxChatCompletionResponse.getUsage()); return chatCompletionResponse; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, false); MinimaxChatCompletion minimaxChatCompletion = convertChatCompletionObject(chatCompletion); Usage allUsage = new Usage(); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { MinimaxChatCompletionResponse response = executeChatCompletionRequest( resolvedBaseUrl, resolvedApiKey, minimaxChatCompletion ); if (response == null) { break; } List choices = response.getChoices(); if (choices == null || choices.isEmpty()) { response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, minimaxChatCompletion); return convertChatCompletionResponse(response); } Choice choice = choices.get(0); finishReason = choice.getFinishReason(); mergeUsage(allUsage, response.getUsage()); if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) { if (passThroughToolCalls) { response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, minimaxChatCompletion); return convertChatCompletionResponse(response); } minimaxChatCompletion.setMessages(appendToolMessages( minimaxChatCompletion.getMessages(), choice.getMessage(), choice.getMessage() == null ? Collections.emptyList() : choice.getMessage().getToolCalls() )); continue; } response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, minimaxChatCompletion); return convertChatCompletionResponse(response); } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return this.chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, true); MinimaxChatCompletion minimaxChatCompletion = convertChatCompletionObject(chatCompletion); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, minimaxChatCompletion); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) { continue; } if (passThroughToolCalls) { return; } minimaxChatCompletion.setMessages(appendStreamToolMessages( minimaxChatCompletion.getMessages(), toolCalls )); resetToolCallState(eventSourceListener); } restoreOriginalRequest(chatCompletion, minimaxChatCompletion); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } private String serializeStreamResponse(String data) { try { MinimaxChatCompletionResponse chatCompletionResponse = objectMapper.readValue(data, MinimaxChatCompletionResponse.class); ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse); return objectMapper.writeValueAsString(response); } catch (JsonProcessingException e) { throw new CommonException("Minimax Chat 对象JSON序列化出错"); } } private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) { chatCompletion.setStream(stream); if (!stream) { chatCompletion.setStreamOptions(null); } attachTools(chatCompletion); } private void attachTools(ChatCompletion chatCompletion) { if (hasPendingTools(chatCompletion)) { List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if (tools == null) { chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) { chatCompletion.setParallelToolCalls(null); } } private boolean hasPendingTools(ChatCompletion chatCompletion) { return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty()); } private boolean requiresFollowUp(String finishReason) { return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason); } private MinimaxChatCompletionResponse executeChatCompletionRequest( String baseUrl, String apiKey, MinimaxChatCompletion minimaxChatCompletion ) throws Exception { Request request = buildChatCompletionRequest(baseUrl, apiKey, minimaxChatCompletion); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), MinimaxChatCompletionResponse.class); } } return null; } private Request buildChatCompletionRequest(String baseUrl, String apiKey, MinimaxChatCompletion minimaxChatCompletion) throws JsonProcessingException { String requestBody = objectMapper.writeValueAsString(minimaxChatCompletion); return new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, minimaxConfig.getChatCompletionUrl())) .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) .build(); } private void mergeUsage(Usage target, Usage usage) { if (usage == null) { return; } target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens()); target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens()); target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens()); } private List appendToolMessages( List messages, ChatMessage assistantMessage, List toolCalls ) { List updatedMessages = new ArrayList(messages); updatedMessages.add(assistantMessage); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private List appendStreamToolMessages(List messages, List toolCalls) { List updatedMessages = new ArrayList(messages); updatedMessages.add(ChatMessage.withAssistant(toolCalls)); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private void appendToolResponses(List messages, List toolCalls) { for (ToolCall toolCall : toolCalls) { if (toolCall == null || toolCall.getFunction() == null) { continue; } String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } private void resetToolCallState(SseListener eventSourceListener) { eventSourceListener.setToolCalls(new ArrayList()); eventSourceListener.setToolCall(null); } private void restoreOriginalRequest(ChatCompletion chatCompletion, MinimaxChatCompletion minimaxChatCompletion) { chatCompletion.setMessages(minimaxChatCompletion.getMessages()); chatCompletion.setTools(minimaxChatCompletion.getTools()); } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? minimaxConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? minimaxConfig.getApiKey() : apiKey; } @SuppressWarnings("deprecation") private Integer resolveMaxTokens(ChatCompletion chatCompletion) { if (chatCompletion.getMaxCompletionTokens() != null) { return chatCompletion.getMaxCompletionTokens(); } return chatCompletion.getMaxTokens(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/minimax/chat/entity/MinimaxChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.minimax.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * @Author : isxuwl * @Date: 2024/10/15 16:24 * @Model Description: * @Description: Minimax对话请求实体 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class MinimaxChatCompletion { @NonNull private String model; @NonNull private List messages; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; /** * 控制模型调用 tool 的行为。 * none 意味着模型不会调用任何 tool,而是生成一条消息。 * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。 * 当没有 tool 时,默认值为 none。如果有 tool 存在,默认值为 auto。 */ @JsonProperty("tool_choice") private String toolChoice; /** * 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ @Builder.Default private Float temperature = 1f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @Builder.Default @JsonProperty("top_p") private Float topP = 1f; /** * 如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾 */ @Builder.Default private Boolean stream = false; /** * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。 */ @JsonProperty("max_tokens") private Integer maxTokens; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class MinimaxChatCompletionBuilder { private List functions; public MinimaxChatCompletion.MinimaxChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public MinimaxChatCompletion.MinimaxChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } if (functions != null) { this.functions.addAll(functions); } return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/minimax/chat/entity/MinimaxChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.minimax.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author : isxuwl * @Date: 2024/10/15 16:24 * @Model Description: * @Description: Minimax对话响应实体 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class MinimaxChatCompletionResponse { /** * 该对话的唯一标识符。 */ private String id; /** * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk */ private String object; /** * 创建聊天完成时的 Unix 时间戳(以秒为单位)。 */ private Long created; /** * 生成该 completion 的模型名。 */ private String model; /** * 模型生成的 completion 的选择列表。 */ private List choices; /** * 该对话补全请求的用量信息。 */ private Usage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/moonshot/chat/MoonshotChatService.java ================================================ package io.github.lnyocly.ai4j.platform.moonshot.chat; import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONPath; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.MoonshotConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.moonshot.chat.entity.MoonshotChatCompletion; import io.github.lnyocly.ai4j.platform.moonshot.chat.entity.MoonshotChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description 月之暗面请求服务 * @Date 2024/8/29 23:12 */ public class MoonshotChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final String TOOL_CALLS_FINISH_REASON = "tool_calls"; private static final String FIRST_FINISH_REASON = "first"; private final MoonshotConfig moonshotConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public MoonshotChatService(Configuration configuration) { this.moonshotConfig = configuration.getMoonshotConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } public MoonshotChatService(Configuration configuration, MoonshotConfig moonshotConfig) { this.moonshotConfig = moonshotConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public MoonshotChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { MoonshotChatCompletion moonshotChatCompletion = new MoonshotChatCompletion(); moonshotChatCompletion.setModel(chatCompletion.getModel()); moonshotChatCompletion.setMessages(chatCompletion.getMessages()); moonshotChatCompletion.setFrequencyPenalty(chatCompletion.getFrequencyPenalty()); moonshotChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion)); moonshotChatCompletion.setPresencePenalty(chatCompletion.getPresencePenalty()); moonshotChatCompletion.setResponseFormat(chatCompletion.getResponseFormat()); moonshotChatCompletion.setStop(chatCompletion.getStop()); moonshotChatCompletion.setStream(chatCompletion.getStream()); moonshotChatCompletion.setTemperature(chatCompletion.getTemperature() / 2); moonshotChatCompletion.setTopP(chatCompletion.getTopP()); moonshotChatCompletion.setTools(chatCompletion.getTools()); moonshotChatCompletion.setFunctions(chatCompletion.getFunctions()); moonshotChatCompletion.setToolChoice(chatCompletion.getToolChoice()); moonshotChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return moonshotChatCompletion; } @Override public EventSourceListener convertEventSource(final SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(MoonshotChatCompletionResponse moonshotChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(moonshotChatCompletionResponse.getId()); chatCompletionResponse.setObject(moonshotChatCompletionResponse.getObject()); chatCompletionResponse.setCreated(moonshotChatCompletionResponse.getCreated()); chatCompletionResponse.setModel(moonshotChatCompletionResponse.getModel()); chatCompletionResponse.setChoices(moonshotChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(moonshotChatCompletionResponse.getUsage()); return chatCompletionResponse; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, false); MoonshotChatCompletion moonshotChatCompletion = convertChatCompletionObject(chatCompletion); Usage allUsage = new Usage(); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { MoonshotChatCompletionResponse response = executeChatCompletionRequest( resolvedBaseUrl, resolvedApiKey, moonshotChatCompletion ); if (response == null) { break; } Choice choice = response.getChoices().get(0); finishReason = choice.getFinishReason(); mergeUsage(allUsage, response.getUsage()); if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) { if (passThroughToolCalls) { response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, moonshotChatCompletion); return convertChatCompletionResponse(response); } moonshotChatCompletion.setMessages(appendToolMessages( moonshotChatCompletion.getMessages(), choice.getMessage(), choice.getMessage().getToolCalls() )); continue; } response.setUsage(allUsage); restoreOriginalRequest(chatCompletion, moonshotChatCompletion); return convertChatCompletionResponse(response); } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return this.chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, true); MoonshotChatCompletion moonshotChatCompletion = convertChatCompletionObject(chatCompletion); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, moonshotChatCompletion); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) { continue; } if (passThroughToolCalls) { return; } moonshotChatCompletion.setMessages(appendStreamToolMessages( moonshotChatCompletion.getMessages(), toolCalls )); resetToolCallState(eventSourceListener); } restoreOriginalRequest(chatCompletion, moonshotChatCompletion); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } private String serializeStreamResponse(String data) { try { MoonshotChatCompletionResponse chatCompletionResponse = objectMapper.readValue(data, MoonshotChatCompletionResponse.class); ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse); Usage usage = extractStreamUsage(data); if (usage != null) { response.setUsage(usage); } return objectMapper.writeValueAsString(response); } catch (JsonProcessingException e) { throw new CommonException("Moonshot Chat 对象JSON序列化出错"); } } private Usage extractStreamUsage(String data) { JSONObject object = (JSONObject) JSONPath.eval(data, "$.choices[0].usage"); if (object == null) { return null; } return object.toJavaObject(Usage.class); } private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) { chatCompletion.setStream(stream); if (!stream) { chatCompletion.setStreamOptions(null); } attachTools(chatCompletion); } private void attachTools(ChatCompletion chatCompletion) { if (hasPendingTools(chatCompletion)) { List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if (tools == null) { chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) { chatCompletion.setParallelToolCalls(null); } } private boolean hasPendingTools(ChatCompletion chatCompletion) { return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty()); } private boolean requiresFollowUp(String finishReason) { return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason); } private MoonshotChatCompletionResponse executeChatCompletionRequest( String baseUrl, String apiKey, MoonshotChatCompletion moonshotChatCompletion ) throws Exception { Request request = buildChatCompletionRequest(baseUrl, apiKey, moonshotChatCompletion); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), MoonshotChatCompletionResponse.class); } } return null; } private Request buildChatCompletionRequest(String baseUrl, String apiKey, MoonshotChatCompletion moonshotChatCompletion) throws JsonProcessingException { String requestBody = objectMapper.writeValueAsString(moonshotChatCompletion); return new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, moonshotConfig.getChatCompletionUrl())) .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) .build(); } private void mergeUsage(Usage target, Usage usage) { if (usage == null) { return; } target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens()); target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens()); target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens()); } private List appendToolMessages( List messages, ChatMessage assistantMessage, List toolCalls ) { List updatedMessages = new ArrayList(messages); updatedMessages.add(assistantMessage); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private List appendStreamToolMessages(List messages, List toolCalls) { List updatedMessages = new ArrayList(messages); updatedMessages.add(ChatMessage.withAssistant(toolCalls)); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private void appendToolResponses(List messages, List toolCalls) { for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } private void resetToolCallState(SseListener eventSourceListener) { eventSourceListener.setToolCalls(new ArrayList()); eventSourceListener.setToolCall(null); } private void restoreOriginalRequest(ChatCompletion chatCompletion, MoonshotChatCompletion moonshotChatCompletion) { chatCompletion.setMessages(moonshotChatCompletion.getMessages()); chatCompletion.setTools(moonshotChatCompletion.getTools()); } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? moonshotConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? moonshotConfig.getApiKey() : apiKey; } @SuppressWarnings("deprecation") private Integer resolveMaxTokens(ChatCompletion chatCompletion) { if (chatCompletion.getMaxCompletionTokens() != null) { return chatCompletion.getMaxCompletionTokens(); } return chatCompletion.getMaxTokens(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/moonshot/chat/entity/MoonshotChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.moonshot.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * @Author cly * @Description 月之暗面对话请求实体 * @Date 2024/8/29 23:13 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class MoonshotChatCompletion { @NonNull private String model; @NonNull private List messages; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。 */ @Builder.Default @JsonProperty("frequency_penalty") private Float frequencyPenalty = 0f; /** * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。 */ @JsonProperty("max_tokens") private Integer maxTokens; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。 */ @Builder.Default @JsonProperty("presence_penalty") private Float presencePenalty = 0f; /** * 一个 object,指定模型必须输出的格式。 * * 设置为 { "type": "json_object" } 以启用 JSON 模式,该模式保证模型生成的消息是有效的 JSON。 * * 注意: 使用 JSON 模式时,你还必须通过系统或用户消息指示模型生成 JSON。 * 否则,模型可能会生成不断的空白字符,直到生成达到令牌限制,从而导致请求长时间运行并显得“卡住”。 * 此外,如果 finish_reason="length",这表示生成超过了 max_tokens 或对话超过了最大上下文长度,消息内容可能会被部分截断。 */ @JsonProperty("response_format") private Object responseFormat; /** * 在遇到这些词时,API 将停止生成更多的 token。 */ private List stop; /** * 如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾 */ @Builder.Default private Boolean stream = false; /** * 采样温度,介于 0 和 1 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ @Builder.Default private Float temperature = 0.3f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @Builder.Default @JsonProperty("top_p") private Float topP = 1f; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; /** * 控制模型调用 tool 的行为。 * none 意味着模型不会调用任何 tool,而是生成一条消息。 * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。 * 当没有 tool 时,默认值为 none。如果有 tool 存在,默认值为 auto。 */ @JsonProperty("tool_choice") private String toolChoice; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class DeepSeekChatCompletionBuilder { private List functions; public MoonshotChatCompletion.DeepSeekChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public MoonshotChatCompletion.DeepSeekChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(functions); return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/moonshot/chat/entity/MoonshotChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.moonshot.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description 月之暗面对话响应实体 * @Date 2024/8/29 10:28 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class MoonshotChatCompletionResponse { /** * 该对话的唯一标识符。 */ private String id; /** * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk */ private String object; /** * 创建聊天完成时的 Unix 时间戳(以秒为单位)。 */ private Long created; /** * 生成该 completion 的模型名。 */ private String model; /** * 模型生成的 completion 的选择列表。 */ private List choices; /** * 该对话补全请求的用量信息。 */ private Usage usage; /** * 该指纹代表模型运行时使用的后端配置。 */ @JsonProperty("system_fingerprint") private String systemFingerprint; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/OllamaAiChatService.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.chat; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.OllamaConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaChatCompletion; import io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaChatCompletionResponse; import io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaMessage; import io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaOptions; import io.github.lnyocly.ai4j.platform.openai.chat.entity.*; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.*; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; /** * @Author cly * @Description Ollama Ai聊天对话服务 * @Date 2024/9/20 0:00 */ public class OllamaAiChatService implements IChatService, ParameterConvert, ResultConvert { private final OllamaConfig ollamaConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; public OllamaAiChatService(Configuration configuration) { this.ollamaConfig = configuration.getOllamaConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } public OllamaAiChatService(Configuration configuration, OllamaConfig ollamaConfig) { this.ollamaConfig = ollamaConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } @Override public OllamaChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { OllamaChatCompletion ollamaChatCompletion = new OllamaChatCompletion(); ollamaChatCompletion.setModel(chatCompletion.getModel()); ollamaChatCompletion.setTools(chatCompletion.getTools()); ollamaChatCompletion.setFunctions(chatCompletion.getFunctions()); ollamaChatCompletion.setStream(chatCompletion.getStream()); OllamaOptions ollamaOptions = new OllamaOptions(); ollamaOptions.setTemperature(chatCompletion.getTemperature()); ollamaOptions.setTopP(chatCompletion.getTopP()); ollamaOptions.setStop(chatCompletion.getStop()); ollamaChatCompletion.setOptions(ollamaOptions); List messages = new ArrayList<>(); for (ChatMessage chatMessage : chatCompletion.getMessages()) { OllamaMessage ollamaMessage = new OllamaMessage(); ollamaMessage.setRole(chatMessage.getRole()); String content = chatMessage.getContent().getText(); if (content != null){ // 普通消息 ollamaMessage.setContent(content); }else if (chatMessage.getContent().getMultiModals() != null){ List multiModals = chatMessage.getContent().getMultiModals(); if(multiModals!=null && !multiModals.isEmpty()){ List images = new ArrayList<>(); for (Content.MultiModal multiModal : multiModals) { String text = multiModal.getText(); Content.MultiModal.ImageUrl imageUrl = multiModal.getImageUrl(); if(imageUrl!=null) images.add(imageUrl.getUrl()); if(StringUtils.isNotBlank(text)) ollamaMessage.setContent(text); } ollamaMessage.setImages(images); } } // 设置toolcalls ollamaMessage.setToolCalls(chatMessage.getToolCalls()); messages.add(ollamaMessage); } ollamaChatCompletion.setMessages(messages); ollamaChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return ollamaChatCompletion; } public List ollamaMessagesToChatMessages(List ollamaMessages){ List chatMessages = new ArrayList<>(); for (OllamaMessage ollamaMessage : ollamaMessages) { chatMessages.add(ollamaMessageToChatMessage(ollamaMessage)); } return chatMessages; } public ChatMessage ollamaMessageToChatMessage(OllamaMessage ollamaMessage){ String role = ollamaMessage.getRole(); List toolCalls = ollamaMessage.getToolCalls(); if(ChatMessageType.USER.getRole().equals(role)){ if(ollamaMessage.getImages()!=null && !ollamaMessage.getImages().isEmpty()){ // 多模态 return ChatMessage.withUser(ollamaMessage.getContent(), ollamaMessage.getImages().toArray(new String[0])); }else{ return ChatMessage.withUser(ollamaMessage.getContent()); } } else if (ChatMessageType.ASSISTANT.getRole().equals(role)) { if(toolCalls!=null && !toolCalls.isEmpty()) { // tool调用 for (ToolCall toolCall : toolCalls) { toolCall.setType("function"); toolCall.setId(UUID.randomUUID().toString()); } return ChatMessage.withAssistant(ollamaMessage.getContent(), toolCalls); }else{ return ChatMessage.withAssistant(ollamaMessage.getContent()); } }else{ // system和tool消息 return new ChatMessage(role,ollamaMessage.getContent()); } } @Override public EventSourceListener convertEventSource(SseListener eventSourceListener) { final AtomicBoolean isThinking = new AtomicBoolean(false); return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } OllamaChatCompletionResponse ollamaChatCompletionResponse = JSON.parseObject(data, OllamaChatCompletionResponse.class); OllamaMessage message = ollamaChatCompletionResponse.getMessage(); String content = message != null ? message.getContent() : null; String thinking = message != null ? message.getThinking() : null; // 1. 处理 thinking 字段(Ollama Qwen 模式) // thinking 字段有值时,直接映射到 reasoning_content if (StringUtils.isNotEmpty(thinking)) { ChatCompletionResponse response = convertChatCompletionResponse(ollamaChatCompletionResponse); if (response.getChoices() != null && !response.getChoices().isEmpty()) { ChatMessage delta = response.getChoices().get(0).getDelta(); delta.setReasoningContent(thinking); delta.setContent(null); } sendConvertedResponse(eventSourceListener, eventSource, id, type, response); return; } // 2. 处理 标签(Ollama DeepSeek 模式) // 检测到 标签时,标记进入思考模式,不传递标签本身 if ("".equals(content)) { isThinking.set(true); return; } // 检测到 标签时,标记退出思考模式,不传递标签本身 if ("".equals(content)) { isThinking.set(false); return; } // 3. 转换为 OpenAI 格式 ChatCompletionResponse response = convertChatCompletionResponse(ollamaChatCompletionResponse); // 4. 如果处于思考模式,将 content 转换为 reasoning_content if (isThinking.get() && StringUtils.isNotEmpty(content)) { if (response.getChoices() != null && !response.getChoices().isEmpty()) { ChatMessage delta = response.getChoices().get(0).getDelta(); delta.setReasoningContent(content); delta.setContent(null); } } sendConvertedResponse(eventSourceListener, eventSource, id, type, response); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } /** * 发送转换后的响应给 SseListener */ private void sendConvertedResponse(SseListener listener, EventSource eventSource, String id, String type, ChatCompletionResponse response) { ObjectMapper mapper = new ObjectMapper(); try { String s = mapper.writeValueAsString(response); listener.onEvent(eventSource, id, type, s); } catch (JsonProcessingException e) { throw new CommonException("Ollama Chat Completion Response convert to JSON error"); } } @Override public ChatCompletionResponse convertChatCompletionResponse(OllamaChatCompletionResponse ollamaChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setModel(ollamaChatCompletionResponse.getModel()); chatCompletionResponse.setId(UUID.randomUUID().toString()); chatCompletionResponse.setObject("chat.completion"); Instant instant = Instant.parse(ollamaChatCompletionResponse.getCreatedAt()); long created = instant.getEpochSecond(); chatCompletionResponse.setCreated(created); Usage usage = new Usage(); usage.setCompletionTokens(ollamaChatCompletionResponse.getEvalCount()); usage.setPromptTokens(ollamaChatCompletionResponse.getPromptEvalCount()); usage.setTotalTokens(ollamaChatCompletionResponse.getEvalCount() + ollamaChatCompletionResponse.getPromptEvalCount()); chatCompletionResponse.setUsage(usage); ChatMessage chatMessage = ollamaMessageToChatMessage(ollamaChatCompletionResponse.getMessage()); List choices = new ArrayList<>(1); Choice choice = new Choice(); choice.setFinishReason(ollamaChatCompletionResponse.getDoneReason()); choice.setIndex(0); choice.setMessage(chatMessage); choice.setDelta(chatMessage); choices.add(choice); chatCompletionResponse.setChoices(choices); return chatCompletionResponse; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if(baseUrl == null || "".equals(baseUrl)) baseUrl = ollamaConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = ollamaConfig.getApiKey(); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); chatCompletion.setStream(false); chatCompletion.setStreamOptions(null); if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){ //List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if(tools == null){ chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){ }else{ chatCompletion.setParallelToolCalls(null); } // 转换 请求参数 OllamaChatCompletion ollamaChatCompletion = this.convertChatCompletionObject(chatCompletion); /* // 如含有function,则添加tool if(ollamaChatCompletion.getFunctions()!=null && !ollamaChatCompletion.getFunctions().isEmpty()){ List tools = ToolUtil.getAllFunctionTools(ollamaChatCompletion.getFunctions()); ollamaChatCompletion.setTools(tools); }*/ // 总token消耗 Usage allUsage = new Usage(); String finishReason = "first"; while("first".equals(finishReason) || "tool_calls".equals(finishReason)){ finishReason = null; // 构造请求 String requestString = JSON.toJSONString(ollamaChatCompletion); JSONObject jsonObject = JSON.parseObject(requestString); // 展开 extraBody 到顶层 JSONObject extraBody = jsonObject.getJSONObject("extraBody"); if (extraBody != null) { for (String key : extraBody.keySet()) { jsonObject.put(key, extraBody.get(key)); } jsonObject.remove("extraBody"); } // 遍历jsonObject的messages JSONArray jsonArrayMessages = jsonObject.getJSONArray("messages"); for (Object message : jsonArrayMessages) { JSONObject messageObject = (JSONObject) message; JSONArray toolCalls = messageObject.getJSONArray("tool_calls"); if(toolCalls!=null && !toolCalls.isEmpty()){ for (Object toolCall : toolCalls) { // 遍历toolCall中的function中的arguments,将arguments(JSON String)转为对象(JSON Object) JSONObject toolCallObject = (JSONObject) toolCall; JSONObject function = toolCallObject.getJSONObject("function"); String arguments = function.getString("arguments"); JSONObject argumentsObject = JSON.parseObject(arguments); function.remove("arguments"); function.put("arguments", argumentsObject); } } } requestString = JSON.toJSONString(jsonObject); Request.Builder builder = new Request.Builder() .url(UrlUtils.concatUrl(baseUrl, ollamaConfig.getChatCompletionUrl())) .post(RequestBody.create(requestString, MediaType.get(Constants.JSON_CONTENT_TYPE))); if(StringUtils.isNotBlank(apiKey)) { builder.header("Authorization", "Bearer " + apiKey); } Request request = builder.build(); Response execute = okHttpClient.newCall(request).execute(); if (execute.isSuccessful() && execute.body() != null){ OllamaChatCompletionResponse ollamaChatCompletionResponse = JSON.parseObject(execute.body().string(), OllamaChatCompletionResponse.class); finishReason = ollamaChatCompletionResponse.getDoneReason(); allUsage.setCompletionTokens(allUsage.getCompletionTokens() + ollamaChatCompletionResponse.getEvalCount()); allUsage.setTotalTokens(allUsage.getTotalTokens() + ollamaChatCompletionResponse.getEvalCount() + ollamaChatCompletionResponse.getPromptEvalCount()); allUsage.setPromptTokens(allUsage.getPromptTokens() + ollamaChatCompletionResponse.getPromptEvalCount()); List functions = ollamaChatCompletionResponse.getMessage().getToolCalls(); if(functions!=null && !functions.isEmpty()){ finishReason = "tool_calls"; } // 判断是否为函数调用返回 if("tool_calls".equals(finishReason)){ if (passThroughToolCalls) { ollamaChatCompletionResponse.setDoneReason("tool_calls"); ollamaChatCompletionResponse.setEvalCount(allUsage.getCompletionTokens()); ollamaChatCompletionResponse.setPromptEvalCount(allUsage.getPromptTokens()); return this.convertChatCompletionResponse(ollamaChatCompletionResponse); } OllamaMessage message = ollamaChatCompletionResponse.getMessage(); List toolCalls = message.getToolCalls(); List messages = new ArrayList<>(ollamaChatCompletion.getMessages()); messages.add(message); // 添加 tool 消息 for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); OllamaMessage ollamaMessage = new OllamaMessage(); ollamaMessage.setRole("tool"); ollamaMessage.setContent(functionResponse); messages.add(ollamaMessage); } ollamaChatCompletion.setMessages(messages); }else{// 其他情况直接返回 // 设置包含tool的总token数 ollamaChatCompletionResponse.setEvalCount(allUsage.getCompletionTokens()); ollamaChatCompletionResponse.setPromptEvalCount(allUsage.getPromptTokens()); // 恢复原始请求数据 chatCompletion.setMessages(ollamaMessagesToChatMessages(ollamaChatCompletion.getMessages())); chatCompletion.setTools(ollamaChatCompletion.getTools()); return this.convertChatCompletionResponse(ollamaChatCompletionResponse); } } } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return this.chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if(baseUrl == null || "".equals(baseUrl)) baseUrl = ollamaConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = ollamaConfig.getApiKey(); chatCompletion.setStream(true); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){ //List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if(tools == null){ chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){ }else{ chatCompletion.setParallelToolCalls(null); } // 转换 请求参数 OllamaChatCompletion ollamaChatCompletion = this.convertChatCompletionObject(chatCompletion); /* // 如含有function,则添加tool if(ollamaChatCompletion.getFunctions()!=null && !ollamaChatCompletion.getFunctions().isEmpty()){ List tools = ToolUtil.getAllFunctionTools(ollamaChatCompletion.getFunctions()); ollamaChatCompletion.setTools(tools); }*/ String finishReason = "first"; while("first".equals(finishReason) || "tool_calls".equals(finishReason)){ finishReason = null; // 构造请求 JSON.toJSONString(ollamaChatCompletion); ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(ollamaChatCompletion); JSONObject jsonObject = JSON.parseObject(requestString); // 遍历jsonObject的messages JSONArray jsonArrayMessages = jsonObject.getJSONArray("messages"); for (Object message : jsonArrayMessages) { JSONObject messageObject = (JSONObject) message; JSONArray toolCalls = messageObject.getJSONArray("tool_calls"); if(toolCalls!=null && !toolCalls.isEmpty()){ for (Object toolCall : toolCalls) { // 遍历toolCall中的function中的arguments,将arguments(JSON String)转为对象(JSON Object) JSONObject toolCallObject = (JSONObject) toolCall; JSONObject function = toolCallObject.getJSONObject("function"); String arguments = function.getString("arguments"); JSONObject argumentsObject = JSON.parseObject(arguments); function.remove("arguments"); function.put("arguments", argumentsObject); } } } requestString = JSON.toJSONString(jsonObject); Request.Builder builder = new Request.Builder() .url(UrlUtils.concatUrl(baseUrl, ollamaConfig.getChatCompletionUrl())) .post(RequestBody.create(requestString, MediaType.get(Constants.JSON_CONTENT_TYPE))); if(StringUtils.isNotBlank(apiKey)) { builder.header("Authorization", "Bearer " + apiKey); } Request request = builder.build(); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); // 需要调用函数 if("tool_calls".equals(finishReason) && !toolCalls.isEmpty()){ if (passThroughToolCalls) { return; } // 创建tool响应消息 OllamaMessage responseMessage = new OllamaMessage(); responseMessage.setRole(ChatMessageType.ASSISTANT.getRole()); responseMessage.setToolCalls(eventSourceListener.getToolCalls()); List messages = new ArrayList<>(ollamaChatCompletion.getMessages()); messages.add(responseMessage); // 封装tool结果消息 for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); OllamaMessage ollamaMessage = new OllamaMessage(); ollamaMessage.setRole("tool"); ollamaMessage.setContent(functionResponse); messages.add(ollamaMessage); } eventSourceListener.setToolCalls(new ArrayList<>()); eventSourceListener.setToolCall(null); ollamaChatCompletion.setMessages(messages); } } // 补全原始请求 chatCompletion.setMessages(ollamaMessagesToChatMessages(ollamaChatCompletion.getMessages())); chatCompletion.setTools(ollamaChatCompletion.getTools()); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.*; import java.util.List; import java.util.Map; /** * @Author cly * @Description Ollama对话请求实体 * @Date 2024/9/20 0:19 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class OllamaChatCompletion { @NonNull private String model; @NonNull private List messages; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 * 使用tools时,需要设置stream为false */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; private OllamaOptions options; private Boolean stream; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description ollama对话响应实体 * @Date 2024/9/20 0:03 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class OllamaChatCompletionResponse { private String model; @JsonProperty("created_at") private String createdAt; private OllamaMessage message; @JsonProperty("done_reason") private String doneReason; private Boolean done; @JsonProperty("total_duration") private long totalDuration; @JsonProperty("load_duration") private long loadDuration; @JsonProperty("prompt_eval_count") private long promptEvalCount; @JsonProperty("prompt_eval_duration") private long promptEvalDuration; @JsonProperty("eval_count") private long evalCount; @JsonProperty("eval_duration") private long evalDuration; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaMessage.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.chat.entity; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/9/20 0:25 */ @Data @AllArgsConstructor @NoArgsConstructor public class OllamaMessage { private String role; private String content; private List images; /** * Ollama Qwen 模型的思考内容字段 * 该字段会在 Converter 层映射到 OpenAI 格式的 reasoning_content */ private String thinking; @JsonProperty("tool_calls") private List toolCalls; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaOptions.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.chat.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/9/20 0:30 */ @Data @AllArgsConstructor @NoArgsConstructor public class OllamaOptions { /** * 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ private Float temperature = 0.8f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @JsonProperty("top_p") private Float topP = 0.9f; private List stop; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/embedding/OllamaEmbeddingService.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.embedding; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.OllamaConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.embedding.EmbeddingParameterConvert; import io.github.lnyocly.ai4j.convert.embedding.EmbeddingResultConvert; import io.github.lnyocly.ai4j.platform.ollama.embedding.entity.OllamaEmbedding; import io.github.lnyocly.ai4j.platform.ollama.embedding.entity.OllamaEmbeddingResponse; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.*; import org.apache.commons.lang3.StringUtils; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description TODO * @Date 2025/2/28 15:52 */ public class OllamaEmbeddingService implements IEmbeddingService, EmbeddingParameterConvert, EmbeddingResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final OllamaConfig ollamaConfig; private final OkHttpClient okHttpClient; public OllamaEmbeddingService(Configuration configuration) { this.ollamaConfig = configuration.getOllamaConfig(); this.okHttpClient = configuration.getOkHttpClient(); } public OllamaEmbeddingService(Configuration configuration, OllamaConfig ollamaConfig) { this.ollamaConfig = ollamaConfig; this.okHttpClient = configuration.getOkHttpClient(); } @Override public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) throws Exception { if(baseUrl == null || "".equals(baseUrl)) baseUrl = ollamaConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = ollamaConfig.getApiKey(); String jsonString = JSON.toJSONString(convertEmbeddingRequest(embeddingReq)); Request.Builder builder = new Request.Builder() .url(UrlUtils.concatUrl(baseUrl, ollamaConfig.getEmbeddingUrl())) .post(RequestBody.create(jsonString, JSON_MEDIA_TYPE)); if(StringUtils.isNotBlank(apiKey)) { builder.header("Authorization", "Bearer " + apiKey); } Request request = builder.build(); Response execute = okHttpClient.newCall(request).execute(); if (execute.isSuccessful() && execute.body() != null) { OllamaEmbeddingResponse ollamaEmbeddingResponse = JSON.parseObject(execute.body().string(), OllamaEmbeddingResponse.class); return convertEmbeddingResponse(ollamaEmbeddingResponse); } return null; } @Override public EmbeddingResponse embedding(Embedding embeddingReq) throws Exception { return this.embedding(null, null, embeddingReq); } @Override public OllamaEmbedding convertEmbeddingRequest(Embedding embeddingRequest) { Object input = embeddingRequest.getInput(); if (input instanceof List) { return OllamaEmbedding.builder() .model(embeddingRequest.getModel()) .input(castStringList(input)) .build(); } return OllamaEmbedding.builder() .model(embeddingRequest.getModel()) .input((String) input) .build(); } @Override public EmbeddingResponse convertEmbeddingResponse(OllamaEmbeddingResponse ollamaEmbeddingResponse) { EmbeddingResponse.EmbeddingResponseBuilder builder = EmbeddingResponse.builder() .model(ollamaEmbeddingResponse.getModel()) .object("list") .usage(new Usage(ollamaEmbeddingResponse.getPromptEvalCount(), 0, ollamaEmbeddingResponse.getPromptEvalCount())); List embeddingObjects = new ArrayList<>(); List> embeddings = ollamaEmbeddingResponse.getEmbeddings(); for (int i = 0; i < embeddings.size(); i++) { EmbeddingObject embeddingObject = new EmbeddingObject(); embeddingObject.setIndex(i); embeddingObject.setEmbedding(embeddings.get(i)); embeddingObject.setObject("embedding"); embeddingObjects.add(embeddingObject); } builder.data(embeddingObjects); return builder.build(); } @SuppressWarnings("unchecked") private List castStringList(Object input) { return (List) input; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/embedding/entity/OllamaEmbedding.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.embedding.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.*; import java.util.List; /** * @Author cly * @Description TODO * @Date 2025/2/28 18:01 */ @Data @Builder @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class OllamaEmbedding { /** * 向量化文本 */ @NonNull private Object input; /** * 向量模型 */ @NonNull private String model; public static class OllamaEmbeddingBuilder { private Object input; private OllamaEmbedding.OllamaEmbeddingBuilder input(Object input){ this.input = input; return this; } public OllamaEmbedding.OllamaEmbeddingBuilder input(String input){ this.input = input; return this; } public OllamaEmbedding.OllamaEmbeddingBuilder input(List content){ this.input = content; return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/embedding/entity/OllamaEmbeddingResponse.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.embedding.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2025/2/28 18:03 */ @Data @NoArgsConstructor() @AllArgsConstructor() @Builder @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class OllamaEmbeddingResponse { private String model; private List> embeddings; @JsonProperty("total_duration") private Long totalDuration; @JsonProperty("load_duration") private Long loadDuration; @JsonProperty("prompt_eval_count") private Long promptEvalCount; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/rerank/OllamaRerankService.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.rerank; import io.github.lnyocly.ai4j.config.OllamaConfig; import io.github.lnyocly.ai4j.platform.standard.rerank.StandardRerankService; import io.github.lnyocly.ai4j.service.Configuration; public class OllamaRerankService extends StandardRerankService { public OllamaRerankService(Configuration configuration) { this(configuration, configuration == null ? null : configuration.getOllamaConfig()); } public OllamaRerankService(Configuration configuration, OllamaConfig ollamaConfig) { super(configuration == null ? null : configuration.getOkHttpClient(), ollamaConfig == null ? null : ollamaConfig.getApiHost(), ollamaConfig == null ? null : ollamaConfig.getApiKey(), ollamaConfig == null ? null : ollamaConfig.getRerankUrl()); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/OpenAiAudioService.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.platform.openai.audio.entity.TextToSpeech; import io.github.lnyocly.ai4j.platform.openai.audio.entity.Transcription; import io.github.lnyocly.ai4j.platform.openai.audio.entity.TranscriptionResponse; import io.github.lnyocly.ai4j.platform.openai.audio.entity.Translation; import io.github.lnyocly.ai4j.platform.openai.audio.entity.TranslationResponse; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IAudioService; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; import org.apache.commons.lang3.StringUtils; import java.io.FilterInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; /** * @Author cly * @Description OpenAi音频服务 * @Date 2024/10/10 23:36 */ public class OpenAiAudioService implements IAudioService { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final MediaType OCTET_STREAM_MEDIA_TYPE = MediaType.get("application/octet-stream"); private final OpenAiConfig openAiConfig; private final OkHttpClient okHttpClient; public OpenAiAudioService(Configuration configuration) { this.openAiConfig = configuration.getOpenAiConfig(); this.okHttpClient = configuration.getOkHttpClient(); } public OpenAiAudioService(Configuration configuration, OpenAiConfig openAiConfig) { this.openAiConfig = openAiConfig; this.okHttpClient = configuration.getOkHttpClient(); } @Override public InputStream textToSpeech(String baseUrl, String apiKey, TextToSpeech textToSpeech) { String requestString = JSON.toJSONString(textToSpeech); Request request = buildAuthorizedRequest( baseUrl, apiKey, openAiConfig.getSpeechUrl(), RequestBody.create(requestString, JSON_MEDIA_TYPE) ); Response response = null; try { response = okHttpClient.newCall(request).execute(); if (!response.isSuccessful()) { throw new IOException("Unexpected code " + response); } ResponseBody responseBody = response.body(); if (responseBody != null) { return new ResponseInputStream(response, responseBody.byteStream()); } } catch (IOException e) { closeQuietly(response); e.printStackTrace(); } closeQuietly(response); return null; } @Override public InputStream textToSpeech(TextToSpeech textToSpeech) { return this.textToSpeech(null, null, textToSpeech); } @Override public TranscriptionResponse transcription(String baseUrl, String apiKey, Transcription transcription) { MultipartBody.Builder builder = newAudioMultipartBuilder( transcription.getFile(), transcription.getModel(), transcription.getTemperature() ); if(StringUtils.isNotBlank(transcription.getLanguage())){ builder.addFormDataPart("language", transcription.getLanguage()); } if(StringUtils.isNotBlank(transcription.getPrompt())){ builder.addFormDataPart("prompt", transcription.getPrompt()); } if(StringUtils.isNotBlank(transcription.getResponseFormat())){ builder.addFormDataPart("response_format", transcription.getResponseFormat()); } return executeJsonRequest( buildAuthorizedRequest(baseUrl, apiKey, openAiConfig.getTranscriptionUrl(), builder.build()), TranscriptionResponse.class ); } @Override public TranscriptionResponse transcription(Transcription transcription) { return this.transcription(null, null, transcription); } @Override public TranslationResponse translation(String baseUrl, String apiKey, Translation translation) { MultipartBody.Builder builder = newAudioMultipartBuilder( translation.getFile(), translation.getModel(), translation.getTemperature() ); if(StringUtils.isNotBlank(translation.getPrompt())){ builder.addFormDataPart("prompt", translation.getPrompt()); } if(StringUtils.isNotBlank(translation.getResponseFormat())){ builder.addFormDataPart("response_format", translation.getResponseFormat()); } return executeJsonRequest( buildAuthorizedRequest(baseUrl, apiKey, openAiConfig.getTranslationUrl(), builder.build()), TranslationResponse.class ); } @Override public TranslationResponse translation(Translation translation) { return this.translation(null, null, translation); } private Request buildAuthorizedRequest(String baseUrl, String apiKey, String path, RequestBody requestBody) { return new Request.Builder() .header("Authorization", "Bearer " + resolveApiKey(apiKey)) .url(UrlUtils.concatUrl(resolveBaseUrl(baseUrl), path)) .post(requestBody) .build(); } private MultipartBody.Builder newAudioMultipartBuilder(File file, String model, Object temperature) { return new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("file", file.getName(), RequestBody.create(file, OCTET_STREAM_MEDIA_TYPE)) .addFormDataPart("model", model) .addFormDataPart("temperature", String.valueOf(temperature)); } private T executeJsonRequest(Request request, Class responseType) { try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return JSON.parseObject(response.body().string(), responseType); } } catch (Exception e) { e.printStackTrace(); } return null; } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? openAiConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? openAiConfig.getApiKey() : apiKey; } private static void closeQuietly(Response response) { if (response != null) { response.close(); } } /** * Keep the HTTP response open until the caller finishes consuming the stream. */ private static final class ResponseInputStream extends FilterInputStream { private final Response response; private ResponseInputStream(Response response, InputStream delegate) { super(delegate); this.response = response; } @Override public void close() throws IOException { try { super.close(); } finally { response.close(); } } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Segment.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description Segment块 * @Date 2024/10/11 11:34 */ @Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class Segment { private Integer id; private Integer seek; private Double start; private Double end; private String text; private List tokens; private Float temperature; @JsonProperty("avg_logprob") private Double avgLogprob; @JsonProperty("compression_ratio") private Double compressionRatio; @JsonProperty("no_speech_prob") private Double noSpeechProb; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/TextToSpeech.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.audio.enums.AudioEnum; import lombok.*; import java.io.Serializable; /** * @Author cly * @Description TextToSpeech请求实体类 * @Date 2024/10/10 23:45 */ @Data @AllArgsConstructor @NoArgsConstructor @Builder @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class TextToSpeech { /** * tts-1 or tts-1-hd */ @Builder.Default @NonNull private String model = "tts-1"; /** * 要为其生成音频的文本。最大长度为 4096 个字符。 */ @NonNull private String input; /** * 生成音频时要使用的语音。支持的声音包括 alloy、echo、fable、onyx、nova 和 shimmer */ @Builder.Default @NonNull private String voice = AudioEnum.Voice.ALLOY.getValue(); /** * 音频输入的格式。支持的格式包括 mp3, opus, aac, flac, wav, and pcm。 */ @Builder.Default @JsonProperty("response_format") private String responseFormat = AudioEnum.ResponseFormat.MP3.getValue(); /** * 生成的音频的速度。选择一个介于 0.25 到 4.0 之间的值。默认值为 1.0。 */ @Builder.Default private Double speed = 1.0d; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Transcription.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.audio.enums.WhisperEnum; import lombok.*; import java.io.File; /** * 转录请求参数。 * 可将音频文件转录为你所输入语言对应文本。语言可以自己指定 * * @author cly */ @Data @AllArgsConstructor @NoArgsConstructor @Builder @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class Transcription { /** * 要转录的音频文件对象(不是文件名),采用以下格式之一:flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。 */ @NonNull private File file; /** * 要使用的模型的 ID。目前只有 whisper-1 可用。 */ @NonNull @Builder.Default private String model = "whisper-1"; /** * 输入音频的语言。以 ISO-639-1 格式提供输入语言将提高准确性和延迟。 */ private String language; /** * 一个可选文本,用于指导模型的样式或继续上一个音频片段。提示应与音频语言匹配。 */ private String prompt; /** * 输出的格式,采用以下选项之一:json、text、srt、verbose_json 或 vtt。 */ @JsonProperty("response_format") @Builder.Default private String responseFormat = WhisperEnum.ResponseFormat.JSON.getValue(); /** * 采样温度,介于 0 和 1 之间。较高的值(如 0.8)将使输出更加随机,而较低的值(如 0.2)将使其更具集中性和确定性。如果设置为 0,模型将使用对数概率自动提高温度,直到达到某些阈值。 */ @Builder.Default private Double temperature = 0d; public static class TranscriptionBuilder { private File file; public Transcription.TranscriptionBuilder content(File file){ // 校验File是否为以下格式之一:flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。 if (file == null) { throw new IllegalArgumentException("file is required"); } String[] allowedFormats = {"flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm"}; String fileName = file.getName().toLowerCase(); boolean isValidFormat = false; for (String format : allowedFormats) { if (fileName.endsWith("." + format)) { isValidFormat = true; break; } } if (!isValidFormat) { throw new IllegalArgumentException("Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm."); } this.file = file; return this; } } public void setFile(@NonNull File file) { // 校验File是否为以下格式之一:flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。 if (file == null) { throw new IllegalArgumentException("file is required"); } String[] allowedFormats = {"flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm"}; String fileName = file.getName().toLowerCase(); boolean isValidFormat = false; for (String format : allowedFormats) { if (fileName.endsWith("." + format)) { isValidFormat = true; break; } } if (!isValidFormat) { throw new IllegalArgumentException("Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm."); } this.file = file; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/TranscriptionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/10/11 16:28 */ @Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class TranscriptionResponse { private String task; private String language; private Double duration; private String text; private List segments; private List words; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Translation.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.audio.enums.WhisperEnum; import lombok.*; import java.io.File; /** * 转录请求参数。 * 可将音频文件转录为你所输入语言对应文本。语言可以自己指定 * * @author cly */ @Data @AllArgsConstructor @NoArgsConstructor @Builder @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class Translation { /** * 要转录的音频文件对象(不是文件名),采用以下格式之一:flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。 */ @NonNull private File file; /** * 要使用的模型的 ID。目前只有 whisper-1 可用。 */ @NonNull @Builder.Default private String model = "whisper-1"; /** * 一个可选文本,用于指导模型的样式或继续上一个音频片段。提示应与音频语言匹配。 */ private String prompt; /** * 输出的格式,采用以下选项之一:json、text、srt、verbose_json 或 vtt。 */ @JsonProperty("response_format") @Builder.Default private String responseFormat = WhisperEnum.ResponseFormat.JSON.getValue(); /** * 采样温度,介于 0 和 1 之间。较高的值(如 0.8)将使输出更加随机,而较低的值(如 0.2)将使其更具集中性和确定性。如果设置为 0,模型将使用对数概率自动提高温度,直到达到某些阈值。 */ @Builder.Default private Double temperature = 0d; public static class TranslationBuilder { private File file; public Translation.TranslationBuilder content(File file){ // 校验File是否为以下格式之一:flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。 if (file == null) { throw new IllegalArgumentException("file is required"); } String[] allowedFormats = {"flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm"}; String fileName = file.getName().toLowerCase(); boolean isValidFormat = false; for (String format : allowedFormats) { if (fileName.endsWith("." + format)) { isValidFormat = true; break; } } if (!isValidFormat) { throw new IllegalArgumentException("Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm."); } this.file = file; return this; } } public void setFile(@NonNull File file) { // 校验File是否为以下格式之一:flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。 if (file == null) { throw new IllegalArgumentException("file is required"); } String[] allowedFormats = {"flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm"}; String fileName = file.getName().toLowerCase(); boolean isValidFormat = false; for (String format : allowedFormats) { if (fileName.endsWith("." + format)) { isValidFormat = true; break; } } if (!isValidFormat) { throw new IllegalArgumentException("Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm."); } this.file = file; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/TranslationResponse.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/10/11 16:30 */ @Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class TranslationResponse { private String task; private String language; private Double duration; private String text; private List segments; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Word.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description word类 * @Date 2024/10/11 12:04 */ @Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class Word { private String word; private Double start; private Double end; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/enums/AudioEnum.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.enums; import lombok.AllArgsConstructor; import lombok.Getter; import java.io.Serializable; /** * @Author cly * @Description 音频audio枚举类 * @Date 2024/10/10 23:49 */ public class AudioEnum { @Getter @AllArgsConstructor public enum Voice implements Serializable { ALLOY("alloy"), ECHO("echo"), FABLE("fable"), ONYX("onyx"), NOVA("nova"), SHIMMER("shimmer"), ; private final String value; } @Getter @AllArgsConstructor public enum ResponseFormat implements Serializable { MP3("mp3"), OPUS("opus"), AAC("aac"), FLAC("flac"), WAV("wav"), PCM("pcm"), ; private final String value; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/enums/WhisperEnum.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio.enums; import lombok.AllArgsConstructor; import lombok.Getter; import java.io.Serializable; /** * @Author cly * @Description Whisper枚举类 * @Date 2024/10/10 23:56 */ public class WhisperEnum { @Getter @AllArgsConstructor public enum ResponseFormat implements Serializable { JSON("json"), TEXT("text"), SRT("srt"), VERBOSE_JSON("verbose_json"), VTT("vtt"), ; private final String value; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/OpenAiChatService.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat; import com.alibaba.fastjson2.JSON; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.openai.chat.entity.*; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import lombok.extern.slf4j.Slf4j; import okhttp3.*; import okhttp3.sse.EventSource; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description OpenAi 聊天服务 * @Date 2024/8/2 23:16 */ @Slf4j public class OpenAiChatService implements IChatService { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final OpenAiConfig openAiConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; public OpenAiChatService(Configuration configuration) { this.openAiConfig = configuration.getOpenAiConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } public OpenAiChatService(Configuration configuration, OpenAiConfig openAiConfig) { this.openAiConfig = openAiConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if(baseUrl == null || "".equals(baseUrl)) baseUrl = openAiConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = openAiConfig.getApiKey(); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); chatCompletion.setStream(false); chatCompletion.setStreamOptions(null); if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){ //List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if(tools == null){ chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){ }else{ chatCompletion.setParallelToolCalls(null); } // 总token消耗 Usage allUsage = new Usage(); String finishReason = "first"; while("first".equals(finishReason) || "tool_calls".equals(finishReason)){ finishReason = null; // 构造请求 ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(chatCompletion); Request request = new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getChatCompletionUrl())) .post(jsonBody(requestString)) .build(); Response execute = okHttpClient.newCall(request).execute(); if (execute.isSuccessful() && execute.body() != null){ ChatCompletionResponse chatCompletionResponse = mapper.readValue(execute.body().string(), ChatCompletionResponse.class); Choice choice = chatCompletionResponse.getChoices().get(0); finishReason = choice.getFinishReason(); Usage usage = chatCompletionResponse.getUsage(); allUsage.setCompletionTokens(allUsage.getCompletionTokens() + usage.getCompletionTokens()); allUsage.setTotalTokens(allUsage.getTotalTokens() + usage.getTotalTokens()); allUsage.setPromptTokens(allUsage.getPromptTokens() + usage.getPromptTokens()); // 判断是否为函数调用返回 if("tool_calls".equals(finishReason)){ if (passThroughToolCalls) { chatCompletionResponse.setUsage(allUsage); return chatCompletionResponse; } ChatMessage message = choice.getMessage(); List toolCalls = message.getToolCalls(); List messages = new ArrayList<>(chatCompletion.getMessages()); messages.add(message); // 添加 tool 消息 for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } chatCompletion.setMessages(messages); }else{ // 其他情况直接返回 chatCompletionResponse.setUsage(allUsage); return chatCompletionResponse; } }else{ return null; } } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { if(baseUrl == null || "".equals(baseUrl)) baseUrl = openAiConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = openAiConfig.getApiKey(); chatCompletion.setStream(true); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); StreamOptions streamOptions = chatCompletion.getStreamOptions(); if(streamOptions == null){ chatCompletion.setStreamOptions(new StreamOptions(true)); } if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){ //List tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions()); List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if(tools == null){ chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){ }else{ chatCompletion.setParallelToolCalls(null); } String finishReason = "first"; while("first".equals(finishReason) || "tool_calls".equals(finishReason)){ finishReason = null; ObjectMapper mapper = new ObjectMapper(); String jsonString = mapper.writeValueAsString(chatCompletion); Request request = new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getChatCompletionUrl())) .post(jsonBody(jsonString)) .build(); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, eventSourceListener) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); // 需要调用函数 if("tool_calls".equals(finishReason) && !toolCalls.isEmpty()){ if (passThroughToolCalls) { return; } // 创建tool响应消息 ChatMessage responseMessage = ChatMessage.withAssistant(eventSourceListener.getToolCalls()); List messages = new ArrayList<>(chatCompletion.getMessages()); messages.add(responseMessage); // 封装tool结果消息 for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } eventSourceListener.setToolCalls(new ArrayList<>()); eventSourceListener.setToolCall(null); chatCompletion.setMessages(messages); } } } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { chatCompletionStream(null, null, chatCompletion, eventSourceListener); } private RequestBody jsonBody(String json) { return RequestBody.create(json, JSON_MEDIA_TYPE); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/ChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.tool.BuiltInToolContext; import lombok.*; import java.util.*; /** * @Author cly * @Description ChatCompletion 实体类 * @Date 2024/8/3 18:00 */ @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ChatCompletion { /** * 对话模型 */ @NonNull private String model; /** * 消息内容 */ @NonNull @Singular private List messages; /** * 如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾 */ @Builder.Default private Boolean stream = false; /** * 流式输出相关选项。只有在 stream 参数为 true 时,才可设置此参数。 */ @JsonProperty("stream_options") private StreamOptions streamOptions; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。 */ @Builder.Default @JsonProperty("frequency_penalty") private Float frequencyPenalty = 0f; /** * 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 * 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 */ @Builder.Default private Float temperature = 1f; /** * 作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 * 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 */ @Builder.Default @JsonProperty("top_p") private Float topP = 1f; /** * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。 */ @Deprecated @JsonProperty("max_tokens") private Integer maxTokens; @JsonProperty("max_completion_tokens") private Integer maxCompletionTokens; /** * 模型可能会调用的 tool 的列表。目前,仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。 */ private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; /** * MCP服务列表,用于集成MCP工具 * 支持字符串(服务器ID)或对象(服务器配置) */ @JsonIgnore private List mcpServices; /** * 控制模型调用 tool 的行为。 * none 意味着模型不会调用任何 tool,而是生成一条消息。 * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。 * 当没有 tool 时,默认值为 none。如果有 tool 存在,默认值为 auto。 */ @JsonProperty("tool_choice") private String toolChoice; @Builder.Default @JsonProperty("parallel_tool_calls") private Boolean parallelToolCalls = true; /** * Agent runtime helper: preserve streamed tool calls for runtime execution * instead of provider-side auto invocation. */ @JsonIgnore private Boolean passThroughToolCalls; @JsonIgnore private BuiltInToolContext builtInToolContext; /** * 一个 object,指定模型必须输出的格式。 * * 设置为 { "type": "json_object" } 以启用 JSON 模式,该模式保证模型生成的消息是有效的 JSON。 * * 注意: 使用 JSON 模式时,你还必须通过系统或用户消息指示模型生成 JSON。 * 否则,模型可能会生成不断的空白字符,直到生成达到令牌限制,从而导致请求长时间运行并显得“卡住”。 * 此外,如果 finish_reason="length",这表示生成超过了 max_tokens 或对话超过了最大上下文长度,消息内容可能会被部分截断。 */ @JsonProperty("response_format") private Object responseFormat; private String user; @Builder.Default private Integer n = 1; /** * 在遇到这些词时,API 将停止生成更多的 token。 */ private List stop; /** * 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。 */ @Builder.Default @JsonProperty("presence_penalty") private Float presencePenalty = 0f; @JsonProperty("logit_bias") private Map logitBias; /** * 是否返回所输出 token 的对数概率。如果为 true,则在 message 的 content 中返回每个输出 token 的对数概率。 */ @Builder.Default private Boolean logprobs = false; /** * 一个介于 0 到 20 之间的整数 N,指定每个输出位置返回输出概率 top N 的 token,且返回这些 token 的对数概率。指定此参数时,logprobs 必须为 true。 */ @JsonProperty("top_logprobs") private Integer topLogprobs; /** * 额外参数 */ @JsonProperty("parameters") @Singular private Map parameters; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 * 如果 extraBody 中的字段与现有字段同名,extraBody 中的值会覆盖现有值 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonIgnore private StreamExecutionOptions streamExecution; /** * Jackson 序列化时自动调用,将 extraBody 的内容展开到顶层 */ @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class ChatCompletionBuilder { private List functions; private List mcpServices; public ChatCompletion.ChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public ChatCompletion.ChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } if (functions != null) { this.functions.addAll(functions); } return this; } public ChatCompletion.ChatCompletionBuilder mcpServices(String... mcpServices){ if (this.mcpServices == null) { this.mcpServices = new ArrayList<>(); } this.mcpServices.addAll(Arrays.asList(mcpServices)); return this; } public ChatCompletion.ChatCompletionBuilder mcpServices(List mcpServices){ if (this.mcpServices == null) { this.mcpServices = new ArrayList<>(); } if (mcpServices != null) { this.mcpServices.addAll(mcpServices); } return this; } public ChatCompletion.ChatCompletionBuilder mcpService(String mcpService){ if (this.mcpServices == null) { this.mcpServices = new ArrayList<>(); } this.mcpServices.add(mcpService); return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/ChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/8/11 19:45 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ChatCompletionResponse { /** * 该对话的唯一标识符。 */ private String id; /** * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk */ private String object; /** * 创建聊天完成时的 Unix 时间戳(以秒为单位)。 */ private Long created; /** * 生成该 completion 的模型名。 */ private String model; /** * 该指纹代表模型运行时使用的后端配置。 */ @JsonProperty("system_fingerprint") private String systemFingerprint; /** * 模型生成的 completion 的选择列表。 */ private List choices; /** * 该对话补全请求的用量信息。 */ private Usage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/ChatMessage.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import lombok.*; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/8/3 18:14 */ @Data @Builder @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ChatMessage { private Content content; private String role; private String name; private String refusal; @JsonProperty("reasoning_content") private String reasoningContent; @JsonProperty("tool_call_id") private String toolCallId; @JsonProperty("tool_calls") private List toolCalls; public ChatMessage(String userMessage) { this.role = ChatMessageType.USER.getRole(); this.content = Content.ofText(userMessage); } public ChatMessage(ChatMessageType role, String message) { this.role = role.getRole(); this.content = Content.ofText(message); } public ChatMessage(String role, String message) { this.role = role; this.content = Content.ofText(message); } public static ChatMessage withSystem(String content) { return new ChatMessage(ChatMessageType.SYSTEM, content); } public static ChatMessage withUser(String content) { return new ChatMessage(ChatMessageType.USER, content); } public static ChatMessage withUser(String content, String ...images) { return ChatMessage.builder() .role(ChatMessageType.USER.getRole()) .content(Content.ofMultiModals(Content.MultiModal.withMultiModal(content, images))) .build(); } public static ChatMessage withAssistant(String content) { return new ChatMessage(ChatMessageType.ASSISTANT, content); } public static ChatMessage withAssistant(List toolCalls) { return ChatMessage.builder() .role(ChatMessageType.ASSISTANT.getRole()) .toolCalls(toolCalls) .build(); } public static ChatMessage withAssistant(String content, List toolCalls) { return ChatMessage.builder() .role(ChatMessageType.ASSISTANT.getRole()) .content(Content.ofText(content)) .toolCalls(toolCalls) .build(); } public static ChatMessage withTool(String content, String toolCallId) { return ChatMessage.builder() .role(ChatMessageType.TOOL.getRole()) .content(Content.ofText(content)) .toolCallId(toolCallId) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/Choice.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; /** * @Author cly * @Description 模型生成的 completion * @Date 2024/8/11 20:01 */ @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class Choice { private Integer index; private ChatMessage delta; private ChatMessage message; private Object logprobs; /** * 模型停止生成 token 的原因。 * * [stop, length, content_filter, tool_calls, insufficient_system_resource] * * stop:模型自然停止生成,或遇到 stop 序列中列出的字符串。 * length:输出长度达到了模型上下文长度限制,或达到了 max_tokens 的限制。 * content_filter:输出内容因触发过滤策略而被过滤。 * tool_calls:函数调用。 * insufficient_system_resource:系统推理资源不足,生成被打断。 * */ @JsonProperty("finish_reason") private String finishReason; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/Content.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.github.lnyocly.ai4j.platform.openai.chat.serializer.ContentDeserializer; import lombok.*; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description TODO * @Date 2025/2/11 0:46 */ @ToString @JsonDeserialize(using = ContentDeserializer.class) public class Content { private String text; // 纯文本时使用 private List multiModals; // 多模态时使用 // 纯文本构造方法 public static Content ofText(String text) { Content instance = new Content(); instance.text = text; return instance; } // 多模态构造方法 public static Content ofMultiModals(List parts) { Content instance = new Content(); instance.multiModals = parts; return instance; } // 序列化逻辑 @JsonValue public Object toJson() { if (text != null) { return text; // 直接返回 } else if (multiModals != null) { return multiModals; } throw new IllegalStateException("Invalid content state"); } public String getText() { return text; } public List getMultiModals() { return multiModals; } @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public static class MultiModal { @Builder.Default private String type = Type.TEXT.type; private String text; @JsonProperty("image_url") private ImageUrl imageUrl; @Data @NoArgsConstructor @AllArgsConstructor public static class ImageUrl { private String url; } @Getter @AllArgsConstructor public enum Type { TEXT("text", "文本类型"), IMAGE_URL("image_url", "图片类型,可以为url或者base64"), ; private final String type; private final String info; } public static List withMultiModal(String text, String... imageUrl) { List messages = new ArrayList<>(); messages.add(new MultiModal(MultiModal.Type.TEXT.getType(), text, null)); for (String url : imageUrl) { messages.add(new MultiModal(MultiModal.Type.IMAGE_URL.getType(), null, new MultiModal.ImageUrl(url))); } return messages; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/StreamOptions.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 流式输出相关选项 * @Date 2024/8/29 13:00 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class StreamOptions { @JsonProperty("include_usage") private Boolean includeUsage = true; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/enums/ChatMessageType.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.enums; import lombok.AllArgsConstructor; import lombok.Getter; /** * @Author cly * @Description TODO * @Date 2024/8/6 23:54 */ @Getter @AllArgsConstructor public enum ChatMessageType { SYSTEM("system"), USER("user"), ASSISTANT("assistant"), TOOL("tool"), ; private final String role; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/serializer/ContentDeserializer.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat.serializer; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Content; import java.io.IOException; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description TODO * @Date 2025/2/11 0:57 */ public class ContentDeserializer extends JsonDeserializer { @Override public Content deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); if (node.isTextual()) { return Content.ofText(node.asText()); } else if (node.isArray()) { List parts = new ArrayList<>(); for (JsonNode element : node) { Content.MultiModal part = p.getCodec().treeToValue(element, Content.MultiModal.class); parts.add(part); } return Content.ofMultiModals(parts); } throw new IOException("Unsupported content format"); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/OpenAiEmbeddingService.java ================================================ package io.github.lnyocly.ai4j.platform.openai.embedding; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.*; /** * @Author cly * @Description TODO * @Date 2024/8/7 17:40 */ public class OpenAiEmbeddingService implements IEmbeddingService { private final OpenAiConfig openAiConfig; private final OkHttpClient okHttpClient; public OpenAiEmbeddingService(Configuration configuration) { this.openAiConfig = configuration.getOpenAiConfig(); this.okHttpClient = configuration.getOkHttpClient(); } public OpenAiEmbeddingService(Configuration configuration, OpenAiConfig openAiConfig) { this.openAiConfig = openAiConfig; this.okHttpClient = configuration.getOkHttpClient(); } @Override public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) throws Exception { if(baseUrl == null || "".equals(baseUrl)) baseUrl = openAiConfig.getApiHost(); if(apiKey == null || "".equals(apiKey)) apiKey = openAiConfig.getApiKey(); String jsonString = JSON.toJSONString(embeddingReq); Request request = new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getEmbeddingUrl())) .post(RequestBody.create(jsonString, MediaType.get(Constants.APPLICATION_JSON))) .build(); Response execute = okHttpClient.newCall(request).execute(); if (execute.isSuccessful() && execute.body() != null) { return JSON.parseObject(execute.body().string(), EmbeddingResponse.class); } return null; } @Override public EmbeddingResponse embedding(Embedding embeddingReq) throws Exception { return embedding(null, null, embeddingReq); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/entity/Embedding.java ================================================ package io.github.lnyocly.ai4j.platform.openai.embedding.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.*; import java.util.List; /** * @Author cly * @Description Embedding 实体类 * @Date 2024/8/7 17:20 */ @Data @Builder @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class Embedding { /** * 向量化文本 */ @NonNull private Object input; /** * 向量模型 */ @NonNull @Builder.Default private String model = "text-embedding-3-small"; @JsonProperty("encoding_format") private String encodingFormat; /** * 向量维度 建议选择256、512、1024或2048维度 */ private String dimensions; private String user; public static class EmbeddingBuilder { private Object input; private Embedding.EmbeddingBuilder input(Object input){ this.input = input; return this; } public Embedding.EmbeddingBuilder input(String input){ this.input = input; return this; } public Embedding.EmbeddingBuilder input(List content){ this.input = content; return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/entity/EmbeddingObject.java ================================================ package io.github.lnyocly.ai4j.platform.openai.embedding.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description embedding的处理结果 * @Date 2024/8/7 17:30 */ @Data @Builder @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class EmbeddingObject { /** * 结果下标 */ private Integer index; /** * embedding的处理结果,返回向量化表征的数组 */ private List embedding; /** * 结果类型,目前为恒为 embedding */ private String object; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/entity/EmbeddingResponse.java ================================================ package io.github.lnyocly.ai4j.platform.openai.embedding.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description Embedding接口的返回结果 * @Date 2024/8/7 17:44 */ @Data @NoArgsConstructor() @AllArgsConstructor() @Builder @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class EmbeddingResponse { private String object; private List data; private String model; private Usage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/OpenAiImageService.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.ImageSseListener; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamEvent; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamError; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageUsage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IImageService; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import okhttp3.sse.EventSources; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import com.fasterxml.jackson.databind.JsonNode; /** * @Author cly * @Description OpenAI 图片生成服务 * @Date 2026/1/31 */ public class OpenAiImageService implements IImageService { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final OpenAiConfig openAiConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; public OpenAiImageService(Configuration configuration) { this.openAiConfig = configuration.getOpenAiConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = EventSources.createFactory(okHttpClient); } @Override public ImageGenerationResponse generate(String baseUrl, String apiKey, ImageGeneration imageGeneration) throws Exception { if (baseUrl == null || "".equals(baseUrl)) { baseUrl = openAiConfig.getApiHost(); } if (apiKey == null || "".equals(apiKey)) { apiKey = openAiConfig.getApiKey(); } ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(imageGeneration); Request request = new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getImageGenerationUrl())) .post(RequestBody.create(requestString, JSON_MEDIA_TYPE)) .build(); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return mapper.readValue(response.body().string(), ImageGenerationResponse.class); } } throw new CommonException("OpenAI 图片生成请求失败"); } @Override public ImageGenerationResponse generate(ImageGeneration imageGeneration) throws Exception { return this.generate(null, null, imageGeneration); } @Override public void generateStream(String baseUrl, String apiKey, ImageGeneration imageGeneration, ImageSseListener listener) throws Exception { if (baseUrl == null || "".equals(baseUrl)) { baseUrl = openAiConfig.getApiHost(); } if (apiKey == null || "".equals(apiKey)) { apiKey = openAiConfig.getApiKey(); } if (imageGeneration.getStream() == null || !imageGeneration.getStream()) { imageGeneration.setStream(true); } ObjectMapper mapper = new ObjectMapper(); String requestString = mapper.writeValueAsString(imageGeneration); Request request = new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getImageGenerationUrl())) .post(RequestBody.create(requestString, JSON_MEDIA_TYPE)) .build(); factory.newEventSource(request, convertEventSource(mapper, listener)); listener.getCountDownLatch().await(); } @Override public void generateStream(ImageGeneration imageGeneration, ImageSseListener listener) throws Exception { this.generateStream(null, null, imageGeneration, listener); } private EventSourceListener convertEventSource(ObjectMapper mapper, ImageSseListener listener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { // no-op } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { listener.onError(t, response); listener.complete(); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { listener.complete(); return; } try { ImageStreamEvent event = parseOpenAiEvent(mapper, data); listener.accept(event); if ("image_generation.completed".equals(event.getType())) { listener.complete(); } } catch (Exception e) { listener.onError(e, null); listener.complete(); } } @Override public void onClosed(@NotNull EventSource eventSource) { listener.complete(); } }; } private ImageStreamEvent parseOpenAiEvent(ObjectMapper mapper, String data) throws Exception { JsonNode node = mapper.readTree(data); ImageStreamEvent event = new ImageStreamEvent(); event.setType(asText(node, "type")); event.setModel(asText(node, "model")); Long createdAt = asLong(node, "created_at"); if (createdAt == null) { createdAt = asLong(node, "created"); } event.setCreatedAt(createdAt); event.setPartialImageIndex(asInt(node, "partial_image_index")); event.setImageIndex(asInt(node, "image_index")); event.setUrl(asText(node, "url")); String b64 = asText(node, "b64_json"); if (b64 == null) { b64 = asText(node, "partial_image_b64"); } event.setB64Json(b64); event.setSize(asText(node, "size")); event.setQuality(asText(node, "quality")); event.setBackground(asText(node, "background")); event.setOutputFormat(asText(node, "output_format")); if (node.has("usage")) { event.setUsage(mapper.treeToValue(node.get("usage"), ImageUsage.class)); } if (node.has("error")) { event.setError(mapper.treeToValue(node.get("error"), ImageStreamError.class)); } return event; } private String asText(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asText(); } private Integer asInt(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asInt(); } private Long asLong(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asLong(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageData.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 图片数据项 * @Date 2026/1/31 */ @Data @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ImageData { private String url; @JsonProperty("b64_json") private String b64Json; @JsonProperty("revised_prompt") private String revisedPrompt; /** * 平台扩展字段(如部分平台返回尺寸) */ private String size; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageGeneration.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.*; import java.util.Map; /** * @Author cly * @Description OpenAI 图片生成请求参数 * @Date 2026/1/31 */ @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ImageGeneration { /** * 模型 ID */ @NonNull private String model; /** * 提示词 */ @NonNull private String prompt; /** * 输出数量 */ private Integer n; /** * 输出尺寸,例如 1024x1024 / 1024x1536 / 1536x1024 / auto */ private String size; /** * 输出质量,例如 low / medium / high / auto */ private String quality; /** * 返回格式,例如 url / b64_json */ @JsonProperty("response_format") private String responseFormat; /** * 输出格式,例如 png / jpeg / webp */ @JsonProperty("output_format") private String outputFormat; /** * 输出压缩质量 (0-100) */ @JsonProperty("output_compression") private Integer outputCompression; /** * 背景,例如 transparent / opaque / auto */ private String background; /** * 部分图数量,用于流式输出 */ @JsonProperty("partial_images") private Integer partialImages; /** * 是否流式返回 */ private Boolean stream; /** * 风险控制或审计用户标识 */ private String user; /** * 额外参数,用于平台扩展 */ @JsonIgnore @Singular("extraBody") private Map extraBody; /** * Jackson 序列化时将 extraBody 展开到顶层 */ @JsonAnyGetter public Map getExtraBody() { return extraBody; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageGenerationResponse.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description OpenAI 图片生成响应 * @Date 2026/1/31 */ @Data @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ImageGenerationResponse { private Long created; private List data; private ImageUsage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageStreamError.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image.entity; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 图片生成流式错误信息 * @Date 2026/1/31 */ @Data @NoArgsConstructor @AllArgsConstructor public class ImageStreamError { private String code; private String message; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageStreamEvent.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image.entity; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 图片生成流式事件(统一结构) * @Date 2026/1/31 */ @Data @NoArgsConstructor @AllArgsConstructor public class ImageStreamEvent { private String type; private String model; private Long createdAt; private Integer partialImageIndex; private Integer imageIndex; private String url; private String b64Json; private String size; private String quality; private String background; private String outputFormat; private ImageUsage usage; private ImageStreamError error; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageUsage.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 图片生成用量信息 * @Date 2026/1/31 */ @Data @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ImageUsage { @JsonProperty("total_tokens") private Long totalTokens; @JsonProperty("input_tokens") private Long inputTokens; @JsonProperty("output_tokens") private Long outputTokens; @JsonProperty("generated_images") private Integer generatedImages; @JsonProperty("input_tokens_details") private ImageUsageDetails inputTokensDetails; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageUsageDetails.java ================================================ package io.github.lnyocly.ai4j.platform.openai.image.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description 图片生成用量明细 * @Date 2026/1/31 */ @Data @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ImageUsageDetails { @JsonProperty("text_tokens") private Long textTokens; @JsonProperty("image_tokens") private Long imageTokens; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/OpenAiRealtimeService.java ================================================ package io.github.lnyocly.ai4j.platform.openai.realtime; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.listener.RealtimeListener; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IRealtimeService; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.WebSocket; /** * @Author cly * @Description OpenAiRealtimeService * @Date 2024/10/12 16:39 */ public class OpenAiRealtimeService implements IRealtimeService { private final OpenAiConfig openAiConfig; private final OkHttpClient okHttpClient; public OpenAiRealtimeService(Configuration configuration) { this.openAiConfig = configuration.getOpenAiConfig(); this.okHttpClient = configuration.getOkHttpClient(); } public OpenAiRealtimeService(Configuration configuration, OpenAiConfig openAiConfig) { this.openAiConfig = openAiConfig; this.okHttpClient = configuration.getOkHttpClient(); } @Override public WebSocket createRealtimeClient(String baseUrl, String apiKey, String model, RealtimeListener realtimeListener) { if(baseUrl == null || "".equals(baseUrl)) baseUrl = openAiConfig.getApiHost(); // url为HTTPS不影响 if(apiKey == null || "".equals(apiKey)) apiKey = openAiConfig.getApiKey(); String url = UrlUtils.concatUrl(baseUrl, openAiConfig.getRealtimeUrl(), "?model=" + model); Request request = new Request.Builder() .url(url) .addHeader("Authorization", "Bearer " + apiKey) .addHeader("OpenAI-Beta", "realtime=v1") .build(); return okHttpClient.newWebSocket(request, realtimeListener); } @Override public WebSocket createRealtimeClient(String model, RealtimeListener realtimeListener) { return this.createRealtimeClient(null, null, model, realtimeListener); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/RealtimeConstant.java ================================================ package io.github.lnyocly.ai4j.platform.openai.realtime; /** * @Author cly * @Description TODO * @Date 2024/10/13 13:56 */ public class RealtimeConstant { /** * session.update * input_audio_buffer.append * input_audio_buffer.commit * input_audio_buffer.clear * conversation.item.create * conversation.item.truncate * conversation.item.delete * response.create * response.cancel */ public static class ClientEvent { public static final String SESSION_UPDATE = "session.update"; public static final String INPUT_AUDIO_BUFFER_APPEND = "input_audio_buffer.append"; public static final String INPUT_AUDIO_BUFFER_COMMIT = "input_audio_buffer.commit"; public static final String INPUT_AUDIO_BUFFER_CLEAR = "input_audio_buffer.clear"; public static final String CONVERSATION_ITEM_CREATE = "conversation.item.create"; public static final String CONVERSATION_ITEM_TRUNCATE = "conversation.item.truncate"; public static final String CONVERSATION_ITEM_DELETE = "conversation.item.delete"; /** * 发送此事件可触发响应生成。 */ public static final String RESPONSE_CREATE = "response.create"; public static final String RESPONSE_CANCEL = "response.cancel"; } /** * error * session.created * session.updated * conversation.created * input_audio_buffer.committed * input_audio_buffer.cleared * input_audio_buffer.speech_started * input_audio_buffer.speech_stopped * conversation.item.created * conversation.item.input_audio_transcription.completed * conversation.item.input_audio_transcription.failed * conversation.item.truncated * conversation.item.deleted * response.created * response.done * response.output_item.added * response.output_item.done * response.content_part.added * response.content_part.done * response.text.delta * response.text.done * response.audio_transcript.delta * response.audio_transcript.done * response.audio.delta * response.audio.done * response.function_call_arguments.delta * response.function_call_arguments.done * rate_limits.updated */ public static class ServerEvent { public static final String ERROR = "error"; public static final String SESSION_CREATED = "session.created"; public static final String SESSION_UPDATED = "session.updated"; /** * 创建对话时返回。会话创建后立即发出。 */ public static final String CONVERSATION_CREATED = "conversation.created"; public static final String INPUT_AUDIO_BUFFER_COMMITTED = "input_audio_buffer.committed"; public static final String INPUT_AUDIO_BUFFER_CLEARED = "input_audio_buffer.cleared"; public static final String INPUT_AUDIO_BUFFER_SPEECH_STARTED = "input_audio_buffer.speech_started"; public static final String INPUT_AUDIO_BUFFER_SPEECH_STOPPED = "input_audio_buffer.speech_stopped"; /** * 创建对话项目时返回。 */ public static final String CONVERSATION_ITEM_CREATED = "conversation.item.created"; public static final String CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_COMPLETED = "conversation.item.input_audio_transcription.completed"; public static final String CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_FAILED = "conversation.item.input_audio_transcription.failed"; public static final String CONVERSATION_ITEM_TRUNCATED = "conversation.item.truncated"; public static final String CONVERSATION_ITEM_DELETED = "conversation.item.deleted"; public static final String RESPONSE_CREATED = "response.created"; /** * 当响应完成流式传输时返回。无论最终状态如何,始终发出。 */ public static final String RESPONSE_DONE = "response.done"; public static final String RESPONSE_OUTPUT_ITEM_ADDED = "response.output_item.added"; public static final String RESPONSE_OUTPUT_ITEM_DONE = "response.output_item.done"; /** * 在生成回复过程中将新内容添加到助理信息项目时返回。 */ public static final String RESPONSE_CONTENT_PART_ADDED = "response.content_part.added"; /** * 当助手信息项目中的内容部分完成流式传输时返回。当响应中断、不完整或取消时也会返回。 */ public static final String RESPONSE_CONTENT_PART_DONE = "response.content_part.done"; /** * 当“文本”内容部分的文本值更新时返回。 */ public static final String RESPONSE_TEXT_DELTA = "response.text.delta"; /** * 当 “文本 ”内容部分的文本值完成流式传输时返回。当响应被中断、不完整或取消时也会返回。 */ public static final String RESPONSE_TEXT_DONE = "response.text.done"; public static final String RESPONSE_AUDIO_TRANSCRIPT_DELTA = "response.audio_transcript.delta"; public static final String RESPONSE_AUDIO_TRANSCRIPT_DONE = "response.audio_transcript.done"; /** * 当模型生成的音频更新时返回。 */ public static final String RESPONSE_AUDIO_DELTA = "response.audio.delta"; /** * 当模型生成的音频完成时返回。当响应被中断、不完整或取消时也会发出。 */ public static final String RESPONSE_AUDIO_DONE = "response.audio.done"; public static final String RESPONSE_FUNCTION_CALL_ARGUMENTS_DELTA = "response.function_call_arguments.delta"; public static final String RESPONSE_FUNCTION_CALL_ARGUMENTS_DONE = "response.function_call_arguments.done"; public static final String RATE_LIMITS_UPDATED = "rate_limits.updated"; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/ConversationCreated.java ================================================ package io.github.lnyocly.ai4j.platform.openai.realtime.entity; /** * @Author cly * @Description TODO * @Date 2024/10/12 18:01 */ public class ConversationCreated { } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/Session.java ================================================ package io.github.lnyocly.ai4j.platform.openai.realtime.entity; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/10/12 17:24 */ public class Session { /** * 模型可以响应的一组模式。要禁用音频,请将其设置为 [“text”]。 */ private List modalities; /** * 默认系统指令添加到模型调用之前。 */ private String instructions; /** * 模型用于响应的声音 - alloy 、 echo或shimmer之一。一旦模型至少响应一次音频,就无法更改。 */ private String voice; /** * 输入音频的格式。选项为“pcm16”、“g711_ulaw”或“g711_alaw”。 */ private String input_audio_format; /** * 输出音频的格式。选项为“pcm16”、“g711_ulaw”或“g711_alaw”。 */ private String output_audio_format; /** * 输入音频转录的配置。可以设置为null来关闭。 */ private Object input_audio_transcription; /** * 转弯检测的配置。可以设置为null来关闭。 */ private Object turn_detection; private Object tools; private String tool_choice; private Double temperature; private Integer max_output_tokens; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/SessionCreated.java ================================================ package io.github.lnyocly.ai4j.platform.openai.realtime.entity; /** * @Author cly * @Description TODO * @Date 2024/10/12 17:58 */ public class SessionCreated { private String event_id; private String type = "session.created"; private Session session; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/SessionUpdated.java ================================================ package io.github.lnyocly.ai4j.platform.openai.realtime.entity; /** * @Author cly * @Description TODO * @Date 2024/10/12 17:58 */ public class SessionUpdated { private String event_id; private String type = "session.updated"; private Session session; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/OpenAiResponsesService.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.ResponseSseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IResponsesService; import io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; /** * @Author cly * @Description OpenAI Responses API service * @Date 2026/2/1 */ public class OpenAiResponsesService implements IResponsesService { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final Set OPENAI_ALLOWED_FIELDS = new java.util.HashSet(java.util.Arrays.asList( "model", "input", "include", "instructions", "max_output_tokens", "metadata", "parallel_tool_calls", "previous_response_id", "reasoning", "store", "stream", "stream_options", "temperature", "text", "tool_choice", "tools", "top_p", "truncation", "user", "background" )); private final OpenAiConfig openAiConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public OpenAiResponsesService(Configuration configuration) { this.openAiConfig = configuration.getOpenAiConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception { String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl()); String key = resolveApiKey(apiKey); request.setStream(false); request.setStreamOptions(null); request = ResponseRequestToolResolver.resolve(request); Request httpRequest = buildJsonPostRequest(url, key, serializeRequest(request)); return executeJsonRequest(httpRequest, Response.class, "OpenAI Responses request failed"); } @Override public Response create(ResponseRequest request) throws Exception { return create(null, null, request); } @Override public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception { String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl()); String key = resolveApiKey(apiKey); if (request.getStream() == null || !request.getStream()) { request.setStream(true); } if (request.getStreamOptions() == null) { request.setStreamOptions(new StreamOptions(true)); } request = ResponseRequestToolResolver.resolve(request); Request httpRequest = buildJsonPostRequest(url, key, serializeRequest(request)); StreamExecutionSupport.execute( listener, request.getStreamExecution(), () -> factory.newEventSource(httpRequest, convertEventSource(listener)) ); } @Override public void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception { createStream(null, null, request, listener); } @Override public Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception { String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl() + "/" + responseId); String key = resolveApiKey(apiKey); Request httpRequest = authorizedRequestBuilder(url, key) .get() .build(); return executeJsonRequest(httpRequest, Response.class, "OpenAI Responses retrieve failed"); } @Override public Response retrieve(String responseId) throws Exception { return retrieve(null, null, responseId); } @Override public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception { String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl() + "/" + responseId); String key = resolveApiKey(apiKey); Request httpRequest = authorizedRequestBuilder(url, key) .delete() .build(); return executeJsonRequest(httpRequest, ResponseDeleteResponse.class, "OpenAI Responses delete failed"); } @Override public ResponseDeleteResponse delete(String responseId) throws Exception { return delete(null, null, responseId); } private String resolveUrl(String baseUrl, String path) { String host = (baseUrl == null || "".equals(baseUrl)) ? openAiConfig.getApiHost() : baseUrl; return UrlUtils.concatUrl(host, path); } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? openAiConfig.getApiKey() : apiKey; } private String serializeRequest(ResponseRequest request) throws Exception { return objectMapper.writeValueAsString(buildOpenAiPayload(request)); } private Request buildJsonPostRequest(String url, String apiKey, String body) { return authorizedRequestBuilder(url, apiKey) .post(RequestBody.create(body, JSON_MEDIA_TYPE)) .build(); } private Request.Builder authorizedRequestBuilder(String url, String apiKey) { return new Request.Builder() .header("Authorization", "Bearer " + apiKey) .url(url); } private T executeJsonRequest(Request request, Class responseType, String failureMessage) throws Exception { try (okhttp3.Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), responseType); } } throw new CommonException(failureMessage); } private Map buildOpenAiPayload(ResponseRequest request) { Map payload = new LinkedHashMap<>(); if (request.getModel() != null) { payload.put("model", request.getModel()); } if (request.getInput() != null) { payload.put("input", request.getInput()); } if (request.getInclude() != null) { payload.put("include", request.getInclude()); } if (request.getInstructions() != null) { payload.put("instructions", request.getInstructions()); } if (request.getMaxOutputTokens() != null) { payload.put("max_output_tokens", request.getMaxOutputTokens()); } if (request.getMetadata() != null) { payload.put("metadata", request.getMetadata()); } if (request.getParallelToolCalls() != null) { payload.put("parallel_tool_calls", request.getParallelToolCalls()); } if (request.getPreviousResponseId() != null) { payload.put("previous_response_id", request.getPreviousResponseId()); } if (request.getReasoning() != null) { payload.put("reasoning", request.getReasoning()); } if (request.getStore() != null) { payload.put("store", request.getStore()); } if (request.getStream() != null) { payload.put("stream", request.getStream()); } if (request.getStreamOptions() != null) { payload.put("stream_options", request.getStreamOptions()); } if (request.getTemperature() != null) { payload.put("temperature", request.getTemperature()); } if (request.getText() != null) { payload.put("text", request.getText()); } if (request.getToolChoice() != null) { payload.put("tool_choice", request.getToolChoice()); } if (request.getTools() != null) { payload.put("tools", request.getTools()); } if (request.getTopP() != null) { payload.put("top_p", request.getTopP()); } if (request.getTruncation() != null) { payload.put("truncation", request.getTruncation()); } if (request.getUser() != null) { payload.put("user", request.getUser()); } if (request.getExtraBody() != null) { for (Map.Entry entry : request.getExtraBody().entrySet()) { if (OPENAI_ALLOWED_FIELDS.contains(entry.getKey()) && !payload.containsKey(entry.getKey())) { payload.put(entry.getKey(), entry.getValue()); } } } return payload; } private EventSourceListener convertEventSource(ResponseSseListener listener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull okhttp3.Response response) { listener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable okhttp3.Response response) { listener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { listener.complete(); return; } try { ResponseStreamEvent event = ResponseEventParser.parse(objectMapper, data); listener.accept(event); if (isTerminalEvent(event.getType())) { listener.complete(); } } catch (Exception e) { listener.onError(e, null); listener.complete(); } } @Override public void onClosed(@NotNull EventSource eventSource) { listener.onClosed(eventSource); } }; } private boolean isTerminalEvent(String type) { if (type == null) { return false; } return "response.completed".equals(type) || "response.failed".equals(type) || "response.incomplete".equals(type); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/ResponseEventParser.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseError; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent; public final class ResponseEventParser { private ResponseEventParser() { } public static ResponseStreamEvent parse(ObjectMapper mapper, String data) throws Exception { JsonNode node = mapper.readTree(data); ResponseStreamEvent event = new ResponseStreamEvent(); event.setRaw(node); event.setType(asText(node, "type")); event.setSequenceNumber(asInt(node, "sequence_number")); event.setOutputIndex(asInt(node, "output_index")); event.setContentIndex(asInt(node, "content_index")); event.setItemId(asText(node, "item_id")); event.setDelta(asText(node, "delta")); event.setText(asText(node, "text")); event.setArguments(asText(node, "arguments")); event.setCallId(asText(node, "call_id")); if (node.has("response") && !node.get("response").isNull()) { event.setResponse(mapper.treeToValue(node.get("response"), Response.class)); } if (node.has("error") && !node.get("error").isNull()) { event.setError(mapper.treeToValue(node.get("error"), ResponseError.class)); } return event; } private static String asText(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asText(); } private static Integer asInt(JsonNode node, String field) { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asInt(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ImagePixelLimit.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ImagePixelLimit { @JsonProperty("min_pixels") private Integer minPixels; @JsonProperty("max_pixels") private Integer maxPixels; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/Response.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.util.List; import java.util.Map; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class Response { private String id; private String object; @JsonProperty("created_at") private Long createdAt; private String model; private String status; private List output; private ResponseError error; @JsonProperty("incomplete_details") private ResponseIncompleteDetails incompleteDetails; private String instructions; @JsonProperty("max_output_tokens") private Integer maxOutputTokens; @JsonProperty("previous_response_id") private String previousResponseId; private ResponseUsage usage; @JsonProperty("service_tier") private String serviceTier; private Map metadata; @JsonProperty("context_management") private ResponseContextManagement contextManagement; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseContentPart.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.*; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseContentPart { private String type; private String text; @JsonProperty("image_url") private String imageUrl; @JsonProperty("file_id") private String fileId; @JsonProperty("file_url") private String fileUrl; @JsonProperty("file_data") private String fileData; @JsonProperty("video_url") private String videoUrl; private String detail; @JsonProperty("image_pixel_limit") private ImagePixelLimit imagePixelLimit; @JsonProperty("translation_options") private TranslationOptions translationOptions; @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseContextEdit.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseContextEdit { private String type; @JsonProperty("cleared_thinking_turns") private Integer clearedThinkingTurns; @JsonProperty("cleared_tool_uses") private Integer clearedToolUses; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseContextManagement.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.util.List; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseContextManagement { @JsonProperty("applied_edits") private List appliedEdits; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseDeleteResponse.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseDeleteResponse { private String id; private String object; private Boolean deleted; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseError.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseError { private String code; private String message; private String type; private String param; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseIncompleteDetails.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseIncompleteDetails { private String reason; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseItem.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.util.List; import java.util.Map; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseItem { private String id; private String type; private String role; private String status; private Boolean partial; private List content; @JsonProperty("call_id") private String callId; private String name; private String arguments; private String output; @JsonProperty("server_label") private String serverLabel; private List summary; @JsonIgnore private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseRequest.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions; import lombok.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseRequest { @NonNull private String model; private Object input; private List include; private String instructions; @JsonProperty("previous_response_id") private String previousResponseId; @JsonProperty("max_output_tokens") private Integer maxOutputTokens; private Map metadata; @JsonProperty("parallel_tool_calls") private Boolean parallelToolCalls; private Object reasoning; private Boolean store; private Boolean stream; @JsonProperty("stream_options") private StreamOptions streamOptions; private Double temperature; private Object text; @JsonProperty("tool_choice") private Object toolChoice; private List tools; @JsonIgnore private List functions; @JsonIgnore private List mcpServices; @JsonProperty("top_p") private Double topP; private String truncation; private String user; @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonIgnore private StreamExecutionOptions streamExecution; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public List getFunctions() { return functions; } public void setFunctions(List functions) { this.functions = functions; } public List getMcpServices() { return mcpServices; } public void setMcpServices(List mcpServices) { this.mcpServices = mcpServices; } public static class ResponseRequestBuilder { private List functions; private List mcpServices; public ResponseRequestBuilder functions(String... functions) { if (this.functions == null) { this.functions = new ArrayList(); } this.functions.addAll(Arrays.asList(functions)); return this; } public ResponseRequestBuilder functions(List functions) { if (this.functions == null) { this.functions = new ArrayList(); } if (functions != null) { this.functions.addAll(functions); } return this; } public ResponseRequestBuilder mcpServices(String... mcpServices) { if (this.mcpServices == null) { this.mcpServices = new ArrayList(); } this.mcpServices.addAll(Arrays.asList(mcpServices)); return this; } public ResponseRequestBuilder mcpServices(List mcpServices) { if (this.mcpServices == null) { this.mcpServices = new ArrayList(); } if (mcpServices != null) { this.mcpServices.addAll(mcpServices); } return this; } public ResponseRequestBuilder mcpService(String mcpService) { if (this.mcpServices == null) { this.mcpServices = new ArrayList(); } this.mcpServices.add(mcpService); return this; } public ResponseRequestBuilder toolRegistry(List functions, List mcpServices) { functions(functions); mcpServices(mcpServices); return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseStreamEvent.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseStreamEvent { private String type; @JsonProperty("sequence_number") private Integer sequenceNumber; private Response response; @JsonProperty("output_index") private Integer outputIndex; @JsonProperty("content_index") private Integer contentIndex; @JsonProperty("item_id") private String itemId; private String delta; private String text; private String arguments; @JsonProperty("call_id") private String callId; private ResponseError error; @JsonIgnore private JsonNode raw; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseSummary.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseSummary { private String type; private String text; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseToolUsage.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseToolUsage { @JsonProperty("image_process") private Integer imageProcess; private Integer mcp; @JsonProperty("web_search") private Integer webSearch; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseToolUsageDetails.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseToolUsageDetails { @JsonProperty("image_process") private Object imageProcess; private Object mcp; @JsonProperty("web_search") private Object webSearch; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseUsage.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseUsage { @JsonProperty("input_tokens") private Integer inputTokens; @JsonProperty("output_tokens") private Integer outputTokens; @JsonProperty("total_tokens") private Integer totalTokens; @JsonProperty("input_tokens_details") private ResponseUsageDetails inputTokensDetails; @JsonProperty("output_tokens_details") private ResponseUsageDetails outputTokensDetails; @JsonProperty("tool_usage") private ResponseToolUsage toolUsage; @JsonProperty("tool_usage_details") private ResponseToolUsageDetails toolUsageDetails; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseUsageDetails.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseUsageDetails { @JsonProperty("cached_tokens") private Integer cachedTokens; @JsonProperty("text_tokens") private Integer textTokens; @JsonProperty("audio_tokens") private Integer audioTokens; @JsonProperty("image_tokens") private Integer imageTokens; @JsonProperty("reasoning_tokens") private Integer reasoningTokens; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/TranslationOptions.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response.entity; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class TranslationOptions { @JsonProperty("source_language") private String sourceLanguage; @JsonProperty("target_language") private String targetLanguage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/tool/Tool.java ================================================ package io.github.lnyocly.ai4j.platform.openai.tool; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; /** * @Author cly * @Description TODO * @Date 2024/8/12 14:55 */ @Data @AllArgsConstructor @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class Tool { /** * 工具类型,目前为“function” */ private String type; private Function function; @Data @AllArgsConstructor @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public static class Function { /** * 函数名称 */ private String name; /** * 函数描述 */ private String description; /** * 函数的参数 key为参数名称,value为参数属性 */ private Parameter parameters; @Data @AllArgsConstructor @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public static class Parameter { private String type = "object"; /** * 函数的参数 key为参数名称,value为参数属性 */ private Map properties; /** * 必须的参数 */ private List required; } @Data @AllArgsConstructor @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public static class Property { /** * 属性类型 */ private String type; /** * 属性描述 */ private String description; /** * 枚举项 */ @JsonProperty("enum") private List enumValues; /** * 数组元素类型定义(当type为array时使用) */ private Property items; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/tool/ToolCall.java ================================================ package io.github.lnyocly.ai4j.platform.openai.tool; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description TODO * @Date 2024/8/13 2:07 */ @Data @AllArgsConstructor @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ToolCall { private String id; private String type; private Function function; @Data @AllArgsConstructor @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public static class Function { private String name; private String arguments; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/usage/Usage.java ================================================ package io.github.lnyocly.ai4j.platform.openai.usage; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; /** * @Author cly * @Description TODO * @Date 2024/8/7 17:38 */ @Data @AllArgsConstructor @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class Usage implements Serializable { @JsonProperty("prompt_tokens") private long promptTokens = 0L; @JsonProperty("completion_tokens") private long completionTokens = 0L; @JsonProperty("total_tokens") private long totalTokens = 0L; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/standard/rerank/StandardRerankService.java ================================================ package io.github.lnyocly.ai4j.platform.standard.rerank; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.network.UrlUtils; import io.github.lnyocly.ai4j.rerank.entity.RerankDocument; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; import io.github.lnyocly.ai4j.rerank.entity.RerankResult; import io.github.lnyocly.ai4j.rerank.entity.RerankUsage; import io.github.lnyocly.ai4j.service.IRerankService; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import org.apache.commons.lang3.StringUtils; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class StandardRerankService implements IRerankService { private final OkHttpClient okHttpClient; private final String apiHost; private final String apiKey; private final String rerankUrl; private final ObjectMapper objectMapper = new ObjectMapper(); public StandardRerankService(OkHttpClient okHttpClient, String apiHost, String apiKey, String rerankUrl) { this.okHttpClient = okHttpClient; this.apiHost = apiHost; this.apiKey = apiKey; this.rerankUrl = rerankUrl; } @Override public RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) throws Exception { String host = resolveBaseUrl(baseUrl); String path = resolveRerankUrl(); Map body = new LinkedHashMap(); body.put("model", request.getModel()); body.put("query", request.getQuery()); body.put("documents", toRequestDocuments(request.getDocuments())); if (request.getTopN() != null) { body.put("top_n", request.getTopN()); } if (request.getReturnDocuments() != null) { body.put("return_documents", request.getReturnDocuments()); } if (StringUtils.isNotBlank(request.getInstruction())) { body.put("instruction", request.getInstruction()); } if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) { body.putAll(request.getExtraBody()); } Request.Builder builder = new Request.Builder() .url(UrlUtils.concatUrl(host, path)) .post(RequestBody.create(objectMapper.writeValueAsString(body), MediaType.get(Constants.JSON_CONTENT_TYPE))); String key = resolveApiKey(apiKey); if (StringUtils.isNotBlank(key)) { builder.header("Authorization", "Bearer " + key); } try (okhttp3.Response response = okHttpClient.newCall(builder.build()).execute()) { if (response.isSuccessful() && response.body() != null) { JsonNode root = objectMapper.readTree(response.body().string()); return toResponse(root, request); } } throw new CommonException("Standard rerank request failed"); } @Override public RerankResponse rerank(RerankRequest request) throws Exception { return rerank(null, null, request); } protected RerankResponse toResponse(JsonNode root, RerankRequest request) { List results = new ArrayList(); JsonNode resultsNode = root == null ? null : root.get("results"); if (resultsNode != null && resultsNode.isArray()) { for (JsonNode item : resultsNode) { if (item == null || item.isNull()) { continue; } results.add(RerankResult.builder() .index(item.has("index") ? item.get("index").asInt() : null) .relevanceScore(item.has("relevance_score") ? (float) item.get("relevance_score").asDouble() : null) .document(parseDocument(item.get("document"), request)) .build()); } } return RerankResponse.builder() .id(text(root, "id")) .model(firstNonBlank(text(root, "model"), request == null ? null : request.getModel())) .results(results) .usage(parseUsage(root == null ? null : root.get("usage"))) .build(); } protected RerankUsage parseUsage(JsonNode usageNode) { if (usageNode == null || usageNode.isNull()) { return null; } return RerankUsage.builder() .promptTokens(intValue(usageNode.get("prompt_tokens"))) .totalTokens(intValue(usageNode.get("total_tokens"))) .inputTokens(intValue(usageNode.get("input_tokens"))) .build(); } protected RerankDocument parseDocument(JsonNode documentNode, RerankRequest request) { if (documentNode == null || documentNode.isNull()) { return null; } if (documentNode.isTextual()) { String text = documentNode.asText(); return RerankDocument.builder().text(text).content(text).build(); } Map metadata = new LinkedHashMap(); JsonNode metadataNode = documentNode.get("metadata"); if (metadataNode != null && metadataNode.isObject()) { java.util.Iterator> iterator = metadataNode.fields(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); metadata.put(entry.getKey(), parseJsonValue(entry.getValue())); } } String text = firstNonBlank(text(documentNode, "text"), text(documentNode, "content")); return RerankDocument.builder() .id(text(documentNode, "id")) .text(text) .content(text) .title(text(documentNode, "title")) .metadata(metadata.isEmpty() ? null : metadata) .build(); } protected List toRequestDocuments(List documents) { if (documents == null || documents.isEmpty()) { return Collections.emptyList(); } List payload = new ArrayList(documents.size()); for (RerankDocument document : documents) { if (document == null) { continue; } String text = firstNonBlank(document.getText(), document.getContent()); if (StringUtils.isNotBlank(text) && StringUtils.isBlank(document.getId()) && StringUtils.isBlank(document.getTitle()) && (document.getMetadata() == null || document.getMetadata().isEmpty()) && document.getImage() == null) { payload.add(text); continue; } Map item = new LinkedHashMap(); if (StringUtils.isNotBlank(document.getId())) { item.put("id", document.getId()); } if (StringUtils.isNotBlank(text)) { item.put("text", text); } if (StringUtils.isNotBlank(document.getTitle())) { item.put("title", document.getTitle()); } if (document.getImage() != null) { item.put("image", document.getImage()); } if (document.getMetadata() != null && !document.getMetadata().isEmpty()) { item.put("metadata", document.getMetadata()); } payload.add(item); } return payload; } protected String resolveBaseUrl(String baseUrl) { String host = StringUtils.isBlank(baseUrl) ? apiHost : baseUrl; if (StringUtils.isBlank(host)) { throw new IllegalArgumentException("rerank apiHost is required"); } return host; } protected String resolveApiKey(String baseUrlApiKey) { return StringUtils.isBlank(baseUrlApiKey) ? apiKey : baseUrlApiKey; } protected String resolveRerankUrl() { if (StringUtils.isBlank(rerankUrl)) { throw new IllegalArgumentException("rerankUrl is required"); } return rerankUrl; } protected String text(JsonNode node, String fieldName) { if (node == null || fieldName == null || !node.has(fieldName) || node.get(fieldName).isNull()) { return null; } String text = node.get(fieldName).asText(); return StringUtils.isBlank(text) ? null : text; } protected Integer intValue(JsonNode node) { if (node == null || node.isNull()) { return null; } return node.asInt(); } protected Object parseJsonValue(JsonNode node) { if (node == null || node.isNull()) { return null; } if (node.isTextual()) { return node.asText(); } if (node.isInt() || node.isLong()) { return node.asLong(); } if (node.isFloat() || node.isDouble() || node.isBigDecimal()) { return node.asDouble(); } if (node.isBoolean()) { return node.asBoolean(); } if (node.isArray()) { List values = new ArrayList(); for (JsonNode child : node) { values.add(parseJsonValue(child)); } return values; } if (node.isObject()) { Map value = new LinkedHashMap(); java.util.Iterator> iterator = node.fields(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); value.put(entry.getKey(), parseJsonValue(entry.getValue())); } return value; } return node.asText(); } protected String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (StringUtils.isNotBlank(value)) { return value.trim(); } } return null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/zhipu/chat/ZhipuChatService.java ================================================ package io.github.lnyocly.ai4j.platform.zhipu.chat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.convert.chat.ParameterConvert; import io.github.lnyocly.ai4j.convert.chat.ResultConvert; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.platform.zhipu.chat.entity.ZhipuChatCompletion; import io.github.lnyocly.ai4j.platform.zhipu.chat.entity.ZhipuChatCompletionResponse; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.auth.BearerTokenUtils; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.network.UrlUtils; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** * @Author cly * @Description 智谱chat服务 * @Date 2024/8/27 17:29 */ public class ZhipuChatService implements IChatService, ParameterConvert, ResultConvert { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private static final String TOOL_CALLS_FINISH_REASON = "tool_calls"; private static final String FIRST_FINISH_REASON = "first"; private final ZhipuConfig zhipuConfig; private final OkHttpClient okHttpClient; private final EventSource.Factory factory; private final ObjectMapper objectMapper; public ZhipuChatService(Configuration configuration) { this.zhipuConfig = configuration.getZhipuConfig(); this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } public ZhipuChatService(Configuration configuration, ZhipuConfig zhipuConfig) { this.zhipuConfig = zhipuConfig; this.okHttpClient = configuration.getOkHttpClient(); this.factory = configuration.createRequestFactory(); this.objectMapper = new ObjectMapper(); } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, false); ZhipuChatCompletion zhipuChatCompletion = convertChatCompletionObject(chatCompletion); Usage allUsage = new Usage(); String token = resolveToken(resolvedApiKey); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { ZhipuChatCompletionResponse response = executeChatCompletionRequest( resolvedBaseUrl, token, zhipuChatCompletion ); if (response == null) { break; } Choice choice = response.getChoices().get(0); finishReason = choice.getFinishReason(); mergeUsage(allUsage, response.getUsage()); if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) { if (passThroughToolCalls) { response.setUsage(allUsage); response.setObject("chat.completion"); return convertChatCompletionResponse(response); } zhipuChatCompletion.setMessages(appendToolMessages( zhipuChatCompletion.getMessages(), choice.getMessage(), choice.getMessage().getToolCalls() )); continue; } response.setUsage(allUsage); response.setObject("chat.completion"); restoreOriginalRequest(chatCompletion, zhipuChatCompletion); return convertChatCompletionResponse(response); } return null; } finally { ToolUtil.popBuiltInToolContext(); } } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return chatCompletion(null, null, chatCompletion); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext()); try { String resolvedBaseUrl = resolveBaseUrl(baseUrl); String resolvedApiKey = resolveApiKey(apiKey); boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls()); prepareChatCompletion(chatCompletion, true); ZhipuChatCompletion zhipuChatCompletion = convertChatCompletionObject(chatCompletion); String token = resolveToken(resolvedApiKey); String finishReason = FIRST_FINISH_REASON; while (requiresFollowUp(finishReason)) { Request request = buildChatCompletionRequest(resolvedBaseUrl, token, zhipuChatCompletion); StreamExecutionSupport.execute( eventSourceListener, chatCompletion.getStreamExecution(), () -> factory.newEventSource(request, convertEventSource(eventSourceListener)) ); finishReason = eventSourceListener.getFinishReason(); List toolCalls = eventSourceListener.getToolCalls(); if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) { continue; } if (passThroughToolCalls) { return; } zhipuChatCompletion.setMessages(appendStreamToolMessages( zhipuChatCompletion.getMessages(), toolCalls )); resetToolCallState(eventSourceListener); } restoreOriginalRequest(chatCompletion, zhipuChatCompletion); } finally { ToolUtil.popBuiltInToolContext(); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { this.chatCompletionStream(null, null, chatCompletion, eventSourceListener); } @Override public ZhipuChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) { ZhipuChatCompletion zhipuChatCompletion = new ZhipuChatCompletion(); zhipuChatCompletion.setModel(chatCompletion.getModel()); zhipuChatCompletion.setMessages(chatCompletion.getMessages()); zhipuChatCompletion.setStream(chatCompletion.getStream()); if (chatCompletion.getTemperature() != null) { zhipuChatCompletion.setTemperature(chatCompletion.getTemperature() / 2); } if (chatCompletion.getTopP() != null) { zhipuChatCompletion.setTopP(chatCompletion.getTopP()); } zhipuChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion)); zhipuChatCompletion.setStop(chatCompletion.getStop()); zhipuChatCompletion.setTools(chatCompletion.getTools()); zhipuChatCompletion.setFunctions(chatCompletion.getFunctions()); zhipuChatCompletion.setToolChoice(chatCompletion.getToolChoice()); zhipuChatCompletion.setExtraBody(chatCompletion.getExtraBody()); return zhipuChatCompletion; } @Override public EventSourceListener convertEventSource(final SseListener eventSourceListener) { return new EventSourceListener() { @Override public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { eventSourceListener.onOpen(eventSource, response); } @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { eventSourceListener.onFailure(eventSource, t, response); } @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { if ("[DONE]".equalsIgnoreCase(data)) { eventSourceListener.onEvent(eventSource, id, type, data); return; } eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data)); } @Override public void onClosed(@NotNull EventSource eventSource) { eventSourceListener.onClosed(eventSource); } }; } @Override public ChatCompletionResponse convertChatCompletionResponse(ZhipuChatCompletionResponse zhipuChatCompletionResponse) { ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse(); chatCompletionResponse.setId(zhipuChatCompletionResponse.getId()); chatCompletionResponse.setCreated(zhipuChatCompletionResponse.getCreated()); chatCompletionResponse.setModel(zhipuChatCompletionResponse.getModel()); chatCompletionResponse.setChoices(zhipuChatCompletionResponse.getChoices()); chatCompletionResponse.setUsage(zhipuChatCompletionResponse.getUsage()); return chatCompletionResponse; } private String serializeStreamResponse(String data) { try { ZhipuChatCompletionResponse chatCompletionResponse = objectMapper.readValue(data, ZhipuChatCompletionResponse.class); chatCompletionResponse.setObject("chat.completion.chunk"); ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse); return objectMapper.writeValueAsString(response); } catch (JsonProcessingException e) { throw new CommonException("Zhipu Chat 对象JSON序列化出错"); } } private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) { chatCompletion.setStream(stream); if (!stream) { chatCompletion.setStreamOptions(null); } attachTools(chatCompletion); } private void attachTools(ChatCompletion chatCompletion) { if (hasPendingTools(chatCompletion)) { List tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices()); chatCompletion.setTools(tools); if (tools == null) { chatCompletion.setParallelToolCalls(null); } } if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) { chatCompletion.setParallelToolCalls(null); } } private boolean hasPendingTools(ChatCompletion chatCompletion) { return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty()); } private boolean requiresFollowUp(String finishReason) { return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason); } private ZhipuChatCompletionResponse executeChatCompletionRequest( String baseUrl, String token, ZhipuChatCompletion zhipuChatCompletion ) throws Exception { Request request = buildChatCompletionRequest(baseUrl, token, zhipuChatCompletion); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { return objectMapper.readValue(response.body().string(), ZhipuChatCompletionResponse.class); } } return null; } private Request buildChatCompletionRequest(String baseUrl, String token, ZhipuChatCompletion zhipuChatCompletion) throws JsonProcessingException { String requestBody = objectMapper.writeValueAsString(zhipuChatCompletion); return new Request.Builder() .header("Authorization", "Bearer " + token) .url(UrlUtils.concatUrl(baseUrl, zhipuConfig.getChatCompletionUrl())) .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) .build(); } private void mergeUsage(Usage target, Usage usage) { if (usage == null) { return; } target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens()); target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens()); target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens()); } private List appendToolMessages( List messages, ChatMessage assistantMessage, List toolCalls ) { List updatedMessages = new ArrayList(messages); updatedMessages.add(assistantMessage); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private List appendStreamToolMessages(List messages, List toolCalls) { List updatedMessages = new ArrayList(messages); updatedMessages.add(ChatMessage.withAssistant(toolCalls)); appendToolResponses(updatedMessages, toolCalls); return updatedMessages; } private void appendToolResponses(List messages, List toolCalls) { for (ToolCall toolCall : toolCalls) { String functionName = toolCall.getFunction().getName(); String arguments = toolCall.getFunction().getArguments(); String functionResponse = ToolUtil.invoke(functionName, arguments); messages.add(ChatMessage.withTool(functionResponse, toolCall.getId())); } } private void resetToolCallState(SseListener eventSourceListener) { eventSourceListener.setToolCalls(new ArrayList()); eventSourceListener.setToolCall(null); } private void restoreOriginalRequest(ChatCompletion chatCompletion, ZhipuChatCompletion zhipuChatCompletion) { chatCompletion.setMessages(zhipuChatCompletion.getMessages()); chatCompletion.setTools(zhipuChatCompletion.getTools()); } private String resolveBaseUrl(String baseUrl) { return (baseUrl == null || "".equals(baseUrl)) ? zhipuConfig.getApiHost() : baseUrl; } private String resolveApiKey(String apiKey) { return (apiKey == null || "".equals(apiKey)) ? zhipuConfig.getApiKey() : apiKey; } private String resolveToken(String apiKey) { return BearerTokenUtils.getToken(apiKey); } @SuppressWarnings("deprecation") private Integer resolveMaxTokens(ChatCompletion chatCompletion) { if (chatCompletion.getMaxCompletionTokens() != null) { return chatCompletion.getMaxCompletionTokens(); } return chatCompletion.getMaxTokens(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/zhipu/chat/entity/ZhipuChatCompletion.java ================================================ package io.github.lnyocly.ai4j.platform.zhipu.chat.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import lombok.*; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * @Author cly * @Description 智谱对话实体类 * @Date 2024/8/27 17:39 */ @Data @Builder(toBuilder = true) @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ZhipuChatCompletion { @NonNull private String model; @NonNull private List messages; @JsonProperty("request_id") private String requestId; @Builder.Default @JsonProperty("do_sample") private Boolean doSample = true; @Builder.Default private Boolean stream = false; /** * 采样温度,控制输出的随机性,必须为正数。值越大,会使输出更随机 * [0.0, 1.0] */ @Builder.Default private Float temperature = 0.95f; /** * 核取样 * [0.0, 1.0] */ @Builder.Default @JsonProperty("top_p") private Float topP = 0.7f; @JsonProperty("max_tokens") private Integer maxTokens; private List stop; private List tools; /** * 辅助属性 */ @JsonIgnore private List functions; @JsonProperty("tool_choice") private String toolChoice; @JsonProperty("user_id") private String userId; /** * 额外的请求体参数,用于扩展不同平台的特定字段 * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层 */ @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } public static class ZhipuChatCompletionBuilder { private List functions; public ZhipuChatCompletion.ZhipuChatCompletionBuilder functions(String... functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } this.functions.addAll(Arrays.asList(functions)); return this; } public ZhipuChatCompletion.ZhipuChatCompletionBuilder functions(List functions){ if (this.functions == null) { this.functions = new ArrayList<>(); } if (functions != null) { this.functions.addAll(functions); } return this; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/platform/zhipu/chat/entity/ZhipuChatCompletionResponse.java ================================================ package io.github.lnyocly.ai4j.platform.zhipu.chat.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description 智谱聊天请求响应实体类 * @Date 2024/8/27 20:34 */ @Data @NoArgsConstructor() @AllArgsConstructor() @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class ZhipuChatCompletionResponse { private String id; private String object; private Long created; private String model; private List choices; private Usage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/AbstractScoreFusionStrategy.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.List; abstract class AbstractScoreFusionStrategy implements FusionStrategy { @Override public List scoreContributions(List hits) { if (hits == null || hits.isEmpty()) { return Collections.emptyList(); } List rawScores = new ArrayList(hits.size()); for (RagHit hit : hits) { if (hit == null || hit.getScore() == null) { return fallbackRankScores(hits.size()); } rawScores.add((double) hit.getScore()); } if (!hasVariance(rawScores)) { return fallbackRankScores(hits.size()); } return scoreWithRawScores(rawScores); } protected abstract List scoreWithRawScores(List rawScores); protected List fallbackRankScores(int size) { List scores = new ArrayList(size); for (int i = 0; i < size; i++) { scores.add(1.0d / (i + 1)); } return scores; } private boolean hasVariance(List rawScores) { if (rawScores == null || rawScores.size() <= 1) { return false; } double min = Double.POSITIVE_INFINITY; double max = Double.NEGATIVE_INFINITY; for (Double rawScore : rawScores) { if (rawScore == null) { continue; } if (rawScore < min) { min = rawScore; } if (rawScore > max) { max = rawScore; } } return Double.isFinite(min) && Double.isFinite(max) && Math.abs(max - min) > 1.0e-9d; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/Bm25Retriever.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class Bm25Retriever implements Retriever { private final List corpus; private final TextTokenizer tokenizer; private final double k1; private final double b; private final Map> termFrequencies; private final Map documentFrequencies; private final double averageDocumentLength; public Bm25Retriever(List corpus) { this(corpus, new DefaultTextTokenizer(), 1.5d, 0.75d); } public Bm25Retriever(List corpus, TextTokenizer tokenizer, double k1, double b) { this.corpus = corpus == null ? Collections.emptyList() : new ArrayList(corpus); this.tokenizer = tokenizer == null ? new DefaultTextTokenizer() : tokenizer; this.k1 = k1; this.b = b; this.termFrequencies = new HashMap>(); this.documentFrequencies = new LinkedHashMap(); this.averageDocumentLength = buildIndex(); } @Override public List retrieve(RagQuery query) { if (query == null || query.getQuery() == null || query.getQuery().trim().isEmpty() || corpus.isEmpty()) { return Collections.emptyList(); } List terms = tokenizer.tokenize(query.getQuery()); if (terms.isEmpty()) { return Collections.emptyList(); } List hits = new ArrayList(); for (int i = 0; i < corpus.size(); i++) { RagHit hit = corpus.get(i); double score = scoreDocument(i, terms); if (score <= 0.0d) { continue; } hits.add(copyWithScore(hit, (float) score)); } Collections.sort(hits, new Comparator() { @Override public int compare(RagHit left, RagHit right) { float l = left == null || left.getScore() == null ? 0.0f : left.getScore(); float r = right == null || right.getScore() == null ? 0.0f : right.getScore(); return Float.compare(r, l); } }); int limit = query.getTopK() == null || query.getTopK() <= 0 ? hits.size() : Math.min(query.getTopK(), hits.size()); return RagHitSupport.prepareRetrievedHits(new ArrayList(hits.subList(0, limit)), retrieverSource()); } @Override public String retrieverSource() { return "bm25"; } private double buildIndex() { if (corpus.isEmpty()) { return 0.0d; } int totalLength = 0; for (int i = 0; i < corpus.size(); i++) { RagHit hit = corpus.get(i); List tokens = tokenizer.tokenize(hit == null ? null : hit.getContent()); totalLength += tokens.size(); Map frequencies = new HashMap(); for (String token : tokens) { Integer count = frequencies.get(token); frequencies.put(token, count == null ? 1 : count + 1); } termFrequencies.put(i, frequencies); for (String token : frequencies.keySet()) { Integer count = documentFrequencies.get(token); documentFrequencies.put(token, count == null ? 1 : count + 1); } } return totalLength == 0 ? 0.0d : (double) totalLength / (double) corpus.size(); } private double scoreDocument(int index, List terms) { Map frequencies = termFrequencies.get(index); if (frequencies == null || frequencies.isEmpty()) { return 0.0d; } int documentLength = 0; for (Integer value : frequencies.values()) { documentLength += value == null ? 0 : value; } double score = 0.0d; for (String term : terms) { Integer tf = frequencies.get(term); Integer df = documentFrequencies.get(term); if (tf == null || tf <= 0 || df == null || df <= 0) { continue; } double idf = Math.log(1.0d + (corpus.size() - df + 0.5d) / (df + 0.5d)); double numerator = tf * (k1 + 1.0d); double denominator = tf + k1 * (1.0d - b + b * documentLength / Math.max(1.0d, averageDocumentLength)); score += idf * (numerator / denominator); } return score; } private RagHit copyWithScore(RagHit hit, float score) { if (hit == null) { return null; } return RagHit.builder() .id(hit.getId()) .score(score) .retrievalScore(score) .content(hit.getContent()) .metadata(hit.getMetadata()) .documentId(hit.getDocumentId()) .sourceName(hit.getSourceName()) .sourcePath(hit.getSourcePath()) .sourceUri(hit.getSourceUri()) .pageNumber(hit.getPageNumber()) .sectionTitle(hit.getSectionTitle()) .chunkIndex(hit.getChunkIndex()) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DbsfFusionStrategy.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.List; public class DbsfFusionStrategy extends AbstractScoreFusionStrategy { @Override protected List scoreWithRawScores(List rawScores) { double mean = 0.0d; for (Double rawScore : rawScores) { mean += rawScore; } mean = mean / rawScores.size(); double variance = 0.0d; for (Double rawScore : rawScores) { double delta = rawScore - mean; variance += delta * delta; } double standardDeviation = Math.sqrt(variance / rawScores.size()); List scores = new ArrayList(rawScores.size()); for (Double rawScore : rawScores) { double zScore = (rawScore - mean) / standardDeviation; scores.add(1.0d / (1.0d + Math.exp(-zScore))); } return scores; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DefaultRagContextAssembler.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class DefaultRagContextAssembler implements RagContextAssembler { @Override public RagContext assemble(RagQuery query, List hits) { if (hits == null || hits.isEmpty()) { return RagContext.builder() .text("") .citations(Collections.emptyList()) .build(); } String delimiter = query == null || query.getDelimiter() == null ? "\n\n" : query.getDelimiter(); boolean includeCitations = query == null || query.isIncludeCitations(); List citations = new ArrayList(); StringBuilder context = new StringBuilder(); int index = 1; for (RagHit hit : hits) { if (hit == null || hit.getContent() == null || hit.getContent().trim().isEmpty()) { continue; } RagCitation citation = RagCitation.builder() .citationId("S" + index) .sourceName(hit.getSourceName()) .sourcePath(hit.getSourcePath()) .sourceUri(hit.getSourceUri()) .pageNumber(hit.getPageNumber()) .sectionTitle(hit.getSectionTitle()) .snippet(hit.getContent()) .build(); citations.add(citation); if (context.length() > 0) { context.append(delimiter); } if (includeCitations) { context.append("[").append(citation.getCitationId()).append("] "); appendSourceLabel(context, citation); context.append("\n"); } context.append(hit.getContent()); index++; } return RagContext.builder() .text(context.toString()) .citations(citations) .build(); } private void appendSourceLabel(StringBuilder builder, RagCitation citation) { String sourceName = trimToNull(citation.getSourceName()); String sourcePath = trimToNull(citation.getSourcePath()); if (sourceName != null) { builder.append(sourceName); } else if (sourcePath != null) { builder.append(sourcePath); } else { builder.append("source"); } if (citation.getPageNumber() != null) { builder.append(" / p.").append(citation.getPageNumber()); } String sectionTitle = trimToNull(citation.getSectionTitle()); if (sectionTitle != null) { builder.append(" / ").append(sectionTitle); } } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DefaultRagService.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class DefaultRagService implements RagService { private final Retriever retriever; private final Reranker reranker; private final RagContextAssembler contextAssembler; public DefaultRagService(Retriever retriever) { this(retriever, new NoopReranker(), new DefaultRagContextAssembler()); } public DefaultRagService(Retriever retriever, Reranker reranker, RagContextAssembler contextAssembler) { if (retriever == null) { throw new IllegalArgumentException("retriever is required"); } this.retriever = retriever; this.reranker = reranker == null ? new NoopReranker() : reranker; this.contextAssembler = contextAssembler == null ? new DefaultRagContextAssembler() : contextAssembler; } @Override public RagResult search(RagQuery query) throws Exception { List hits = RagHitSupport.prepareRetrievedHits(retriever.retrieve(query), retriever.retrieverSource()); List rerankInput = RagHitSupport.copyList(hits); List reranked = RagHitSupport.prepareRerankedHits( hits, reranker.rerank(query == null ? null : query.getQuery(), rerankInput), !(reranker instanceof NoopReranker) ); List finalHits = trim(reranked, query == null ? null : query.getFinalTopK()); RagContext context = contextAssembler.assemble(query, finalHits); return RagResult.builder() .query(query == null ? null : query.getQuery()) .hits(finalHits) .context(context == null ? "" : context.getText()) .citations(context == null ? Collections.emptyList() : context.getCitations()) .sources(context == null ? Collections.emptyList() : context.getCitations()) .trace(query != null && query.isIncludeTrace() ? RagTrace.builder().retrievedHits(hits).rerankedHits(reranked).build() : null) .build(); } private List trim(List hits, Integer finalTopK) { if (hits == null || hits.isEmpty()) { return Collections.emptyList(); } if (finalTopK == null || finalTopK <= 0 || hits.size() <= finalTopK) { return new ArrayList(hits); } return new ArrayList(hits.subList(0, finalTopK)); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DefaultTextTokenizer.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; public class DefaultTextTokenizer implements TextTokenizer { @Override public List tokenize(String text) { if (text == null || text.trim().isEmpty()) { return Collections.emptyList(); } List tokens = new ArrayList(); StringBuilder latin = new StringBuilder(); char[] chars = text.toLowerCase(Locale.ROOT).toCharArray(); for (char ch : chars) { if (Character.isLetterOrDigit(ch) && !isCjk(ch)) { latin.append(ch); continue; } flushLatin(tokens, latin); if (isCjk(ch)) { tokens.add(String.valueOf(ch)); } } flushLatin(tokens, latin); return tokens; } private void flushLatin(List tokens, StringBuilder latin) { if (latin.length() == 0) { return; } tokens.add(latin.toString()); latin.setLength(0); } private boolean isCjk(char ch) { Character.UnicodeBlock block = Character.UnicodeBlock.of(ch); return Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS.equals(block) || Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A.equals(block) || Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B.equals(block) || Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS.equals(block) || Character.UnicodeBlock.HIRAGANA.equals(block) || Character.UnicodeBlock.KATAKANA.equals(block) || Character.UnicodeBlock.HANGUL_SYLLABLES.equals(block); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DenseRetriever.java ================================================ package io.github.lnyocly.ai4j.rag; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; public class DenseRetriever implements Retriever { private final IEmbeddingService embeddingService; private final VectorStore vectorStore; public DenseRetriever(IEmbeddingService embeddingService, VectorStore vectorStore) { if (embeddingService == null) { throw new IllegalArgumentException("embeddingService is required"); } if (vectorStore == null) { throw new IllegalArgumentException("vectorStore is required"); } this.embeddingService = embeddingService; this.vectorStore = vectorStore; } @Override public List retrieve(RagQuery query) throws Exception { if (query == null || query.getQuery() == null || query.getQuery().trim().isEmpty()) { return Collections.emptyList(); } if (query.getEmbeddingModel() == null || query.getEmbeddingModel().trim().isEmpty()) { throw new IllegalArgumentException("embeddingModel is required"); } EmbeddingResponse response = embeddingService.embedding(Embedding.builder() .model(query.getEmbeddingModel()) .input(query.getQuery()) .build()); List data = response == null ? null : response.getData(); if (data == null || data.isEmpty() || data.get(0) == null || data.get(0).getEmbedding() == null) { throw new IllegalStateException("Failed to generate query embedding"); } List searchResults = vectorStore.search(VectorSearchRequest.builder() .dataset(query.getDataset()) .vector(data.get(0).getEmbedding()) .topK(query.getTopK()) .filter(query.getFilter()) .includeMetadata(Boolean.TRUE) .includeVector(Boolean.FALSE) .build()); if (searchResults == null || searchResults.isEmpty()) { return Collections.emptyList(); } List hits = new ArrayList(); for (VectorSearchResult result : searchResults) { if (result == null) { continue; } Map metadata = result.getMetadata(); hits.add(RagHit.builder() .id(result.getId()) .score(result.getScore()) .retrievalScore(result.getScore()) .content(firstNonBlank(result.getContent(), metadataValue(metadata, RagMetadataKeys.CONTENT))) .metadata(metadata) .documentId(metadataValue(metadata, RagMetadataKeys.DOCUMENT_ID)) .sourceName(firstNonBlank( metadataValue(metadata, RagMetadataKeys.SOURCE_NAME), metadataValue(metadata, "source"), metadataValue(metadata, "fileName"))) .sourcePath(metadataValue(metadata, RagMetadataKeys.SOURCE_PATH)) .sourceUri(metadataValue(metadata, RagMetadataKeys.SOURCE_URI)) .pageNumber(intValue(metadata == null ? null : metadata.get(RagMetadataKeys.PAGE_NUMBER))) .sectionTitle(metadataValue(metadata, RagMetadataKeys.SECTION_TITLE)) .chunkIndex(intValue(metadata == null ? null : metadata.get(RagMetadataKeys.CHUNK_INDEX))) .build()); } return RagHitSupport.prepareRetrievedHits(hits, retrieverSource()); } @Override public String retrieverSource() { return "dense"; } private String metadataValue(Map metadata, String key) { if (metadata == null || key == null) { return null; } Object value = metadata.get(key); if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } private Integer intValue(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).intValue(); } try { return Integer.parseInt(String.valueOf(value).trim()); } catch (Exception ignore) { return null; } } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/FusionStrategy.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.List; public interface FusionStrategy { List scoreContributions(List hits); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/HybridRetriever.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class HybridRetriever implements Retriever { private final List retrievers; private final FusionStrategy fusionStrategy; public HybridRetriever(List retrievers) { this(retrievers, new RrfFusionStrategy()); } public HybridRetriever(List retrievers, int rankConstant) { this(retrievers, new RrfFusionStrategy(rankConstant)); } public HybridRetriever(List retrievers, FusionStrategy fusionStrategy) { this.retrievers = retrievers == null ? Collections.emptyList() : new ArrayList(retrievers); this.fusionStrategy = fusionStrategy == null ? new RrfFusionStrategy() : fusionStrategy; } @Override public List retrieve(RagQuery query) throws Exception { if (retrievers.isEmpty()) { return Collections.emptyList(); } Map merged = new LinkedHashMap(); for (Retriever retriever : retrievers) { if (retriever == null) { continue; } List hits = RagHitSupport.prepareRetrievedHits(retriever.retrieve(query), retriever.retrieverSource()); if (hits == null) { continue; } List contributions = fusionStrategy.scoreContributions(hits); for (int i = 0; i < hits.size(); i++) { RagHit hit = hits.get(i); if (hit == null) { continue; } String key = keyOf(hit, i); RankedHit ranked = merged.get(key); if (ranked == null) { ranked = new RankedHit(hit); merged.put(key, ranked); } double contribution = contributionOf(contributions, i); ranked.score += contribution; ranked.addDetail(retriever.retrieverSource(), i + 1, retrievalScoreOf(hit), (float) contribution); if (ranked.hit.getScore() == null || (hit.getScore() != null && hit.getScore() > ranked.hit.getScore())) { ranked.hit = RagHitSupport.copy(hit); } } } List result = new ArrayList(); for (RankedHit ranked : merged.values()) { RagHit hit = RagHitSupport.copy(ranked.hit); hit.setRetrieverSource(retrieverSource()); hit.setRetrievalScore(ranked.bestRetrievalScore); hit.setFusionScore((float) ranked.score); hit.setScore((float) ranked.score); hit.setScoreDetails(ranked.details); result.add(hit); } Collections.sort(result, new Comparator() { @Override public int compare(RagHit left, RagHit right) { float l = left == null || left.getScore() == null ? 0.0f : left.getScore(); float r = right == null || right.getScore() == null ? 0.0f : right.getScore(); return Float.compare(r, l); } }); int limit = query == null || query.getTopK() == null || query.getTopK() <= 0 ? result.size() : Math.min(query.getTopK(), result.size()); return RagHitSupport.prepareRetrievedHits(new ArrayList(result.subList(0, limit)), retrieverSource()); } @Override public String retrieverSource() { return "hybrid"; } private double contributionOf(List contributions, int index) { if (contributions == null || index < 0 || index >= contributions.size()) { return 0.0d; } Double contribution = contributions.get(index); if (contribution == null || Double.isNaN(contribution) || Double.isInfinite(contribution)) { return 0.0d; } return contribution; } private String keyOf(RagHit hit, int fallbackIndex) { if (hit.getId() != null && !hit.getId().trim().isEmpty()) { return hit.getId().trim(); } if (hit.getDocumentId() != null && !hit.getDocumentId().trim().isEmpty()) { return hit.getDocumentId().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } if (hit.getSourcePath() != null && !hit.getSourcePath().trim().isEmpty()) { return hit.getSourcePath().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } if (hit.getSourceUri() != null && !hit.getSourceUri().trim().isEmpty()) { return hit.getSourceUri().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } if (hit.getSourceName() != null && !hit.getSourceName().trim().isEmpty() && hit.getSectionTitle() != null && !hit.getSectionTitle().trim().isEmpty()) { return hit.getSourceName().trim() + "#" + hit.getSectionTitle().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } String content = hit.getContent() == null ? "" : hit.getContent(); if (!content.trim().isEmpty()) { return content.trim(); } return String.valueOf(fallbackIndex); } private String normalizeIndex(Integer chunkIndex) { return chunkIndex == null ? "-" : String.valueOf(chunkIndex); } private Float retrievalScoreOf(RagHit hit) { if (hit == null) { return null; } if (hit.getRetrievalScore() != null) { return hit.getRetrievalScore(); } return hit.getScore(); } private static class RankedHit { private RagHit hit; private double score; private Float bestRetrievalScore; private List details; private RankedHit(RagHit hit) { this.hit = copyStatic(hit); this.bestRetrievalScore = hit == null ? null : (hit.getRetrievalScore() != null ? hit.getRetrievalScore() : hit.getScore()); this.details = new ArrayList(); } private void addDetail(String source, int rank, Float retrievalScore, Float fusionContribution) { details.add(RagScoreDetail.builder() .source(source) .rank(rank) .retrievalScore(retrievalScore) .fusionContribution(fusionContribution) .build()); if (retrievalScore != null && (bestRetrievalScore == null || retrievalScore > bestRetrievalScore)) { bestRetrievalScore = retrievalScore; } } private static RagHit copyStatic(RagHit hit) { return RagHitSupport.copy(hit); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ModelReranker.java ================================================ package io.github.lnyocly.ai4j.rag; import io.github.lnyocly.ai4j.rerank.entity.RerankDocument; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; import io.github.lnyocly.ai4j.rerank.entity.RerankResult; import io.github.lnyocly.ai4j.service.IRerankService; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; public class ModelReranker implements Reranker { private final IRerankService rerankService; private final String model; private final Integer topN; private final String instruction; private final boolean returnDocuments; private final boolean appendRemainingHits; public ModelReranker(IRerankService rerankService, String model) { this(rerankService, model, null, null, false, true); } public ModelReranker(IRerankService rerankService, String model, Integer topN, String instruction, boolean returnDocuments, boolean appendRemainingHits) { if (rerankService == null) { throw new IllegalArgumentException("rerankService is required"); } if (model == null || model.trim().isEmpty()) { throw new IllegalArgumentException("rerank model is required"); } this.rerankService = rerankService; this.model = model.trim(); this.topN = topN; this.instruction = instruction; this.returnDocuments = returnDocuments; this.appendRemainingHits = appendRemainingHits; } @Override public List rerank(String query, List hits) throws Exception { if (hits == null || hits.isEmpty()) { return Collections.emptyList(); } if (query == null || query.trim().isEmpty()) { return RagHitSupport.copyList(hits); } List sourceHits = RagHitSupport.copyList(hits); int rerankTopN = topN == null || topN <= 0 ? sourceHits.size() : Math.min(topN, sourceHits.size()); List documents = new ArrayList(sourceHits.size()); for (RagHit hit : sourceHits) { if (hit == null) { continue; } documents.add(RerankDocument.builder() .id(RagHitSupport.stableKey(hit)) .text(hit.getContent()) .content(hit.getContent()) .title(hit.getSectionTitle()) .metadata(hit.getMetadata()) .build()); } RerankResponse response = rerankService.rerank(RerankRequest.builder() .model(model) .query(query) .documents(documents) .topN(rerankTopN) .returnDocuments(returnDocuments) .instruction(instruction) .build()); List results = response == null ? null : response.getResults(); if (results == null || results.isEmpty()) { return sourceHits; } Map byIndex = new LinkedHashMap(); for (int i = 0; i < sourceHits.size(); i++) { byIndex.put(i, sourceHits.get(i)); } List reranked = new ArrayList(); Set consumed = new LinkedHashSet(); for (RerankResult result : results) { if (result == null || result.getIndex() == null) { continue; } RagHit hit = byIndex.get(result.getIndex()); if (hit == null) { continue; } RagHit copy = RagHitSupport.copy(hit); copy.setRerankScore(result.getRelevanceScore()); if (returnDocuments && result.getDocument() != null && result.getDocument().getContent() != null) { copy.setContent(result.getDocument().getContent()); } copy.setScore(result.getRelevanceScore()); reranked.add(copy); consumed.add(result.getIndex()); } if (appendRemainingHits) { for (int i = 0; i < sourceHits.size(); i++) { if (consumed.contains(i)) { continue; } reranked.add(RagHitSupport.copy(sourceHits.get(i))); } } return reranked; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/NoopReranker.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class NoopReranker implements Reranker { @Override public List rerank(String query, List hits) { if (hits == null || hits.isEmpty()) { return Collections.emptyList(); } return new ArrayList(hits); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagChunk.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagChunk { private String chunkId; private String documentId; private String content; private Integer chunkIndex; private Integer pageNumber; private String sectionTitle; private Map metadata; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagCitation.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagCitation { private String citationId; private String sourceName; private String sourcePath; private String sourceUri; private Integer pageNumber; private String sectionTitle; private String snippet; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagContext.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagContext { private String text; @Builder.Default private List citations = Collections.emptyList(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagContextAssembler.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.List; public interface RagContextAssembler { RagContext assemble(RagQuery query, List hits); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagDocument.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagDocument { private String documentId; private String sourceName; private String sourcePath; private String sourceUri; private String title; private String tenant; private String biz; private String version; private Map metadata; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagEvaluation.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagEvaluation { private Integer evaluatedAtK; private Integer retrievedCount; private Integer relevantCount; private Integer truePositiveCount; private Double precisionAtK; private Double recallAtK; private Double f1AtK; private Double mrr; private Double ndcg; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagEvaluator.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; public class RagEvaluator { public RagEvaluation evaluate(List hits, Collection relevantIds) { int topK = hits == null ? 0 : hits.size(); return evaluate(hits, relevantIds, topK); } public RagEvaluation evaluate(List hits, Collection relevantIds, int topK) { List safeHits = hits == null ? Collections.emptyList() : hits; Set relevant = normalize(relevantIds); int limit = topK <= 0 ? safeHits.size() : Math.min(topK, safeHits.size()); int truePositiveCount = 0; double reciprocalRank = 0.0d; double dcg = 0.0d; for (int i = 0; i < limit; i++) { RagHit hit = safeHits.get(i); String key = RagHitSupport.stableKey(hit, i); if (!relevant.contains(key)) { continue; } truePositiveCount++; if (reciprocalRank == 0.0d) { reciprocalRank = 1.0d / (i + 1); } dcg += 1.0d / log2(i + 2); } int idealCount = Math.min(limit, relevant.size()); double idcg = 0.0d; for (int i = 0; i < idealCount; i++) { idcg += 1.0d / log2(i + 2); } double denominator = limit <= 0 ? 1.0d : (double) limit; double precision = limit <= 0 ? 0.0d : truePositiveCount / denominator; double recall = relevant.isEmpty() ? 0.0d : (double) truePositiveCount / (double) relevant.size(); double f1 = precision + recall == 0.0d ? 0.0d : 2.0d * precision * recall / (precision + recall); double ndcg = idcg == 0.0d ? 0.0d : dcg / idcg; return RagEvaluation.builder() .evaluatedAtK(limit) .retrievedCount(safeHits.size()) .relevantCount(relevant.size()) .truePositiveCount(truePositiveCount) .precisionAtK(precision) .recallAtK(recall) .f1AtK(f1) .mrr(reciprocalRank) .ndcg(ndcg) .build(); } private Set normalize(Collection relevantIds) { if (relevantIds == null || relevantIds.isEmpty()) { return Collections.emptySet(); } Set normalized = new LinkedHashSet(); for (String relevantId : relevantIds) { if (relevantId == null || relevantId.trim().isEmpty()) { continue; } normalized.add(relevantId.trim()); } return normalized; } private double log2(int value) { return Math.log(value) / Math.log(2.0d); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagHit.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagHit { private String id; private Float score; private Integer rank; private String retrieverSource; private Float retrievalScore; private Float fusionScore; private Float rerankScore; private String content; private Map metadata; private String documentId; private String sourceName; private String sourcePath; private String sourceUri; private Integer pageNumber; private String sectionTitle; private Integer chunkIndex; private List scoreDetails; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagHitSupport.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; final class RagHitSupport { private RagHitSupport() { } static List copyList(List hits) { if (hits == null || hits.isEmpty()) { return Collections.emptyList(); } List copies = new ArrayList(hits.size()); for (RagHit hit : hits) { if (hit == null) { continue; } copies.add(copy(hit)); } return copies; } static RagHit copy(RagHit hit) { if (hit == null) { return null; } return RagHit.builder() .id(hit.getId()) .score(hit.getScore()) .rank(hit.getRank()) .retrieverSource(hit.getRetrieverSource()) .retrievalScore(hit.getRetrievalScore()) .fusionScore(hit.getFusionScore()) .rerankScore(hit.getRerankScore()) .content(hit.getContent()) .metadata(copyMetadata(hit.getMetadata())) .documentId(hit.getDocumentId()) .sourceName(hit.getSourceName()) .sourcePath(hit.getSourcePath()) .sourceUri(hit.getSourceUri()) .pageNumber(hit.getPageNumber()) .sectionTitle(hit.getSectionTitle()) .chunkIndex(hit.getChunkIndex()) .scoreDetails(copyScoreDetails(hit.getScoreDetails())) .build(); } static List prepareRetrievedHits(List hits, String retrieverSource) { List prepared = copyList(hits); assignRanks(prepared); for (RagHit hit : prepared) { if (hit == null) { continue; } if (isBlank(hit.getRetrieverSource())) { hit.setRetrieverSource(retrieverSource); } if (hit.getRetrievalScore() == null && hit.getScore() != null) { hit.setRetrievalScore(hit.getScore()); } normalizeEffectiveScore(hit); if ((hit.getScoreDetails() == null || hit.getScoreDetails().isEmpty()) && hit.getRetrievalScore() != null && !isBlank(hit.getRetrieverSource()) && !"hybrid".equalsIgnoreCase(hit.getRetrieverSource())) { hit.setScoreDetails(Collections.singletonList(RagScoreDetail.builder() .source(hit.getRetrieverSource()) .rank(hit.getRank()) .retrievalScore(hit.getRetrievalScore()) .build())); } } return prepared; } static List prepareRerankedHits(List retrievedHits, List rerankedHits, boolean rerankApplied) { List safeReranked = rerankedHits == null ? Collections.emptyList() : rerankedHits; Map retrievedIndex = new LinkedHashMap(); for (int i = 0; i < retrievedHits.size(); i++) { RagHit hit = retrievedHits.get(i); if (hit == null) { continue; } retrievedIndex.put(stableKey(hit, i), hit); } List merged = new ArrayList(safeReranked.size()); for (int i = 0; i < safeReranked.size(); i++) { RagHit current = safeReranked.get(i); if (current == null) { continue; } RagHit original = retrievedIndex.get(stableKey(current, i)); RagHit mergedHit = merge(original, current); if (rerankApplied && current.getScore() != null) { mergedHit.setRerankScore(current.getScore()); } merged.add(mergedHit); } assignRanks(merged); for (RagHit hit : merged) { normalizeEffectiveScore(hit); } return merged; } static void assignRanks(List hits) { if (hits == null || hits.isEmpty()) { return; } for (int i = 0; i < hits.size(); i++) { RagHit hit = hits.get(i); if (hit != null) { hit.setRank(i + 1); } } } static String stableKey(RagHit hit, int fallbackIndex) { String key = stableKey(hit); return key == null ? String.valueOf(fallbackIndex) : key; } static String stableKey(RagHit hit) { if (hit == null) { return null; } if (!isBlank(hit.getId())) { return hit.getId().trim(); } if (!isBlank(hit.getDocumentId())) { return hit.getDocumentId().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } if (!isBlank(hit.getSourcePath())) { return hit.getSourcePath().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } if (!isBlank(hit.getSourceUri())) { return hit.getSourceUri().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } if (!isBlank(hit.getSourceName()) && !isBlank(hit.getSectionTitle())) { return hit.getSourceName().trim() + "#" + hit.getSectionTitle().trim() + "#" + normalizeIndex(hit.getChunkIndex()); } if (!isBlank(hit.getContent())) { return hit.getContent().trim(); } return null; } static void normalizeEffectiveScore(RagHit hit) { if (hit == null) { return; } Float effectiveScore = hit.getRerankScore(); if (effectiveScore == null) { effectiveScore = hit.getFusionScore(); } if (effectiveScore == null) { effectiveScore = hit.getRetrievalScore(); } if (effectiveScore == null) { effectiveScore = hit.getScore(); } hit.setScore(effectiveScore); } private static RagHit merge(RagHit original, RagHit current) { RagHit base = original == null ? copy(current) : copy(original); if (base == null) { return null; } if (current == null) { return base; } if (!isBlank(current.getId())) { base.setId(current.getId()); } if (current.getScore() != null) { base.setScore(current.getScore()); } if (current.getRank() != null) { base.setRank(current.getRank()); } if (!isBlank(current.getRetrieverSource())) { base.setRetrieverSource(current.getRetrieverSource()); } if (current.getRetrievalScore() != null) { base.setRetrievalScore(current.getRetrievalScore()); } if (current.getFusionScore() != null) { base.setFusionScore(current.getFusionScore()); } if (current.getRerankScore() != null) { base.setRerankScore(current.getRerankScore()); } if (!isBlank(current.getContent())) { base.setContent(current.getContent()); } if (current.getMetadata() != null && !current.getMetadata().isEmpty()) { base.setMetadata(copyMetadata(current.getMetadata())); } if (!isBlank(current.getDocumentId())) { base.setDocumentId(current.getDocumentId()); } if (!isBlank(current.getSourceName())) { base.setSourceName(current.getSourceName()); } if (!isBlank(current.getSourcePath())) { base.setSourcePath(current.getSourcePath()); } if (!isBlank(current.getSourceUri())) { base.setSourceUri(current.getSourceUri()); } if (current.getPageNumber() != null) { base.setPageNumber(current.getPageNumber()); } if (!isBlank(current.getSectionTitle())) { base.setSectionTitle(current.getSectionTitle()); } if (current.getChunkIndex() != null) { base.setChunkIndex(current.getChunkIndex()); } if (current.getScoreDetails() != null && !current.getScoreDetails().isEmpty()) { base.setScoreDetails(copyScoreDetails(current.getScoreDetails())); } return base; } private static Map copyMetadata(Map metadata) { if (metadata == null || metadata.isEmpty()) { return metadata; } return new LinkedHashMap(metadata); } private static List copyScoreDetails(List details) { if (details == null || details.isEmpty()) { return details; } List copies = new ArrayList(details.size()); for (RagScoreDetail detail : details) { if (detail == null) { continue; } copies.add(RagScoreDetail.builder() .source(detail.getSource()) .rank(detail.getRank()) .retrievalScore(detail.getRetrievalScore()) .fusionContribution(detail.getFusionContribution()) .build()); } return copies; } private static String normalizeIndex(Integer chunkIndex) { return chunkIndex == null ? "-" : String.valueOf(chunkIndex); } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagMetadataKeys.java ================================================ package io.github.lnyocly.ai4j.rag; import io.github.lnyocly.ai4j.constant.Constants; public final class RagMetadataKeys { public static final String CONTENT = Constants.METADATA_KEY; public static final String DOCUMENT_ID = "documentId"; public static final String CHUNK_ID = "chunkId"; public static final String SOURCE_NAME = "sourceName"; public static final String SOURCE_PATH = "sourcePath"; public static final String SOURCE_URI = "sourceUri"; public static final String PAGE_NUMBER = "pageNumber"; public static final String SECTION_TITLE = "sectionTitle"; public static final String CHUNK_INDEX = "chunkIndex"; public static final String TENANT = "tenant"; public static final String BIZ = "biz"; public static final String VERSION = "version"; private RagMetadataKeys() { } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagQuery.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagQuery { private String query; private String dataset; private String embeddingModel; @Builder.Default private Integer topK = 5; private Integer finalTopK; private Map filter; @Builder.Default private String delimiter = "\n\n"; @Builder.Default private boolean includeCitations = true; @Builder.Default private boolean includeTrace = true; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagResult.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagResult { private String query; @Builder.Default private List hits = Collections.emptyList(); private String context; @Builder.Default private List citations = Collections.emptyList(); @Builder.Default private List sources = Collections.emptyList(); private RagTrace trace; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagScoreDetail.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagScoreDetail { private String source; private Integer rank; private Float retrievalScore; private Float fusionContribution; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagService.java ================================================ package io.github.lnyocly.ai4j.rag; public interface RagService { RagResult search(RagQuery query) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagTrace.java ================================================ package io.github.lnyocly.ai4j.rag; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RagTrace { @Builder.Default private List retrievedHits = Collections.emptyList(); @Builder.Default private List rerankedHits = Collections.emptyList(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/Reranker.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.List; public interface Reranker { List rerank(String query, List hits) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/Retriever.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.List; public interface Retriever { List retrieve(RagQuery query) throws Exception; default String retrieverSource() { String simpleName = getClass().getSimpleName(); if (simpleName == null || simpleName.trim().isEmpty()) { return "retriever"; } return simpleName; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RrfFusionStrategy.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class RrfFusionStrategy implements FusionStrategy { private final int rankConstant; public RrfFusionStrategy() { this(60); } public RrfFusionStrategy(int rankConstant) { this.rankConstant = rankConstant <= 0 ? 60 : rankConstant; } @Override public List scoreContributions(List hits) { if (hits == null || hits.isEmpty()) { return Collections.emptyList(); } List scores = new ArrayList(hits.size()); for (int i = 0; i < hits.size(); i++) { scores.add(1.0d / (rankConstant + i + 1)); } return scores; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RsfFusionStrategy.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.ArrayList; import java.util.List; public class RsfFusionStrategy extends AbstractScoreFusionStrategy { @Override protected List scoreWithRawScores(List rawScores) { double min = Double.POSITIVE_INFINITY; double max = Double.NEGATIVE_INFINITY; for (Double rawScore : rawScores) { if (rawScore == null) { continue; } if (rawScore < min) { min = rawScore; } if (rawScore > max) { max = rawScore; } } double denominator = max - min; List scores = new ArrayList(rawScores.size()); for (Double rawScore : rawScores) { scores.add((rawScore - min) / denominator); } return scores; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/TextTokenizer.java ================================================ package io.github.lnyocly.ai4j.rag; import java.util.List; public interface TextTokenizer { List tokenize(String text); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/Chunker.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.rag.RagChunk; import io.github.lnyocly.ai4j.rag.RagDocument; import java.util.List; /** * Splits a loaded document into retrieval chunks. */ public interface Chunker { List chunk(RagDocument document, String content) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/DefaultMetadataEnricher.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.rag.RagChunk; import io.github.lnyocly.ai4j.rag.RagDocument; import io.github.lnyocly.ai4j.rag.RagMetadataKeys; import java.util.Map; /** * Writes the canonical RAG metadata keys used by retrieval and citation layers. */ public class DefaultMetadataEnricher implements MetadataEnricher { @Override public void enrich(RagDocument document, RagChunk chunk, Map metadata) { if (document == null || chunk == null || metadata == null) { return; } putIfNotBlank(metadata, RagMetadataKeys.CONTENT, chunk.getContent()); putIfNotBlank(metadata, RagMetadataKeys.DOCUMENT_ID, document.getDocumentId()); putIfNotBlank(metadata, RagMetadataKeys.CHUNK_ID, chunk.getChunkId()); putIfNotBlank(metadata, RagMetadataKeys.SOURCE_NAME, document.getSourceName()); putIfNotBlank(metadata, RagMetadataKeys.SOURCE_PATH, document.getSourcePath()); putIfNotBlank(metadata, RagMetadataKeys.SOURCE_URI, document.getSourceUri()); putIfNotBlank(metadata, RagMetadataKeys.SECTION_TITLE, chunk.getSectionTitle()); putIfNotBlank(metadata, RagMetadataKeys.TENANT, document.getTenant()); putIfNotBlank(metadata, RagMetadataKeys.BIZ, document.getBiz()); putIfNotBlank(metadata, RagMetadataKeys.VERSION, document.getVersion()); if (chunk.getChunkIndex() != null) { metadata.put(RagMetadataKeys.CHUNK_INDEX, chunk.getChunkIndex()); } if (chunk.getPageNumber() != null) { metadata.put(RagMetadataKeys.PAGE_NUMBER, chunk.getPageNumber()); } } private void putIfNotBlank(Map metadata, String key, String value) { if (key == null || value == null || value.trim().isEmpty()) { return; } metadata.put(key, value.trim()); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/DocumentLoader.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; /** * Converts a raw ingestion source into normalized text plus source metadata. */ public interface DocumentLoader { boolean supports(IngestionSource source); LoadedDocument load(IngestionSource source) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionPipeline.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.rag.RagChunk; import io.github.lnyocly.ai4j.rag.RagDocument; import io.github.lnyocly.ai4j.rag.RagMetadataKeys; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; /** * Thin orchestration layer for RAG ingestion: * source -> text -> chunks -> metadata -> embeddings -> vector upsert. */ public class IngestionPipeline { private static final int DEFAULT_BATCH_SIZE = 32; private final IEmbeddingService embeddingService; private final VectorStore vectorStore; private final List documentLoaders; private final Chunker defaultChunker; private final List defaultDocumentProcessors; private final List defaultMetadataEnrichers; public IngestionPipeline(IEmbeddingService embeddingService, VectorStore vectorStore) { this( embeddingService, vectorStore, Arrays.asList(new TextDocumentLoader(), new TikaDocumentLoader()), new RecursiveTextChunker(1000, 200), Collections.singletonList(new WhitespaceNormalizingDocumentProcessor()), Collections.singletonList(new DefaultMetadataEnricher()) ); } public IngestionPipeline(IEmbeddingService embeddingService, VectorStore vectorStore, List documentLoaders, Chunker defaultChunker, List defaultMetadataEnrichers) { this(embeddingService, vectorStore, documentLoaders, defaultChunker, null, defaultMetadataEnrichers); } public IngestionPipeline(IEmbeddingService embeddingService, VectorStore vectorStore, List documentLoaders, Chunker defaultChunker, List defaultDocumentProcessors, List defaultMetadataEnrichers) { if (embeddingService == null) { throw new IllegalArgumentException("embeddingService is required"); } if (vectorStore == null) { throw new IllegalArgumentException("vectorStore is required"); } this.embeddingService = embeddingService; this.vectorStore = vectorStore; this.documentLoaders = documentLoaders == null ? Collections.emptyList() : new ArrayList(documentLoaders); this.defaultChunker = defaultChunker == null ? new RecursiveTextChunker(1000, 200) : defaultChunker; this.defaultDocumentProcessors = defaultDocumentProcessors == null ? Collections.singletonList(new WhitespaceNormalizingDocumentProcessor()) : new ArrayList(defaultDocumentProcessors); this.defaultMetadataEnrichers = defaultMetadataEnrichers == null ? Collections.singletonList(new DefaultMetadataEnricher()) : new ArrayList(defaultMetadataEnrichers); } public IngestionResult ingest(IngestionRequest request) throws Exception { if (request == null) { throw new IllegalArgumentException("request is required"); } if (isBlank(request.getDataset())) { throw new IllegalArgumentException("dataset is required"); } if (isBlank(request.getEmbeddingModel())) { throw new IllegalArgumentException("embeddingModel is required"); } LoadedDocument loadedDocument = load(request.getSource()); loadedDocument = processLoadedDocument(request.getSource(), loadedDocument, mergeDocumentProcessors(request.getDocumentProcessors())); if (loadedDocument == null || isBlank(loadedDocument.getContent())) { throw new IllegalStateException("Loaded document content is empty"); } RagDocument document = resolveDocument(request.getDocument(), request.getSource(), loadedDocument); List chunks = normalizeChunks( document, (request.getChunker() == null ? defaultChunker : request.getChunker()).chunk(document, loadedDocument.getContent()) ); if (chunks.isEmpty()) { return IngestionResult.builder() .dataset(request.getDataset()) .embeddingModel(request.getEmbeddingModel()) .source(request.getSource()) .document(document) .chunks(Collections.emptyList()) .records(Collections.emptyList()) .upsertedCount(0) .build(); } List enrichers = mergeEnrichers(request.getMetadataEnrichers()); List records = buildRecords(request, document, chunks, enrichers); int upsertedCount = Boolean.FALSE.equals(request.getUpsert()) ? 0 : vectorStore.upsert(VectorUpsertRequest.builder() .dataset(request.getDataset()) .records(records) .build()); return IngestionResult.builder() .dataset(request.getDataset()) .embeddingModel(request.getEmbeddingModel()) .source(request.getSource()) .document(document) .chunks(chunks) .records(records) .upsertedCount(upsertedCount) .build(); } private LoadedDocument load(IngestionSource source) throws Exception { if (source == null) { throw new IllegalArgumentException("source is required"); } for (DocumentLoader loader : documentLoaders) { if (loader != null && loader.supports(source)) { return loader.load(source); } } throw new IllegalArgumentException("No DocumentLoader can handle the provided source"); } private RagDocument resolveDocument(RagDocument requestedDocument, IngestionSource source, LoadedDocument loadedDocument) { RagDocument base = requestedDocument == null ? new RagDocument() : requestedDocument; Map mergedMetadata = new LinkedHashMap(); if (loadedDocument != null && loadedDocument.getMetadata() != null) { mergedMetadata.putAll(loadedDocument.getMetadata()); } if (source != null && source.getMetadata() != null) { mergedMetadata.putAll(source.getMetadata()); } if (base.getMetadata() != null) { mergedMetadata.putAll(base.getMetadata()); } String sourceName = firstNonBlank( base.getSourceName(), loadedDocument == null ? null : loadedDocument.getSourceName(), source == null ? null : source.getName(), source != null && source.getFile() != null ? source.getFile().getName() : null ); String sourcePath = firstNonBlank( base.getSourcePath(), loadedDocument == null ? null : loadedDocument.getSourcePath(), source == null ? null : source.getPath(), source != null && source.getFile() != null ? source.getFile().getAbsolutePath() : null ); String sourceUri = firstNonBlank( base.getSourceUri(), loadedDocument == null ? null : loadedDocument.getSourceUri(), source == null ? null : source.getUri(), source != null && source.getFile() != null ? source.getFile().toURI().toString() : null ); String documentId = firstNonBlank( base.getDocumentId(), stringValue(mergedMetadata.get(RagMetadataKeys.DOCUMENT_ID)) ); if (isBlank(documentId)) { String seed = firstNonBlank(sourceUri, sourcePath, sourceName); documentId = isBlank(seed) ? UUID.randomUUID().toString() : UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8)).toString(); } return RagDocument.builder() .documentId(documentId) .sourceName(sourceName) .sourcePath(sourcePath) .sourceUri(sourceUri) .title(firstNonBlank(base.getTitle(), sourceName)) .tenant(firstNonBlank(base.getTenant(), stringValue(mergedMetadata.get(RagMetadataKeys.TENANT)))) .biz(firstNonBlank(base.getBiz(), stringValue(mergedMetadata.get(RagMetadataKeys.BIZ)))) .version(firstNonBlank(base.getVersion(), stringValue(mergedMetadata.get(RagMetadataKeys.VERSION)))) .metadata(mergedMetadata) .build(); } private List normalizeChunks(RagDocument document, List chunks) { if (chunks == null || chunks.isEmpty()) { return Collections.emptyList(); } List normalized = new ArrayList(chunks.size()); int ordinal = 0; for (RagChunk chunk : chunks) { if (chunk == null || isBlank(chunk.getContent())) { continue; } Integer chunkIndex = chunk.getChunkIndex() == null ? ordinal : chunk.getChunkIndex(); normalized.add(RagChunk.builder() .chunkId(firstNonBlank(chunk.getChunkId(), buildChunkId(document.getDocumentId(), chunkIndex))) .documentId(firstNonBlank(chunk.getDocumentId(), document.getDocumentId())) .content(chunk.getContent().trim()) .chunkIndex(chunkIndex) .pageNumber(chunk.getPageNumber()) .sectionTitle(chunk.getSectionTitle()) .metadata(copyMetadata(chunk.getMetadata())) .build()); ordinal++; } return normalized; } private List buildRecords(IngestionRequest request, RagDocument document, List chunks, List enrichers) throws Exception { List contents = new ArrayList(chunks.size()); for (RagChunk chunk : chunks) { contents.add(chunk.getContent()); } List> vectors = embed(contents, request.getEmbeddingModel(), request.getBatchSize()); if (vectors.size() != chunks.size()) { throw new IllegalStateException("Embedding vector count does not match chunk count"); } List records = new ArrayList(chunks.size()); for (int i = 0; i < chunks.size(); i++) { RagChunk chunk = chunks.get(i); Map metadata = buildChunkMetadata(document, chunk, enrichers); chunk.setMetadata(metadata); records.add(VectorRecord.builder() .id(chunk.getChunkId()) .vector(vectors.get(i)) .content(chunk.getContent()) .metadata(metadata) .build()); } return records; } private Map buildChunkMetadata(RagDocument document, RagChunk chunk, List enrichers) throws Exception { Map metadata = new LinkedHashMap(); if (document != null && document.getMetadata() != null) { metadata.putAll(document.getMetadata()); } if (chunk != null && chunk.getMetadata() != null) { metadata.putAll(chunk.getMetadata()); } for (MetadataEnricher enricher : enrichers) { if (enricher != null) { enricher.enrich(document, chunk, metadata); } } return metadata; } private List mergeEnrichers(List enrichers) { List merged = new ArrayList(defaultMetadataEnrichers); if (enrichers != null && !enrichers.isEmpty()) { merged.addAll(enrichers); } return merged; } private List mergeDocumentProcessors(List processors) { List merged = new ArrayList(defaultDocumentProcessors); if (processors != null && !processors.isEmpty()) { merged.addAll(processors); } return merged; } private LoadedDocument processLoadedDocument(IngestionSource source, LoadedDocument loadedDocument, List processors) throws Exception { LoadedDocument current = loadedDocument; if (processors == null || processors.isEmpty()) { return current; } for (LoadedDocumentProcessor processor : processors) { if (processor == null || current == null) { continue; } LoadedDocument processed = processor.process(source, current); if (processed != null) { current = processed; } } return current; } private List> embed(List texts, String model, Integer batchSize) throws Exception { if (texts == null || texts.isEmpty()) { return Collections.emptyList(); } int effectiveBatchSize = batchSize == null || batchSize <= 0 ? DEFAULT_BATCH_SIZE : batchSize; List> vectors = new ArrayList>(texts.size()); for (int start = 0; start < texts.size(); start += effectiveBatchSize) { int end = Math.min(start + effectiveBatchSize, texts.size()); List batch = new ArrayList(texts.subList(start, end)); EmbeddingResponse response = embeddingService.embedding(Embedding.builder() .model(model) .input(batch) .build()); vectors.addAll(extractEmbeddings(response, batch.size())); } return vectors; } private List> extractEmbeddings(EmbeddingResponse response, int expectedSize) { List data = response == null ? null : response.getData(); if (data == null || data.isEmpty()) { throw new IllegalStateException("Failed to generate embeddings for ingestion pipeline"); } Map> indexed = new LinkedHashMap>(); int fallbackIndex = 0; for (EmbeddingObject object : data) { if (object == null || object.getEmbedding() == null) { continue; } Integer index = object.getIndex(); indexed.put(index == null ? fallbackIndex : index, object.getEmbedding()); fallbackIndex++; } if (indexed.size() < expectedSize) { throw new IllegalStateException("Embedding response size is smaller than the requested batch size"); } List> vectors = new ArrayList>(expectedSize); for (int i = 0; i < expectedSize; i++) { List vector = indexed.get(i); if (vector == null) { throw new IllegalStateException("Missing embedding vector at index " + i); } vectors.add(new ArrayList(vector)); } return vectors; } private String buildChunkId(String documentId, Integer chunkIndex) { return documentId + "#chunk-" + (chunkIndex == null ? 0 : chunkIndex); } private Map copyMetadata(Map metadata) { if (metadata == null || metadata.isEmpty()) { return null; } return new LinkedHashMap(metadata); } private String stringValue(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionRequest.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.rag.RagDocument; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class IngestionRequest { private String dataset; private String embeddingModel; private RagDocument document; private IngestionSource source; private Chunker chunker; @Builder.Default private List documentProcessors = Collections.emptyList(); @Builder.Default private List metadataEnrichers = Collections.emptyList(); @Builder.Default private Integer batchSize = 32; @Builder.Default private Boolean upsert = Boolean.TRUE; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionResult.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.rag.RagChunk; import io.github.lnyocly.ai4j.rag.RagDocument; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class IngestionResult { private String dataset; private String embeddingModel; private IngestionSource source; private RagDocument document; @Builder.Default private List chunks = Collections.emptyList(); @Builder.Default private List records = Collections.emptyList(); private int upsertedCount; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionSource.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.io.File; import java.util.Collections; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class IngestionSource { private String name; private String path; private String uri; private File file; private String content; @Builder.Default private Map metadata = Collections.emptyMap(); public static IngestionSource text(String content) { return IngestionSource.builder().content(content).build(); } public static IngestionSource file(File file) { return IngestionSource.builder().file(file).build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/LoadedDocument.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class LoadedDocument { private String content; private String sourceName; private String sourcePath; private String sourceUri; @Builder.Default private Map metadata = Collections.emptyMap(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/LoadedDocumentProcessor.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; /** * Post-processes loaded documents before chunking. * Useful for OCR fallback, scanned-document cleanup, and complex text normalization. */ public interface LoadedDocumentProcessor { LoadedDocument process(IngestionSource source, LoadedDocument document) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/MetadataEnricher.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.rag.RagChunk; import io.github.lnyocly.ai4j.rag.RagDocument; import java.util.Map; /** * Adds or normalizes metadata before records are written into the vector store. */ public interface MetadataEnricher { void enrich(RagDocument document, RagChunk chunk, Map metadata) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/OcrNoiseCleaningDocumentProcessor.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import java.util.LinkedHashMap; import java.util.Map; /** * Cleans common OCR artifacts such as hyphenated line breaks and letter-by-letter spacing. */ public class OcrNoiseCleaningDocumentProcessor implements LoadedDocumentProcessor { @Override public LoadedDocument process(IngestionSource source, LoadedDocument document) { if (document == null || isBlank(document.getContent())) { return document; } String cleaned = clean(document.getContent()); if (cleaned.equals(document.getContent())) { return document; } Map metadata = new LinkedHashMap(); if (document.getMetadata() != null) { metadata.putAll(document.getMetadata()); } metadata.put("ocrNoiseCleaned", Boolean.TRUE); return document.toBuilder() .content(cleaned) .metadata(metadata) .build(); } String clean(String content) { String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); String hyphenJoined = normalized.replaceAll("([\\p{L}\\p{N}])\\s*-\\s*\\n\\s*([\\p{L}\\p{N}])", "$1$2"); String[] lines = hyphenJoined.split("\\n", -1); StringBuilder cleaned = new StringBuilder(); int blankCount = 0; for (String line : lines) { String normalizedLine = collapseInnerWhitespace(line); if (looksLikeSpacedWord(normalizedLine)) { normalizedLine = normalizedLine.replace(" ", ""); } normalizedLine = normalizedLine.trim(); if (normalizedLine.isEmpty()) { blankCount++; if (blankCount > 1) { continue; } } else { blankCount = 0; } if (cleaned.length() > 0) { cleaned.append('\n'); } cleaned.append(normalizedLine); } return cleaned.toString().trim(); } private String collapseInnerWhitespace(String value) { return value == null ? "" : value.replaceAll("[\\t\\x0B\\f ]+", " "); } private boolean looksLikeSpacedWord(String line) { if (line == null) { return false; } String trimmed = line.trim(); if (trimmed.isEmpty()) { return false; } String[] parts = trimmed.split(" "); if (parts.length < 3) { return false; } for (String part : parts) { if (part.length() != 1 || !Character.isLetterOrDigit(part.charAt(0))) { return false; } } return true; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/OcrTextExtractingDocumentProcessor.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import java.util.LinkedHashMap; import java.util.Map; /** * Uses an external OCR extractor to populate text for scanned or non-text documents. */ public class OcrTextExtractingDocumentProcessor implements LoadedDocumentProcessor { private final OcrTextExtractor extractor; public OcrTextExtractingDocumentProcessor(OcrTextExtractor extractor) { if (extractor == null) { throw new IllegalArgumentException("extractor is required"); } this.extractor = extractor; } @Override public LoadedDocument process(IngestionSource source, LoadedDocument document) throws Exception { if (document == null || !isBlank(document.getContent())) { return document; } if (!extractor.supports(source, document)) { return document; } String extracted = extractor.extractText(source, document); if (isBlank(extracted)) { return document; } Map metadata = copyMetadata(document.getMetadata()); metadata.put("ocrApplied", Boolean.TRUE); return document.toBuilder() .content(extracted) .metadata(metadata) .build(); } private Map copyMetadata(Map metadata) { Map copied = new LinkedHashMap(); if (metadata != null) { copied.putAll(metadata); } return copied; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/OcrTextExtractor.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; /** * Extension point for OCR engines used during ingestion. */ public interface OcrTextExtractor { boolean supports(IngestionSource source, LoadedDocument document); String extractText(IngestionSource source, LoadedDocument document) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/RecursiveTextChunker.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.document.RecursiveCharacterTextSplitter; import io.github.lnyocly.ai4j.rag.RagChunk; import io.github.lnyocly.ai4j.rag.RagDocument; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * Default chunker backed by the existing recursive character splitter. */ public class RecursiveTextChunker implements Chunker { private final RecursiveCharacterTextSplitter splitter; public RecursiveTextChunker(int chunkSize, int chunkOverlap) { this(new RecursiveCharacterTextSplitter(chunkSize, chunkOverlap)); } public RecursiveTextChunker(RecursiveCharacterTextSplitter splitter) { if (splitter == null) { throw new IllegalArgumentException("splitter is required"); } this.splitter = splitter; } @Override public List chunk(RagDocument document, String content) { if (content == null || content.trim().isEmpty()) { return Collections.emptyList(); } List parts = splitter.splitText(content); if (parts == null || parts.isEmpty()) { return Collections.emptyList(); } List chunks = new ArrayList(parts.size()); int index = 0; for (String part : parts) { if (part == null || part.trim().isEmpty()) { continue; } chunks.add(RagChunk.builder() .documentId(document == null ? null : document.getDocumentId()) .content(part) .chunkIndex(index++) .build()); } return chunks; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/TextDocumentLoader.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; /** * Loader for already extracted text content. */ public class TextDocumentLoader implements DocumentLoader { @Override public boolean supports(IngestionSource source) { return source != null && source.getContent() != null && !source.getContent().trim().isEmpty(); } @Override public LoadedDocument load(IngestionSource source) { return LoadedDocument.builder() .content(source.getContent()) .sourceName(source.getName()) .sourcePath(source.getPath()) .sourceUri(source.getUri()) .metadata(source.getMetadata()) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/TikaDocumentLoader.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import io.github.lnyocly.ai4j.document.TikaUtil; import java.io.File; import java.util.LinkedHashMap; import java.util.Map; /** * Loader for local files using Apache Tika. */ public class TikaDocumentLoader implements DocumentLoader { @Override public boolean supports(IngestionSource source) { return source != null && source.getFile() != null; } @Override public LoadedDocument load(IngestionSource source) throws Exception { File file = source.getFile(); Map metadata = new LinkedHashMap(); if (source.getMetadata() != null) { metadata.putAll(source.getMetadata()); } metadata.put("mimeType", TikaUtil.detectMimeType(file)); return LoadedDocument.builder() .content(TikaUtil.parseFile(file)) .sourceName(source.getName() == null ? file.getName() : source.getName()) .sourcePath(source.getPath() == null ? file.getAbsolutePath() : source.getPath()) .sourceUri(source.getUri() == null ? file.toURI().toString() : source.getUri()) .metadata(metadata) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/WhitespaceNormalizingDocumentProcessor.java ================================================ package io.github.lnyocly.ai4j.rag.ingestion; import java.util.LinkedHashMap; import java.util.Map; /** * Normalizes line endings and trims noisy whitespace without changing semantic content. */ public class WhitespaceNormalizingDocumentProcessor implements LoadedDocumentProcessor { @Override public LoadedDocument process(IngestionSource source, LoadedDocument document) { if (document == null || document.getContent() == null) { return document; } String normalized = normalize(document.getContent()); if (normalized.equals(document.getContent())) { return document; } Map metadata = new LinkedHashMap(); if (document.getMetadata() != null) { metadata.putAll(document.getMetadata()); } metadata.put("whitespaceNormalized", Boolean.TRUE); return document.toBuilder() .content(normalized) .metadata(metadata) .build(); } String normalize(String text) { String normalized = text.replace("\r\n", "\n").replace('\r', '\n'); String[] lines = normalized.split("\\n", -1); StringBuilder builder = new StringBuilder(); int blankCount = 0; for (String line : lines) { String cleaned = line == null ? "" : line.replaceAll("[\\t\\x0B\\f ]+", " ").trim(); if (cleaned.isEmpty()) { blankCount++; if (blankCount > 1) { continue; } } else { blankCount = 0; } if (builder.length() > 0) { builder.append('\n'); } builder.append(cleaned); } return builder.toString().trim(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankDocument.java ================================================ package io.github.lnyocly.ai4j.rerank.entity; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class RerankDocument { private String id; private String text; private String content; private String title; private Object image; private Map metadata; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankRequest.java ================================================ package io.github.lnyocly.ai4j.rerank.entity; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Singular; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @JsonInclude(JsonInclude.Include.NON_NULL) public class RerankRequest { @NonNull private String model; @NonNull private Object query; @Builder.Default private List documents = java.util.Collections.emptyList(); @JsonProperty("top_n") private Integer topN; @JsonProperty("return_documents") private Boolean returnDocuments; private String instruction; @JsonIgnore @Singular("extraBody") private Map extraBody; @JsonAnyGetter public Map getExtraBody() { return extraBody; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankResponse.java ================================================ package io.github.lnyocly.ai4j.rerank.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class RerankResponse { private String id; private String model; @Builder.Default private List results = Collections.emptyList(); private RerankUsage usage; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankResult.java ================================================ package io.github.lnyocly.ai4j.rerank.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class RerankResult { private Integer index; @JsonProperty("relevance_score") private Float relevanceScore; private RerankDocument document; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankUsage.java ================================================ package io.github.lnyocly.ai4j.rerank.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class RerankUsage { @JsonProperty("prompt_tokens") private Integer promptTokens; @JsonProperty("total_tokens") private Integer totalTokens; @JsonProperty("input_tokens") private Integer inputTokens; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/AiConfig.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.config.AiPlatform; import lombok.Data; import java.util.List; @Data public class AiConfig { private List platforms; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/Configuration.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.config.*; import io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig; import lombok.Data; import okhttp3.OkHttpClient; import okhttp3.sse.EventSource; import okhttp3.sse.EventSources; /** * @Author cly * @Description 统一的配置管理 * @Date 2024/8/8 23:44 */ @Data public class Configuration { private OkHttpClient okHttpClient; public EventSource.Factory createRequestFactory() { return EventSources.createFactory(okHttpClient); } private OpenAiConfig openAiConfig; private ZhipuConfig zhipuConfig; private DeepSeekConfig deepSeekConfig; private MoonshotConfig moonshotConfig; private HunyuanConfig hunyuanConfig; private LingyiConfig lingyiConfig; private OllamaConfig ollamaConfig; private MinimaxConfig minimaxConfig; private BaichuanConfig baichuanConfig; private PineconeConfig pineconeConfig; private QdrantConfig qdrantConfig; private MilvusConfig milvusConfig; private PgVectorConfig pgVectorConfig; private SearXNGConfig searXNGConfig; private McpConfig mcpConfig; private DashScopeConfig dashScopeConfig; private DoubaoConfig doubaoConfig; private JinaConfig jinaConfig; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/IAudioService.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.platform.openai.audio.entity.*; import java.io.InputStream; /** * @Author cly * @Description 音频audio接口服务 * @Date 2024/10/10 23:39 */ public interface IAudioService { InputStream textToSpeech(String baseUrl, String apiKey, TextToSpeech textToSpeech); InputStream textToSpeech(TextToSpeech textToSpeech); TranscriptionResponse transcription(String baseUrl, String apiKey, Transcription transcription); TranscriptionResponse transcription(Transcription transcription); TranslationResponse translation(String baseUrl, String apiKey, Translation translation); TranslationResponse translation(Translation translation); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/IChatService.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; /** * @Author cly * @Description TODO * @Date 2024/8/2 23:15 */ public interface IChatService { ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception; ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception; void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception; void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/IEmbeddingService.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; /** * @Author cly * @Description TODO * @Date 2024/8/2 23:15 */ public interface IEmbeddingService { EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) throws Exception ; EmbeddingResponse embedding(Embedding embeddingReq) throws Exception ; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/IImageService.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse; import io.github.lnyocly.ai4j.listener.ImageSseListener; /** * @Author cly * @Description 图片生成服务接口 * @Date 2026/1/31 */ public interface IImageService { ImageGenerationResponse generate(String baseUrl, String apiKey, ImageGeneration imageGeneration) throws Exception; ImageGenerationResponse generate(ImageGeneration imageGeneration) throws Exception; void generateStream(String baseUrl, String apiKey, ImageGeneration imageGeneration, ImageSseListener listener) throws Exception; void generateStream(ImageGeneration imageGeneration, ImageSseListener listener) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/IRealtimeService.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.listener.RealtimeListener; import okhttp3.WebSocket; /** * @Author cly * @Description realtime服务接口 * @Date 2024/10/12 16:30 */ public interface IRealtimeService { WebSocket createRealtimeClient(String baseUrl, String apiKey, String model, RealtimeListener realtimeListener); WebSocket createRealtimeClient(String model, RealtimeListener realtimeListener); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/IRerankService.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; public interface IRerankService { RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) throws Exception; RerankResponse rerank(RerankRequest request) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/IResponsesService.java ================================================ package io.github.lnyocly.ai4j.service; import io.github.lnyocly.ai4j.listener.ResponseSseListener; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; public interface IResponsesService { Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception; Response create(ResponseRequest request) throws Exception; void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception; void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception; Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception; Response retrieve(String responseId) throws Exception; ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception; ResponseDeleteResponse delete(String responseId) throws Exception; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/ModelType.java ================================================ package io.github.lnyocly.ai4j.service; import lombok.AllArgsConstructor; import lombok.Getter; @AllArgsConstructor @Getter public enum ModelType { REALTIME("realtime"), EMBEDDING("embedding"), CHAT("chat"), ; private final String type; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/PlatformType.java ================================================ package io.github.lnyocly.ai4j.service; import lombok.AllArgsConstructor; import lombok.Getter; /** * @Author cly * @Description TODO * @Date 2024/8/8 17:29 */ @AllArgsConstructor @Getter public enum PlatformType { OPENAI("openai"), ZHIPU("zhipu"), DEEPSEEK("deepseek"), MOONSHOT("moonshot"), HUNYUAN("hunyuan"), LINGYI("lingyi"), OLLAMA("ollama"), MINIMAX("minimax"), BAICHUAN("baichuan"), DASHSCOPE("dashscope"), DOUBAO("doubao"), JINA("jina"), ; private final String platform; public static PlatformType getPlatform(String value) { String target = value.toLowerCase(); for (PlatformType platformType : PlatformType.values()) { if (platformType.getPlatform().equals(target)) { return platformType; } } return PlatformType.OPENAI; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiService.java ================================================ package io.github.lnyocly.ai4j.service.factory; import io.github.lnyocly.ai4j.agentflow.AgentFlow; import io.github.lnyocly.ai4j.agentflow.AgentFlowConfig; import io.github.lnyocly.ai4j.platform.baichuan.chat.BaichuanChatService; import io.github.lnyocly.ai4j.platform.dashscope.DashScopeChatService; import io.github.lnyocly.ai4j.platform.deepseek.chat.DeepSeekChatService; import io.github.lnyocly.ai4j.platform.doubao.chat.DoubaoChatService; import io.github.lnyocly.ai4j.platform.doubao.image.DoubaoImageService; import io.github.lnyocly.ai4j.platform.doubao.rerank.DoubaoRerankService; import io.github.lnyocly.ai4j.platform.hunyuan.chat.HunyuanChatService; import io.github.lnyocly.ai4j.platform.jina.rerank.JinaRerankService; import io.github.lnyocly.ai4j.platform.lingyi.chat.LingyiChatService; import io.github.lnyocly.ai4j.platform.minimax.chat.MinimaxChatService; import io.github.lnyocly.ai4j.platform.moonshot.chat.MoonshotChatService; import io.github.lnyocly.ai4j.platform.ollama.chat.OllamaAiChatService; import io.github.lnyocly.ai4j.platform.ollama.embedding.OllamaEmbeddingService; import io.github.lnyocly.ai4j.platform.ollama.rerank.OllamaRerankService; import io.github.lnyocly.ai4j.platform.openai.audio.OpenAiAudioService; import io.github.lnyocly.ai4j.platform.openai.chat.OpenAiChatService; import io.github.lnyocly.ai4j.platform.openai.embedding.OpenAiEmbeddingService; import io.github.lnyocly.ai4j.platform.openai.image.OpenAiImageService; import io.github.lnyocly.ai4j.platform.openai.realtime.OpenAiRealtimeService; import io.github.lnyocly.ai4j.platform.zhipu.chat.ZhipuChatService; import io.github.lnyocly.ai4j.rag.DefaultRagContextAssembler; import io.github.lnyocly.ai4j.rag.DefaultRagService; import io.github.lnyocly.ai4j.rag.DenseRetriever; import io.github.lnyocly.ai4j.rag.ModelReranker; import io.github.lnyocly.ai4j.rag.NoopReranker; import io.github.lnyocly.ai4j.rag.RagService; import io.github.lnyocly.ai4j.rag.Reranker; import io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline; import io.github.lnyocly.ai4j.service.*; import io.github.lnyocly.ai4j.vector.service.PineconeService; import io.github.lnyocly.ai4j.vector.store.milvus.MilvusVectorStore; import io.github.lnyocly.ai4j.vector.store.pgvector.PgVectorStore; import io.github.lnyocly.ai4j.vector.store.qdrant.QdrantVectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.pinecone.PineconeVectorStore; import io.github.lnyocly.ai4j.websearch.ChatWithWebSearchEnhance; /** * @Author cly * @Description AI鏈嶅姟宸ュ巶锛屽垱寤哄悇绉岮I搴旂敤 * @Date 2024/8/7 18:10 */ public class AiService { // private final ConcurrentMap chatServiceCache = new ConcurrentHashMap<>(); //private final ConcurrentMap embeddingServiceCache = new ConcurrentHashMap<>(); private final Configuration configuration; public AiService(Configuration configuration) { this.configuration = configuration; } public Configuration getConfiguration() { return configuration; } public AgentFlow getAgentFlow(AgentFlowConfig agentFlowConfig) { return new AgentFlow(configuration, agentFlowConfig); } public IChatService getChatService(PlatformType platform) { //return chatServiceCache.computeIfAbsent(platform, this::createChatService); return createChatService(platform); } public IChatService webSearchEnhance(IChatService chatService) { //IChatService chatService = getChatService(platform); return new ChatWithWebSearchEnhance(chatService, configuration); } private IChatService createChatService(PlatformType platform) { switch (platform) { case OPENAI: return new OpenAiChatService(configuration); case ZHIPU: return new ZhipuChatService(configuration); case DEEPSEEK: return new DeepSeekChatService(configuration); case MOONSHOT: return new MoonshotChatService(configuration); case HUNYUAN: return new HunyuanChatService(configuration); case LINGYI: return new LingyiChatService(configuration); case OLLAMA: return new OllamaAiChatService(configuration); case MINIMAX: return new MinimaxChatService(configuration); case BAICHUAN: return new BaichuanChatService(configuration); case DASHSCOPE: return new DashScopeChatService(configuration); case DOUBAO: return new DoubaoChatService(configuration); default: throw new IllegalArgumentException("Unknown platform: " + platform); } } public IEmbeddingService getEmbeddingService(PlatformType platform) { //return embeddingServiceCache.computeIfAbsent(platform, this::createEmbeddingService); return createEmbeddingService(platform); } private IEmbeddingService createEmbeddingService(PlatformType platform) { switch (platform) { case OPENAI: return new OpenAiEmbeddingService(configuration); case OLLAMA: return new OllamaEmbeddingService(configuration); default: throw new IllegalArgumentException("Unknown platform: " + platform); } } public IAudioService getAudioService(PlatformType platform) { return createAudioService(platform); } private IAudioService createAudioService(PlatformType platform) { switch (platform) { case OPENAI: return new OpenAiAudioService(configuration); default: throw new IllegalArgumentException("Unknown platform: " + platform); } } public IRealtimeService getRealtimeService(PlatformType platform) { return createRealtimeService(platform); } private IRealtimeService createRealtimeService(PlatformType platform) { switch (platform) { case OPENAI: return new OpenAiRealtimeService(configuration); default: throw new IllegalArgumentException("Unknown platform: " + platform); } } public PineconeService getPineconeService() { return new PineconeService(configuration); } public VectorStore getPineconeVectorStore() { return new PineconeVectorStore(getPineconeService()); } public VectorStore getQdrantVectorStore() { return new QdrantVectorStore(configuration); } public VectorStore getMilvusVectorStore() { return new MilvusVectorStore(configuration); } public VectorStore getPgVectorStore() { return new PgVectorStore(configuration); } public IImageService getImageService(PlatformType platform) { return createImageService(platform); } private IImageService createImageService(PlatformType platform) { switch (platform) { case OPENAI: return new OpenAiImageService(configuration); case DOUBAO: return new DoubaoImageService(configuration); default: throw new IllegalArgumentException("Unknown platform: " + platform); } } public IResponsesService getResponsesService(PlatformType platform) { return createResponsesService(platform); } private IResponsesService createResponsesService(PlatformType platform) { switch (platform) { case OPENAI: return new io.github.lnyocly.ai4j.platform.openai.response.OpenAiResponsesService(configuration); case DOUBAO: return new io.github.lnyocly.ai4j.platform.doubao.response.DoubaoResponsesService(configuration); case DASHSCOPE: return new io.github.lnyocly.ai4j.platform.dashscope.response.DashScopeResponsesService(configuration); default: throw new IllegalArgumentException("Unknown platform: " + platform); } } public IRerankService getRerankService(PlatformType platform) { return createRerankService(platform); } private IRerankService createRerankService(PlatformType platform) { switch (platform) { case JINA: return new JinaRerankService(configuration); case OLLAMA: return new OllamaRerankService(configuration); case DOUBAO: return new DoubaoRerankService(configuration); default: throw new IllegalArgumentException("Unknown platform: " + platform); } } public RagService getRagService(PlatformType platform, VectorStore vectorStore) { return new DefaultRagService( new DenseRetriever(getEmbeddingService(platform), vectorStore), new NoopReranker(), new DefaultRagContextAssembler() ); } public IngestionPipeline getIngestionPipeline(PlatformType platform, VectorStore vectorStore) { return new IngestionPipeline(getEmbeddingService(platform), vectorStore); } public Reranker getModelReranker(PlatformType platform, String model) { return new ModelReranker(getRerankService(platform), model); } public Reranker getModelReranker(PlatformType platform, String model, Integer topN, String instruction) { return getModelReranker(platform, model, topN, instruction, false, true); } public Reranker getModelReranker(PlatformType platform, String model, Integer topN, String instruction, boolean returnDocuments, boolean appendRemainingHits) { return new ModelReranker( getRerankService(platform), model, topN, instruction, returnDocuments, appendRemainingHits ); } public RagService getPineconeRagService(PlatformType platform) { return getRagService(platform, getPineconeVectorStore()); } public IngestionPipeline getPineconeIngestionPipeline(PlatformType platform) { return getIngestionPipeline(platform, getPineconeVectorStore()); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiServiceFactory.java ================================================ package io.github.lnyocly.ai4j.service.factory; import io.github.lnyocly.ai4j.service.Configuration; /** * {@link AiService} 的创建工厂。 */ public interface AiServiceFactory { AiService create(Configuration configuration); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiServiceRegistration.java ================================================ package io.github.lnyocly.ai4j.service.factory; import io.github.lnyocly.ai4j.service.PlatformType; /** * 多实例注册表中的单个注册项。 */ public class AiServiceRegistration { private final String id; private final PlatformType platformType; private final AiService aiService; public AiServiceRegistration(String id, PlatformType platformType, AiService aiService) { this.id = id; this.platformType = platformType; this.aiService = aiService; } public String getId() { return id; } public PlatformType getPlatformType() { return platformType; } public AiService getAiService() { return aiService; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiServiceRegistry.java ================================================ package io.github.lnyocly.ai4j.service.factory; import io.github.lnyocly.ai4j.rag.RagService; import io.github.lnyocly.ai4j.service.IAudioService; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.service.IImageService; import io.github.lnyocly.ai4j.service.IRerankService; import io.github.lnyocly.ai4j.service.IRealtimeService; import io.github.lnyocly.ai4j.service.IResponsesService; import io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.rag.Reranker; import java.util.Set; /** * 按 id 管理多套 {@link AiService} 的正式抽象。 */ public interface AiServiceRegistry { AiServiceRegistration find(String id); Set ids(); default boolean contains(String id) { return find(id) != null; } default AiServiceRegistration get(String id) { AiServiceRegistration registration = find(id); if (registration == null) { throw new IllegalArgumentException("Unknown ai service id: " + id); } return registration; } default AiService getAiService(String id) { return get(id).getAiService(); } default IChatService getChatService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getChatService(registration.getPlatformType()); } default IEmbeddingService getEmbeddingService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getEmbeddingService(registration.getPlatformType()); } default IAudioService getAudioService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getAudioService(registration.getPlatformType()); } default IRealtimeService getRealtimeService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getRealtimeService(registration.getPlatformType()); } default IImageService getImageService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getImageService(registration.getPlatformType()); } default IResponsesService getResponsesService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getResponsesService(registration.getPlatformType()); } default IRerankService getRerankService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getRerankService(registration.getPlatformType()); } default RagService getRagService(String id, VectorStore vectorStore) { AiServiceRegistration registration = get(id); return registration.getAiService().getRagService(registration.getPlatformType(), vectorStore); } default RagService getPineconeRagService(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getPineconeRagService(registration.getPlatformType()); } default IngestionPipeline getIngestionPipeline(String id, VectorStore vectorStore) { AiServiceRegistration registration = get(id); return registration.getAiService().getIngestionPipeline(registration.getPlatformType(), vectorStore); } default IngestionPipeline getPineconeIngestionPipeline(String id) { AiServiceRegistration registration = get(id); return registration.getAiService().getPineconeIngestionPipeline(registration.getPlatformType()); } default Reranker getModelReranker(String id, String model) { AiServiceRegistration registration = get(id); return registration.getAiService().getModelReranker(registration.getPlatformType(), model); } default Reranker getModelReranker(String id, String model, Integer topN, String instruction) { AiServiceRegistration registration = get(id); return registration.getAiService().getModelReranker( registration.getPlatformType(), model, topN, instruction ); } default Reranker getModelReranker(String id, String model, Integer topN, String instruction, boolean returnDocuments, boolean appendRemainingHits) { AiServiceRegistration registration = get(id); return registration.getAiService().getModelReranker( registration.getPlatformType(), model, topN, instruction, returnDocuments, appendRemainingHits ); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/DefaultAiServiceFactory.java ================================================ package io.github.lnyocly.ai4j.service.factory; import io.github.lnyocly.ai4j.service.Configuration; /** * 默认的 {@link AiService} 工厂实现。 */ public class DefaultAiServiceFactory implements AiServiceFactory { @Override public AiService create(Configuration configuration) { return new AiService(configuration); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/DefaultAiServiceRegistry.java ================================================ package io.github.lnyocly.ai4j.service.factory; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.copier.CopyOptions; import cn.hutool.core.collection.CollUtil; import io.github.lnyocly.ai4j.config.AiPlatform; import io.github.lnyocly.ai4j.config.BaichuanConfig; import io.github.lnyocly.ai4j.config.DashScopeConfig; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.config.HunyuanConfig; import io.github.lnyocly.ai4j.config.JinaConfig; import io.github.lnyocly.ai4j.config.LingyiConfig; import io.github.lnyocly.ai4j.config.MinimaxConfig; import io.github.lnyocly.ai4j.config.MoonshotConfig; import io.github.lnyocly.ai4j.config.OllamaConfig; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.service.AiConfig; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import java.lang.reflect.InvocationTargetException; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; /** * 默认的多实例 {@link AiService} 注册表实现。 */ public class DefaultAiServiceRegistry implements AiServiceRegistry { private final Map registrations; public DefaultAiServiceRegistry(Map registrations) { this.registrations = Collections.unmodifiableMap(new LinkedHashMap(registrations)); } public static DefaultAiServiceRegistry empty() { return new DefaultAiServiceRegistry(Collections.emptyMap()); } public static DefaultAiServiceRegistry from(Configuration configuration, AiConfig aiConfig) { return from(configuration, aiConfig, new DefaultAiServiceFactory()); } public static DefaultAiServiceRegistry from(Configuration configuration, AiConfig aiConfig, AiServiceFactory aiServiceFactory) { if (configuration == null || aiConfig == null || CollUtil.isEmpty(aiConfig.getPlatforms())) { return empty(); } Map registrations = new LinkedHashMap(); for (AiPlatform aiPlatform : aiConfig.getPlatforms()) { if (aiPlatform == null) { continue; } String id = aiPlatform.getId(); if (id == null || "".equals(id.trim())) { throw new IllegalArgumentException("Ai platform id must not be blank"); } PlatformType platformType = resolvePlatformType(aiPlatform.getPlatform(), id); Configuration scopedConfiguration = createScopedConfiguration(configuration, aiPlatform, platformType); registrations.put(id, new AiServiceRegistration(id, platformType, aiServiceFactory.create(scopedConfiguration))); } return new DefaultAiServiceRegistry(registrations); } @Override public AiServiceRegistration find(String id) { return registrations.get(id); } @Override public Set ids() { return Collections.unmodifiableSet(new LinkedHashSet(registrations.keySet())); } private static Configuration createScopedConfiguration(Configuration source, AiPlatform aiPlatform, PlatformType platformType) { Configuration target = new Configuration(); BeanUtil.copyProperties(source, target, CopyOptions.create()); applyPlatformConfig(target, aiPlatform, platformType); return target; } private static PlatformType resolvePlatformType(String rawPlatform, String id) { if (rawPlatform == null || "".equals(rawPlatform.trim())) { throw new IllegalArgumentException("Ai platform '" + id + "' platform must not be blank"); } String target = rawPlatform.trim(); for (PlatformType platformType : PlatformType.values()) { if (platformType.getPlatform().equalsIgnoreCase(target)) { return platformType; } } throw new IllegalArgumentException("Unsupported ai platform '" + rawPlatform + "' for id '" + id + "'"); } private static void applyPlatformConfig(Configuration target, AiPlatform aiPlatform, PlatformType platformType) { switch (platformType) { case OPENAI: target.setOpenAiConfig(copy(aiPlatform, OpenAiConfig.class)); break; case ZHIPU: target.setZhipuConfig(copy(aiPlatform, ZhipuConfig.class)); break; case DEEPSEEK: target.setDeepSeekConfig(copy(aiPlatform, DeepSeekConfig.class)); break; case MOONSHOT: target.setMoonshotConfig(copy(aiPlatform, MoonshotConfig.class)); break; case HUNYUAN: target.setHunyuanConfig(copy(aiPlatform, HunyuanConfig.class)); break; case LINGYI: target.setLingyiConfig(copy(aiPlatform, LingyiConfig.class)); break; case OLLAMA: target.setOllamaConfig(copy(aiPlatform, OllamaConfig.class)); break; case MINIMAX: target.setMinimaxConfig(copy(aiPlatform, MinimaxConfig.class)); break; case BAICHUAN: target.setBaichuanConfig(copy(aiPlatform, BaichuanConfig.class)); break; case DASHSCOPE: target.setDashScopeConfig(copy(aiPlatform, DashScopeConfig.class)); break; case DOUBAO: target.setDoubaoConfig(copy(aiPlatform, DoubaoConfig.class)); break; case JINA: target.setJinaConfig(copy(aiPlatform, JinaConfig.class)); break; default: throw new IllegalArgumentException("Unsupported platform type: " + platformType); } } private static T copy(AiPlatform aiPlatform, Class type) { try { T target = type.getDeclaredConstructor().newInstance(); BeanUtil.copyProperties(aiPlatform, target, CopyOptions.create().ignoreNullValue()); return target; } catch (InstantiationException e) { throw new IllegalStateException("Cannot instantiate config type: " + type.getName(), e); } catch (IllegalAccessException e) { throw new IllegalStateException("Cannot access config type: " + type.getName(), e); } catch (InvocationTargetException e) { throw new IllegalStateException("Cannot invoke config constructor: " + type.getName(), e); } catch (NoSuchMethodException e) { throw new IllegalStateException("Missing no-args constructor for config type: " + type.getName(), e); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/FreeAiService.java ================================================ package io.github.lnyocly.ai4j.service.factory; import io.github.lnyocly.ai4j.rag.RagService; import io.github.lnyocly.ai4j.rag.Reranker; import io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline; import io.github.lnyocly.ai4j.service.AiConfig; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IAudioService; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.service.IImageService; import io.github.lnyocly.ai4j.service.IRerankService; import io.github.lnyocly.ai4j.service.IRealtimeService; import io.github.lnyocly.ai4j.service.IResponsesService; import io.github.lnyocly.ai4j.vector.store.VectorStore; import java.util.Collections; import java.util.Set; /** * 兼容旧版本的多实例聊天入口。 * *

主线入口仍然是 {@link AiService}。如果需要正式的多实例管理,请使用 * {@link AiServiceRegistry}。本类保留旧构造方式和静态获取方式,仅作为兼容壳。

*/ @Deprecated public class FreeAiService { private static volatile AiServiceRegistry registry = DefaultAiServiceRegistry.empty(); private final Configuration configuration; private final AiConfig aiConfig; private final AiServiceFactory aiServiceFactory; public FreeAiService(Configuration configuration, AiConfig aiConfig) { this(configuration, aiConfig, new DefaultAiServiceFactory()); } public FreeAiService(Configuration configuration, AiConfig aiConfig, AiServiceFactory aiServiceFactory) { this.configuration = configuration; this.aiConfig = aiConfig; this.aiServiceFactory = aiServiceFactory; init(); } public FreeAiService(AiServiceRegistry registry) { this.configuration = null; this.aiConfig = null; this.aiServiceFactory = null; setRegistry(registry); } public void init() { if (configuration == null) { return; } setRegistry(DefaultAiServiceRegistry.from(configuration, aiConfig, aiServiceFactory)); } public static IChatService getChatService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getChatService(id); } public static AiService getAiService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registration.getAiService(); } public static IEmbeddingService getEmbeddingService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getEmbeddingService(id); } public static IAudioService getAudioService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getAudioService(id); } public static IRealtimeService getRealtimeService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getRealtimeService(id); } public static IImageService getImageService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getImageService(id); } public static IResponsesService getResponsesService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getResponsesService(id); } public static IRerankService getRerankService(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getRerankService(id); } public static RagService getRagService(String id, VectorStore vectorStore) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getRagService(id, vectorStore); } public static IngestionPipeline getIngestionPipeline(String id, VectorStore vectorStore) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getIngestionPipeline(id, vectorStore); } public static IngestionPipeline getPineconeIngestionPipeline(String id) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getPineconeIngestionPipeline(id); } public static Reranker getModelReranker(String id, String model) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getModelReranker(id, model); } public static Reranker getModelReranker(String id, String model, Integer topN, String instruction) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getModelReranker(id, model, topN, instruction); } public static Reranker getModelReranker(String id, String model, Integer topN, String instruction, boolean returnDocuments, boolean appendRemainingHits) { AiServiceRegistration registration = registry.find(id); return registration == null ? null : registry.getModelReranker(id, model, topN, instruction, returnDocuments, appendRemainingHits); } public static boolean contains(String id) { return registry.contains(id); } public static Set ids() { return registry.ids(); } public static AiServiceRegistry getRegistry() { return registry; } private static void setRegistry(AiServiceRegistry registry) { FreeAiService.registry = registry == null ? DefaultAiServiceRegistry.empty() : registry; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/service/spi/ServiceLoaderUtil.java ================================================ package io.github.lnyocly.ai4j.service.spi; import lombok.extern.slf4j.Slf4j; import java.util.ServiceLoader; /** * @Author cly * @Description SPI服务加载类 * @Date 2024/10/16 23:25 */ @Slf4j public class ServiceLoaderUtil { public static T load(Class service) { ServiceLoader loader = ServiceLoader.load(service); for (T impl : loader) { log.info("Loaded SPI implementation: {}", impl.getClass().getSimpleName()); return impl; } throw new IllegalStateException("No implementation found for " + service.getName()); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/skill/SkillDescriptor.java ================================================ package io.github.lnyocly.ai4j.skill; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class SkillDescriptor { private String name; private String description; private String skillFilePath; private String source; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/skill/Skills.java ================================================ package io.github.lnyocly.ai4j.skill; import io.github.lnyocly.ai4j.tool.BuiltInToolContext; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; public final class Skills { private static final String SKILL_FILE_NAME = "SKILL.md"; private Skills() { } public static DiscoveryResult discoverDefault(Path workspaceRoot) { return discoverDefault(workspaceRoot, null); } public static DiscoveryResult discoverDefault(String workspaceRoot, List skillDirectories) { return discoverDefault(isBlank(workspaceRoot) ? null : Paths.get(workspaceRoot), skillDirectories); } public static DiscoveryResult discoverDefault(Path workspaceRoot, List skillDirectories) { Path resolvedWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot); List roots = resolveSkillRoots(resolvedWorkspaceRoot, skillDirectories); return discover(resolvedWorkspaceRoot, roots); } public static DiscoveryResult discover(Path workspaceRoot, List roots) { Path resolvedWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot); Map byName = new LinkedHashMap(); Set allowedReadRoots = new LinkedHashSet(); if (roots != null) { for (Path root : roots) { if (root == null || !Files.isDirectory(root)) { continue; } allowedReadRoots.add(root.toAbsolutePath().normalize().toString()); for (SkillDescriptor descriptor : discoverFromRoot(root, resolvedWorkspaceRoot)) { String normalizedName = normalizeKey(descriptor.getName()); if (!byName.containsKey(normalizedName)) { byName.put(normalizedName, descriptor); } } } } return new DiscoveryResult( new ArrayList(byName.values()), new ArrayList(allowedReadRoots) ); } public static BuiltInToolContext createToolContext(Path workspaceRoot) { DiscoveryResult discovery = discoverDefault(workspaceRoot); return createToolContext(workspaceRoot, discovery); } public static BuiltInToolContext createToolContext(Path workspaceRoot, DiscoveryResult discovery) { Path resolvedWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot); List allowedReadRoots = discovery == null ? Collections.emptyList() : discovery.getAllowedReadRoots(); return BuiltInToolContext.builder() .workspaceRoot(resolvedWorkspaceRoot.toString()) .allowedReadRoots(new ArrayList(allowedReadRoots)) .build(); } public static String appendAvailableSkillsPrompt(String basePrompt, List availableSkills) { String skillPrompt = buildAvailableSkillsPrompt(availableSkills); if (isBlank(basePrompt)) { return skillPrompt; } if (isBlank(skillPrompt)) { return basePrompt; } return basePrompt + "\n\n" + skillPrompt; } public static void appendAvailableSkillsPrompt(StringBuilder builder, List availableSkills) { if (builder == null) { return; } String skillPrompt = buildAvailableSkillsPrompt(availableSkills); if (isBlank(skillPrompt)) { return; } if (builder.length() > 0 && !endsWithBlankLine(builder)) { builder.append("\n\n"); } builder.append(skillPrompt); } public static String buildAvailableSkillsPrompt(List availableSkills) { if (availableSkills == null || availableSkills.isEmpty()) { return null; } StringBuilder builder = new StringBuilder(); builder.append("Some reusable skills are installed. Do not read every skill file up front. ") .append("When the task clearly matches a skill, read that SKILL.md with read_file first and then follow it.\n"); builder.append("\n"); for (SkillDescriptor skill : availableSkills) { if (skill == null) { continue; } builder.append("- name: ").append(firstNonBlank(skill.getName(), "skill")).append("\n"); builder.append(" path: ").append(firstNonBlank(skill.getSkillFilePath(), "(missing)")).append("\n"); builder.append(" description: ").append(firstNonBlank(skill.getDescription(), "No description available.")).append("\n"); } builder.append("\n"); builder.append("Only use a skill after reading its SKILL.md. Prefer the smallest relevant skill set and reuse read_file instead of asking for a dedicated skill tool."); return builder.toString().trim(); } private static List resolveSkillRoots(Path workspaceRoot, List skillDirectories) { Set roots = new LinkedHashSet(); roots.add(workspaceRoot.resolve(".ai4j").resolve("skills").toAbsolutePath().normalize()); String userHome = System.getProperty("user.home"); if (!isBlank(userHome)) { roots.add(Paths.get(userHome).resolve(".ai4j").resolve("skills").toAbsolutePath().normalize()); } if (skillDirectories != null) { for (String configuredRoot : skillDirectories) { if (isBlank(configuredRoot)) { continue; } Path root = Paths.get(configuredRoot); if (!root.isAbsolute()) { root = workspaceRoot.resolve(configuredRoot); } roots.add(root.toAbsolutePath().normalize()); } } return new ArrayList(roots); } private static List discoverFromRoot(Path root, Path workspaceRoot) { Path directSkillFile = resolveSkillFile(root); if (directSkillFile != null) { return Collections.singletonList(buildDescriptor(directSkillFile, root, workspaceRoot)); } List descriptors = new ArrayList(); try (DirectoryStream stream = Files.newDirectoryStream(root)) { for (Path child : stream) { if (!Files.isDirectory(child)) { continue; } Path skillFile = resolveSkillFile(child); if (skillFile == null) { continue; } descriptors.add(buildDescriptor(skillFile, root, workspaceRoot)); } } catch (IOException ignored) { } return descriptors; } private static Path resolveSkillFile(Path directory) { Path upper = directory.resolve(SKILL_FILE_NAME); if (Files.isRegularFile(upper)) { return upper; } Path lower = directory.resolve("skill.md"); return Files.isRegularFile(lower) ? lower : null; } private static SkillDescriptor buildDescriptor(Path skillFile, Path skillRoot, Path workspaceRoot) { String content = readQuietly(skillFile); String name = firstNonBlank( parseFrontMatterValue(content, "name"), parseHeading(content), inferName(skillFile) ); String description = firstNonBlank( parseFrontMatterValue(content, "description"), parseFirstParagraph(content), "No description available." ); return SkillDescriptor.builder() .name(name) .description(description) .skillFilePath(skillFile.toAbsolutePath().normalize().toString()) .source(resolveSource(skillRoot, workspaceRoot)) .build(); } private static String resolveSource(Path skillRoot, Path workspaceRoot) { if (skillRoot != null && workspaceRoot != null && skillRoot.startsWith(workspaceRoot)) { return "workspace"; } return "global"; } private static Path normalizeWorkspaceRoot(Path workspaceRoot) { if (workspaceRoot == null) { return Paths.get(".").toAbsolutePath().normalize(); } return workspaceRoot.toAbsolutePath().normalize(); } private static String readQuietly(Path skillFile) { try { return new String(Files.readAllBytes(skillFile), StandardCharsets.UTF_8); } catch (IOException ex) { return ""; } } private static String parseFrontMatterValue(String content, String key) { if (isBlank(content) || isBlank(key)) { return null; } boolean inFrontMatter = false; for (String line : content.split("\\r?\\n")) { String trimmed = line.trim(); if ("---".equals(trimmed)) { if (!inFrontMatter) { inFrontMatter = true; continue; } return null; } if (!inFrontMatter) { break; } String prefix = key + ":"; if (trimmed.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { return stripQuotes(trimmed.substring(prefix.length()).trim()); } } return null; } private static String parseHeading(String content) { if (isBlank(content)) { return null; } for (String line : content.split("\\r?\\n")) { String trimmed = line.trim(); if (trimmed.startsWith("#")) { return trimmed.replaceFirst("^#+\\s*", "").trim(); } } return null; } private static String parseFirstParagraph(String content) { if (isBlank(content)) { return null; } String[] lines = content.split("\\r?\\n"); StringBuilder paragraph = new StringBuilder(); boolean inFrontMatter = false; for (String line : lines) { String trimmed = line.trim(); if ("---".equals(trimmed) && paragraph.length() == 0) { inFrontMatter = !inFrontMatter; continue; } if (inFrontMatter || trimmed.isEmpty() || trimmed.startsWith("#")) { if (paragraph.length() > 0) { break; } continue; } if (paragraph.length() > 0) { paragraph.append(' '); } paragraph.append(trimmed); } return paragraph.length() == 0 ? null : paragraph.toString().trim(); } private static String inferName(Path skillFile) { Path parent = skillFile == null ? null : skillFile.getParent(); if (parent == null) { return "skill"; } return parent.getFileName().toString(); } private static boolean endsWithBlankLine(StringBuilder builder) { int length = builder.length(); return length >= 2 && builder.charAt(length - 1) == '\n' && builder.charAt(length - 2) == '\n'; } private static String normalizeKey(String value) { return isBlank(value) ? "" : value.trim().toLowerCase(Locale.ROOT); } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private static String stripQuotes(String value) { if (isBlank(value)) { return null; } String normalized = value.trim(); if ((normalized.startsWith("\"") && normalized.endsWith("\"")) || (normalized.startsWith("'") && normalized.endsWith("'"))) { return normalized.substring(1, normalized.length() - 1).trim(); } return normalized; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } public static final class DiscoveryResult { private final List skills; private final List allowedReadRoots; public DiscoveryResult(List skills, List allowedReadRoots) { this.skills = skills == null ? Collections.emptyList() : skills; this.allowedReadRoots = allowedReadRoots == null ? Collections.emptyList() : allowedReadRoots; } public List getSkills() { return skills; } public List getAllowedReadRoots() { return allowedReadRoots; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/token/TikTokensUtil.java ================================================ package io.github.lnyocly.ai4j.token; import com.knuddels.jtokkit.Encodings; import com.knuddels.jtokkit.api.Encoding; import com.knuddels.jtokkit.api.EncodingRegistry; import com.knuddels.jtokkit.api.EncodingType; import com.knuddels.jtokkit.api.ModelType; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; /** * @author cly */ @Slf4j public class TikTokensUtil { /** * 模型名称对应Encoding */ private static final Map modelMap = new HashMap<>(); /** * registry实例 */ private static final EncodingRegistry registry = Encodings.newDefaultEncodingRegistry(); static { for (ModelType model : ModelType.values()){ Optional encodingForModel = registry.getEncodingForModel(model.getName()); encodingForModel.ifPresent(encoding -> modelMap.put(model.getName(), encoding)); } } /** * 可以简单点用,直接传入list.toString() * @param encodingType * @param content * @return */ public static int tokens(EncodingType encodingType, String content){ Encoding encoding = registry.getEncoding(encodingType); return encoding.countTokens(content); } public static int tokens(String modelName, String content) { if (StringUtils.isEmpty(content)) { return 0; } Encoding encoding = modelMap.get(modelName); return encoding.countTokens(content); } public static int tokens(String modelName, List messages) { Encoding encoding = modelMap.get(modelName); if (ObjectUtils.isEmpty(encoding)) { throw new IllegalArgumentException("不支持计算Token的模型: " + modelName); } int tokensPerMessage = 0; int tokensPerName = 0; if (modelName.startsWith("gpt-4")) { tokensPerMessage = 3; tokensPerName = 1; } else if (modelName.startsWith("gpt-3.5")) { tokensPerMessage = 4; tokensPerName = -1; } int sum = 0; for (ChatMessage message : messages) { sum += tokensPerMessage; sum += encoding.countTokens(message.getContent().getText()); sum += encoding.countTokens(message.getRole()); if(StringUtils.isNotEmpty(message.getName())){ sum += encoding.countTokens(message.getName()); sum += tokensPerName; } } sum += 3; // every reply is primed with <|start|>assistant<|message|> return sum; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInProcessRegistry.java ================================================ package io.github.lnyocly.ai4j.tool; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; final class BuiltInProcessRegistry { private final BuiltInToolContext context; private final Map processes = new ConcurrentHashMap(); BuiltInProcessRegistry(BuiltInToolContext context) { this.context = context; } Map start(String command, String cwd) throws IOException { if (isBlank(command)) { throw new IllegalArgumentException("command is required"); } Path workingDirectory = context.resolveWorkspacePath(cwd); ProcessBuilder processBuilder = new ProcessBuilder(buildShellCommand(command)); processBuilder.directory(workingDirectory.toFile()); Process process = processBuilder.start(); Charset charset = resolveShellCharset(); String processId = "proc_" + UUID.randomUUID().toString().replace("-", ""); ManagedProcess managed = new ManagedProcess( processId, command, workingDirectory.toString(), process, context.getMaxProcessOutputChars(), charset, context.getProcessStopGraceMs() ); processes.put(processId, managed); managed.startReaders(); managed.startWatcher(); return managed.snapshot(); } Map status(String processId) { return getManagedProcess(processId).snapshot(); } List> list() { List> result = new ArrayList>(); for (ManagedProcess managed : processes.values()) { result.add(managed.snapshot()); } result.sort(new Comparator>() { @Override public int compare(Map left, Map right) { Long leftStartedAt = toLong(left.get("startedAt")); Long rightStartedAt = toLong(right.get("startedAt")); return leftStartedAt.compareTo(rightStartedAt); } }); return result; } Map logs(String processId, Long offset, Integer limit) { ManagedProcess managed = getManagedProcess(processId); long effectiveOffset = offset == null || offset.longValue() < 0L ? 0L : offset.longValue(); int effectiveLimit = limit == null || limit.intValue() <= 0 ? context.getDefaultBashLogChars() : limit.intValue(); return managed.readLogs(effectiveOffset, effectiveLimit); } int write(String processId, String input) throws IOException { ManagedProcess managed = getManagedProcess(processId); byte[] bytes = (input == null ? "" : input).getBytes(managed.charset); OutputStream outputStream = managed.process.getOutputStream(); outputStream.write(bytes); outputStream.flush(); return bytes.length; } Map stop(String processId) { ManagedProcess managed = getManagedProcess(processId); managed.stop(); return managed.snapshot(); } private ManagedProcess getManagedProcess(String processId) { ManagedProcess managed = processes.get(processId); if (managed == null) { throw new IllegalArgumentException("Unknown processId: " + processId); } return managed; } private List buildShellCommand(String command) { if (isWindows()) { return Arrays.asList("cmd.exe", "/c", command); } return Arrays.asList("sh", "-lc", command); } private Charset resolveShellCharset() { Charset explicit = firstSupportedCharset(new String[]{ System.getProperty("ai4j.shell.encoding"), System.getenv("AI4J_SHELL_ENCODING") }); if (explicit != null) { return explicit; } if (!isWindows()) { return StandardCharsets.UTF_8; } Charset platform = firstSupportedCharset(new String[]{ System.getProperty("native.encoding"), System.getProperty("sun.jnu.encoding"), System.getProperty("file.encoding"), Charset.defaultCharset().name() }); return platform == null ? Charset.defaultCharset() : platform; } private Charset firstSupportedCharset(String[] candidates) { if (candidates == null) { return null; } for (String candidate : candidates) { if (isBlank(candidate)) { continue; } try { return Charset.forName(candidate.trim()); } catch (Exception ignored) { } } return null; } private boolean isWindows() { return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private Long toLong(Object value) { if (value instanceof Number) { return ((Number) value).longValue(); } return 0L; } private static final class ManagedProcess { private final String processId; private final String command; private final String workingDirectory; private final Process process; private final ProcessOutputBuffer outputBuffer; private final long startedAt; private final Long pid; private final Charset charset; private final long stopGraceMs; private volatile String status; private volatile Integer exitCode; private volatile Long endedAt; private ManagedProcess(String processId, String command, String workingDirectory, Process process, int maxOutputChars, Charset charset, long stopGraceMs) { this.processId = processId; this.command = command; this.workingDirectory = workingDirectory; this.process = process; this.outputBuffer = new ProcessOutputBuffer(maxOutputChars); this.startedAt = System.currentTimeMillis(); this.pid = safePid(process); this.charset = charset; this.stopGraceMs = stopGraceMs; this.status = "RUNNING"; } private void startReaders() { Thread stdoutThread = new Thread( new StreamCollector(process.getInputStream(), outputBuffer, "[stdout] ", charset), processId + "-stdout" ); Thread stderrThread = new Thread( new StreamCollector(process.getErrorStream(), outputBuffer, "[stderr] ", charset), processId + "-stderr" ); stdoutThread.setDaemon(true); stderrThread.setDaemon(true); stdoutThread.start(); stderrThread.start(); } private void startWatcher() { Thread watcher = new Thread(new Runnable() { @Override public void run() { try { int code = process.waitFor(); exitCode = code; endedAt = System.currentTimeMillis(); if (!"STOPPED".equals(status)) { status = "EXITED"; } } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } } }, processId + "-watcher"); watcher.setDaemon(true); watcher.start(); } private Map snapshot() { Map result = new LinkedHashMap(); result.put("processId", processId); result.put("command", command); result.put("workingDirectory", workingDirectory); result.put("status", status); result.put("pid", pid); result.put("exitCode", exitCode); result.put("startedAt", startedAt); result.put("endedAt", endedAt); result.put("restored", false); result.put("controlAvailable", true); return result; } private Map readLogs(long offset, int limit) { return outputBuffer.read(processId, offset, limit, status, exitCode); } private void stop() { if (!process.isAlive()) { if (exitCode == null) { try { exitCode = process.exitValue(); } catch (IllegalThreadStateException ignored) { exitCode = -1; } } if (endedAt == null) { endedAt = System.currentTimeMillis(); } status = "STOPPED"; return; } process.destroy(); try { if (!process.waitFor(stopGraceMs, TimeUnit.MILLISECONDS)) { process.destroyForcibly(); process.waitFor(5L, TimeUnit.SECONDS); } } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } status = "STOPPED"; endedAt = System.currentTimeMillis(); try { exitCode = process.exitValue(); } catch (IllegalThreadStateException ignored) { exitCode = -1; } } private static Long safePid(Process process) { try { return (Long) process.getClass().getMethod("pid").invoke(process); } catch (Exception ignored) { return null; } } } private static final class StreamCollector implements Runnable { private final InputStream inputStream; private final ProcessOutputBuffer outputBuffer; private final String prefix; private final Charset charset; private StreamCollector(InputStream inputStream, ProcessOutputBuffer outputBuffer, String prefix, Charset charset) { this.inputStream = inputStream; this.outputBuffer = outputBuffer; this.prefix = prefix; this.charset = charset; } @Override public void run() { try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) { String line; while ((line = reader.readLine()) != null) { outputBuffer.append(prefix + line + "\n"); } } catch (IOException ignored) { } } } private static final class ProcessOutputBuffer { private final int maxChars; private final StringBuilder buffer = new StringBuilder(); private long startOffset = 0L; private ProcessOutputBuffer(int maxChars) { this.maxChars = Math.max(1024, maxChars); } private synchronized void append(String text) { if (text == null || text.isEmpty()) { return; } buffer.append(text); int overflow = buffer.length() - maxChars; if (overflow > 0) { buffer.delete(0, overflow); startOffset += overflow; } } private synchronized Map read(String processId, long offset, int limit, String status, Integer exitCode) { long effectiveOffset = Math.max(offset, startOffset); int from = (int) Math.max(0L, effectiveOffset - startOffset); int to = Math.min(buffer.length(), from + Math.max(1, limit)); Map result = new LinkedHashMap(); result.put("processId", processId); result.put("offset", effectiveOffset); result.put("nextOffset", startOffset + to); result.put("truncated", offset < startOffset); result.put("content", buffer.substring(Math.min(from, buffer.length()), Math.min(to, buffer.length()))); result.put("status", status); result.put("exitCode", exitCode); return result; } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInToolContext.java ================================================ package io.github.lnyocly.ai4j.tool; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class BuiltInToolContext { @Builder.Default private String workspaceRoot = Paths.get(".").toAbsolutePath().normalize().toString(); @Builder.Default private boolean allowOutsideWorkspace = false; @Builder.Default private List allowedReadRoots = new ArrayList(); @Builder.Default private int defaultReadMaxChars = 12000; @Builder.Default private long defaultCommandTimeoutMs = 30000L; @Builder.Default private int defaultBashLogChars = 12000; @Builder.Default private int maxProcessOutputChars = 120000; @Builder.Default private long processStopGraceMs = 1000L; private transient BuiltInProcessRegistry processRegistry; public Path getWorkspaceRootPath() { if (isBlank(workspaceRoot)) { return Paths.get(".").toAbsolutePath().normalize(); } return Paths.get(workspaceRoot).toAbsolutePath().normalize(); } public Path resolveWorkspacePath(String path) { Path root = getWorkspaceRootPath(); if (isBlank(path)) { return root; } Path candidate = Paths.get(path); if (!candidate.isAbsolute()) { candidate = root.resolve(path); } candidate = candidate.toAbsolutePath().normalize(); if (!allowOutsideWorkspace && !candidate.startsWith(root)) { throw new IllegalArgumentException("Path escapes workspace root: " + path); } return candidate; } public Path resolveReadablePath(String path) { Path root = getWorkspaceRootPath(); if (isBlank(path)) { return root; } Path candidate = Paths.get(path); if (!candidate.isAbsolute()) { candidate = root.resolve(path); } candidate = candidate.toAbsolutePath().normalize(); if (allowOutsideWorkspace || candidate.startsWith(root)) { return candidate; } for (Path allowedRoot : getAllowedReadRootPaths()) { if (candidate.startsWith(allowedRoot)) { return candidate; } } throw new IllegalArgumentException("Path escapes workspace root: " + path); } public List getAllowedReadRootPaths() { if (allowedReadRoots == null || allowedReadRoots.isEmpty()) { return Collections.emptyList(); } List paths = new ArrayList(); for (String allowedReadRoot : allowedReadRoots) { if (isBlank(allowedReadRoot)) { continue; } paths.add(Paths.get(allowedReadRoot).toAbsolutePath().normalize()); } return paths; } BuiltInProcessRegistry getOrCreateProcessRegistry() { if (processRegistry != null) { return processRegistry; } synchronized (this) { if (processRegistry == null) { processRegistry = new BuiltInProcessRegistry(this); } return processRegistry; } } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutor.java ================================================ package io.github.lnyocly.ai4j.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; public final class BuiltInToolExecutor { private static final String BEGIN_PATCH = "*** Begin Patch"; private static final String END_PATCH = "*** End Patch"; private static final String ADD_FILE = "*** Add File:"; private static final String ADD_FILE_ALIAS = "*** Add:"; private static final String UPDATE_FILE = "*** Update File:"; private static final String UPDATE_FILE_ALIAS = "*** Update:"; private static final String DELETE_FILE = "*** Delete File:"; private static final String DELETE_FILE_ALIAS = "*** Delete:"; private BuiltInToolExecutor() { } public static boolean supports(String functionName) { return BuiltInTools.allCodingToolNames().contains(functionName); } public static String invoke(String functionName, String argument, BuiltInToolContext context) throws Exception { if (!supports(functionName)) { return null; } BuiltInToolContext effectiveContext = context == null ? BuiltInToolContext.builder().build() : context; JSONObject arguments = parseArguments(argument); if (BuiltInTools.READ_FILE.equals(functionName)) { return JSON.toJSONString(readFile(effectiveContext, arguments)); } if (BuiltInTools.WRITE_FILE.equals(functionName)) { return JSON.toJSONString(writeFile(effectiveContext, arguments)); } if (BuiltInTools.APPLY_PATCH.equals(functionName)) { return JSON.toJSONString(applyPatch(effectiveContext, arguments)); } if (BuiltInTools.BASH.equals(functionName)) { return JSON.toJSONString(runBash(effectiveContext, arguments)); } throw new IllegalArgumentException("Unsupported built-in tool: " + functionName); } private static Map readFile(BuiltInToolContext context, JSONObject arguments) throws IOException { String path = arguments.getString("path"); if (isBlank(path)) { throw new IllegalArgumentException("path is required"); } Path file = context.resolveReadablePath(path); if (!Files.exists(file)) { throw new IllegalArgumentException("File does not exist: " + path); } if (Files.isDirectory(file)) { throw new IllegalArgumentException("Path is a directory: " + path); } List lines = Files.readAllLines(file, StandardCharsets.UTF_8); int startLine = arguments.getInteger("startLine") == null || arguments.getInteger("startLine").intValue() < 1 ? 1 : arguments.getInteger("startLine").intValue(); int endLine = arguments.getInteger("endLine") == null || arguments.getInteger("endLine").intValue() > lines.size() ? lines.size() : arguments.getInteger("endLine").intValue(); if (endLine < startLine) { endLine = startLine - 1; } StringBuilder contentBuilder = new StringBuilder(); for (int i = startLine; i <= endLine; i++) { if (i > lines.size()) { break; } if (contentBuilder.length() > 0) { contentBuilder.append('\n'); } contentBuilder.append(lines.get(i - 1)); } int maxChars = arguments.getInteger("maxChars") == null || arguments.getInteger("maxChars").intValue() <= 0 ? context.getDefaultReadMaxChars() : arguments.getInteger("maxChars").intValue(); String content = contentBuilder.toString(); boolean truncated = false; if (content.length() > maxChars) { content = content.substring(0, maxChars); truncated = true; } Map result = new LinkedHashMap(); result.put("path", toDisplayPath(context, file)); result.put("content", content); result.put("startLine", startLine); result.put("endLine", endLine); result.put("truncated", truncated); return result; } private static Map writeFile(BuiltInToolContext context, JSONObject arguments) throws IOException { String path = safeTrim(arguments.getString("path")); if (isBlank(path)) { throw new IllegalArgumentException("path is required"); } String content = arguments.containsKey("content") && arguments.get("content") != null ? arguments.getString("content") : ""; String mode = firstNonBlank(safeTrim(arguments.getString("mode")), "overwrite").toLowerCase(Locale.ROOT); Path file = context.resolveWorkspacePath(path); if (Files.exists(file) && Files.isDirectory(file)) { throw new IllegalArgumentException("Target is a directory: " + path); } boolean existed = Files.exists(file); boolean appended = false; boolean created; byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8); Path parent = file.getParent(); if (parent != null) { Files.createDirectories(parent); } if ("create".equals(mode)) { if (existed) { throw new IllegalArgumentException("File already exists: " + path); } Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE}); created = true; } else if ("overwrite".equals(mode)) { Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE}); created = !existed; } else if ("append".equals(mode)) { Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE}); created = !existed; appended = true; } else { throw new IllegalArgumentException("Unsupported write mode: " + mode); } Map result = new LinkedHashMap(); result.put("path", path); result.put("resolvedPath", file.toString()); result.put("mode", mode); result.put("created", created); result.put("appended", appended); result.put("bytesWritten", bytes.length); return result; } private static Map runBash(BuiltInToolContext context, JSONObject arguments) throws Exception { String action = firstNonBlank(arguments.getString("action"), "exec"); if ("exec".equals(action)) { return exec(context, arguments); } BuiltInProcessRegistry processRegistry = context.getOrCreateProcessRegistry(); if ("start".equals(action)) { return processRegistry.start(arguments.getString("command"), arguments.getString("cwd")); } if ("status".equals(action)) { return processRegistry.status(arguments.getString("processId")); } if ("logs".equals(action)) { return processRegistry.logs( arguments.getString("processId"), arguments.getLong("offset"), arguments.getInteger("limit") ); } if ("write".equals(action)) { String processId = arguments.getString("processId"); int bytesWritten = processRegistry.write(processId, arguments.getString("input")); Map result = new LinkedHashMap(); result.put("process", processRegistry.status(processId)); result.put("bytesWritten", bytesWritten); return result; } if ("stop".equals(action)) { return processRegistry.stop(arguments.getString("processId")); } if ("list".equals(action)) { Map result = new LinkedHashMap(); result.put("processes", processRegistry.list()); return result; } throw new IllegalArgumentException("Unsupported bash action: " + action); } private static Map exec(BuiltInToolContext context, JSONObject arguments) throws Exception { String command = arguments.getString("command"); if (isBlank(command)) { throw new IllegalArgumentException("command is required"); } Path workingDirectory = context.resolveWorkspacePath(arguments.getString("cwd")); long timeoutMs = arguments.getLong("timeoutMs") == null || arguments.getLong("timeoutMs").longValue() <= 0L ? context.getDefaultCommandTimeoutMs() : arguments.getLong("timeoutMs").longValue(); ProcessBuilder processBuilder; if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win")) { processBuilder = new ProcessBuilder("cmd.exe", "/c", command); } else { processBuilder = new ProcessBuilder("sh", "-lc", command); } processBuilder.directory(workingDirectory.toFile()); Process process = processBuilder.start(); Charset shellCharset = resolveShellCharset(); StringBuilder stdout = new StringBuilder(); StringBuilder stderr = new StringBuilder(); Thread stdoutThread = new Thread(new StreamCollector(process.getInputStream(), stdout, shellCharset), "ai4j-built-in-stdout"); Thread stderrThread = new Thread(new StreamCollector(process.getErrorStream(), stderr, shellCharset), "ai4j-built-in-stderr"); stdoutThread.start(); stderrThread.start(); boolean finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS); int exitCode; if (finished) { exitCode = process.exitValue(); } else { process.destroyForcibly(); process.waitFor(5L, TimeUnit.SECONDS); exitCode = -1; if (stderr.length() > 0) { stderr.append('\n'); } stderr.append("Command timed out before exit. If it is interactive or long-running, use bash action=start and then bash action=logs/status/write/stop instead of bash action=exec."); } stdoutThread.join(); stderrThread.join(); Map result = new LinkedHashMap(); result.put("command", command); result.put("workingDirectory", workingDirectory.toString()); result.put("stdout", stdout.toString()); result.put("stderr", stderr.toString()); result.put("exitCode", exitCode); result.put("timedOut", !finished); return result; } private static Charset resolveShellCharset() { try { String explicit = System.getProperty("ai4j.shell.encoding"); if (!isBlank(explicit)) { return Charset.forName(explicit.trim()); } } catch (Exception ignored) { } try { String env = System.getenv("AI4J_SHELL_ENCODING"); if (!isBlank(env)) { return Charset.forName(env.trim()); } } catch (Exception ignored) { } if (!System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win")) { return StandardCharsets.UTF_8; } String[] candidates = new String[]{ System.getProperty("native.encoding"), System.getProperty("sun.jnu.encoding"), System.getProperty("file.encoding"), Charset.defaultCharset().name() }; for (String candidate : candidates) { if (isBlank(candidate)) { continue; } try { return Charset.forName(candidate.trim()); } catch (Exception ignored) { } } return Charset.defaultCharset(); } private static Map applyPatch(BuiltInToolContext context, JSONObject arguments) throws IOException { String patch = arguments.getString("patch"); if (isBlank(patch)) { throw new IllegalArgumentException("patch is required"); } return applyPatch(context, patch); } private static Map applyPatch(BuiltInToolContext context, String patchText) throws IOException { List lines = normalizeLines(patchText); if (lines.size() < 2 || !BEGIN_PATCH.equals(lines.get(0)) || !END_PATCH.equals(lines.get(lines.size() - 1))) { throw new IllegalArgumentException("Invalid patch envelope"); } int index = 1; int operationsApplied = 0; Set changedFiles = new LinkedHashSet(); List> fileChanges = new ArrayList>(); while (index < lines.size() - 1) { String line = lines.get(index); PatchDirective directive = parseDirective(line); if (directive == null) { if (line.trim().isEmpty()) { index++; continue; } throw new IllegalArgumentException("Unsupported patch line: " + line); } if ("add".equals(directive.operation)) { PatchOperation operation = applyAddFile(context, lines, index + 1, directive.path); index = operation.nextIndex; operationsApplied++; changedFiles.add(operation.path); fileChanges.add(operation.fileChange); continue; } if ("update".equals(directive.operation)) { PatchOperation operation = applyUpdateFile(context, lines, index + 1, directive.path); index = operation.nextIndex; operationsApplied++; changedFiles.add(operation.path); fileChanges.add(operation.fileChange); continue; } if ("delete".equals(directive.operation)) { PatchOperation operation = applyDeleteFile(context, directive.path, index + 1); index = operation.nextIndex; operationsApplied++; changedFiles.add(operation.path); fileChanges.add(operation.fileChange); } } Map result = new LinkedHashMap(); result.put("filesChanged", changedFiles.size()); result.put("operationsApplied", operationsApplied); result.put("changedFiles", new ArrayList(changedFiles)); result.put("fileChanges", fileChanges); return result; } private static PatchOperation applyAddFile(BuiltInToolContext context, List lines, int startIndex, String path) throws IOException { Path file = context.resolveWorkspacePath(path); if (Files.exists(file)) { throw new IllegalArgumentException("File already exists: " + path); } List contentLines = new ArrayList(); int index = startIndex; while (index < lines.size() - 1 && !lines.get(index).startsWith("*** ")) { String line = lines.get(index); if (!line.startsWith("+")) { throw new IllegalArgumentException("Add file lines must start with '+': " + line); } contentLines.add(line.substring(1)); index++; } writePatchFile(file, joinLines(contentLines)); return new PatchOperation(index, normalizeRelativePath(path), buildFileChange(normalizeRelativePath(path), "add", contentLines.size(), 0)); } private static PatchOperation applyUpdateFile(BuiltInToolContext context, List lines, int startIndex, String path) throws IOException { Path file = context.resolveWorkspacePath(path); if (!Files.exists(file) || Files.isDirectory(file)) { throw new IllegalArgumentException("File does not exist: " + path); } List body = new ArrayList(); int index = startIndex; while (index < lines.size() - 1 && !lines.get(index).startsWith("*** ")) { body.add(lines.get(index)); index++; } List normalizedBody = normalizeUpdateBody(body); String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); String updated = applyUpdateBody(original, normalizedBody, path); writePatchFile(file, updated); return new PatchOperation( index, normalizeRelativePath(path), buildFileChange(normalizeRelativePath(path), "update", countPrefixedLines(normalizedBody, '+'), countPrefixedLines(normalizedBody, '-')) ); } private static PatchOperation applyDeleteFile(BuiltInToolContext context, String path, int startIndex) throws IOException { Path file = context.resolveWorkspacePath(path); if (!Files.exists(file) || Files.isDirectory(file)) { throw new IllegalArgumentException("File does not exist: " + path); } String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); int removed = splitContentLines(original).size(); Files.delete(file); return new PatchOperation(startIndex, normalizeRelativePath(path), buildFileChange(normalizeRelativePath(path), "delete", 0, removed)); } private static String applyUpdateBody(String original, List body, String path) { List originalLines = splitContentLines(original); List> hunks = parseHunks(body); List output = new ArrayList(); int cursor = 0; for (List hunk : hunks) { List anchor = resolveAnchor(hunk); int matchIndex = findAnchor(originalLines, cursor, anchor); if (matchIndex < 0) { throw new IllegalArgumentException("Failed to locate patch hunk in file: " + path); } appendRange(output, originalLines, cursor, matchIndex); int current = matchIndex; for (String line : hunk) { if (line.startsWith("@@")) { continue; } if (line.isEmpty()) { throw new IllegalArgumentException("Invalid empty patch line in update body"); } char prefix = line.charAt(0); String content = line.substring(1); switch (prefix) { case ' ': ensureMatch(originalLines, current, content, path); output.add(originalLines.get(current)); current++; break; case '-': ensureMatch(originalLines, current, content, path); current++; break; case '+': output.add(content); break; default: throw new IllegalArgumentException("Unsupported update line: " + line); } } cursor = current; } appendRange(output, originalLines, cursor, originalLines.size()); return joinLines(output); } private static List> parseHunks(List body) { List> hunks = new ArrayList>(); List current = new ArrayList(); for (String line : body) { if (line.startsWith("@@")) { if (!current.isEmpty()) { hunks.add(current); current = new ArrayList(); } current.add(line); continue; } if (line.startsWith(" ") || line.startsWith("+") || line.startsWith("-")) { current.add(line); continue; } if (line.trim().isEmpty()) { current.add(" "); continue; } throw new IllegalArgumentException("Unsupported update body line: " + line); } if (!current.isEmpty()) { hunks.add(current); } if (hunks.isEmpty()) { throw new IllegalArgumentException("Update file patch must contain at least one hunk"); } return hunks; } private static List resolveAnchor(List hunk) { List leading = new ArrayList(); for (String line : hunk) { if (line.startsWith("@@")) { continue; } if (line.startsWith(" ") || line.startsWith("-")) { leading.add(line.substring(1)); } else if (line.startsWith("+")) { break; } } if (!leading.isEmpty()) { return leading; } for (String line : hunk) { if (line.startsWith("+")) { return java.util.Collections.singletonList(line.substring(1)); } } return new ArrayList(); } private static int findAnchor(List originalLines, int cursor, List anchor) { if (anchor.isEmpty()) { return cursor; } for (int i = cursor; i <= originalLines.size() - anchor.size(); i++) { boolean match = true; for (int j = 0; j < anchor.size(); j++) { if (!originalLines.get(i + j).equals(anchor.get(j))) { match = false; break; } } if (match) { return i; } } return -1; } private static void appendRange(List output, List originalLines, int fromInclusive, int toExclusive) { for (int i = fromInclusive; i < toExclusive; i++) { output.add(originalLines.get(i)); } } private static void ensureMatch(List originalLines, int current, String expected, String path) { if (current >= originalLines.size()) { throw new IllegalArgumentException("Patch exceeds file length: " + path); } String actual = originalLines.get(current); if (!actual.equals(expected)) { throw new IllegalArgumentException("Patch context mismatch in file " + path + ": expected [" + expected + "] but found [" + actual + "]"); } } private static int countPrefixedLines(List lines, char prefix) { int count = 0; for (String line : lines) { if (!line.isEmpty() && line.charAt(0) == prefix) { count++; } } return count; } private static List normalizeUpdateBody(List body) { List normalized = new ArrayList(); for (String line : body) { if (line != null) { normalized.add(line); } } return normalized; } private static void writePatchFile(Path file, String content) throws IOException { Path parent = file.getParent(); if (parent != null) { Files.createDirectories(parent); } byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8); Files.write(file, bytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); } private static List splitContentLines(String content) { String normalized = content == null ? "" : content.replace("\r\n", "\n"); List lines = new ArrayList(); if (normalized.isEmpty()) { return lines; } String[] split = normalized.split("\n", -1); for (int i = 0; i < split.length; i++) { if (i == split.length - 1 && normalized.endsWith("\n") && split[i].isEmpty()) { continue; } lines.add(split[i]); } return lines; } private static String joinLines(List lines) { if (lines == null || lines.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (int i = 0; i < lines.size(); i++) { if (i > 0) { builder.append('\n'); } builder.append(lines.get(i)); } return builder.toString(); } private static List normalizeLines(String text) { String normalized = text == null ? "" : text.replace("\r\n", "\n"); String[] split = normalized.split("\n", -1); List lines = new ArrayList(split.length); for (String line : split) { lines.add(line); } if (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) { lines.remove(lines.size() - 1); } return lines; } private static PatchDirective parseDirective(String line) { if (line.startsWith(ADD_FILE)) { return new PatchDirective("add", safeTrim(line.substring(ADD_FILE.length()))); } if (line.startsWith(ADD_FILE_ALIAS)) { return new PatchDirective("add", safeTrim(line.substring(ADD_FILE_ALIAS.length()))); } if (line.startsWith(UPDATE_FILE)) { return new PatchDirective("update", safeTrim(line.substring(UPDATE_FILE.length()))); } if (line.startsWith(UPDATE_FILE_ALIAS)) { return new PatchDirective("update", safeTrim(line.substring(UPDATE_FILE_ALIAS.length()))); } if (line.startsWith(DELETE_FILE)) { return new PatchDirective("delete", safeTrim(line.substring(DELETE_FILE.length()))); } if (line.startsWith(DELETE_FILE_ALIAS)) { return new PatchDirective("delete", safeTrim(line.substring(DELETE_FILE_ALIAS.length()))); } return null; } private static JSONObject parseArguments(String rawArguments) { if (rawArguments == null || rawArguments.trim().isEmpty()) { return new JSONObject(); } return JSON.parseObject(rawArguments); } private static Map buildFileChange(String path, String operation, int linesAdded, int linesRemoved) { Map change = new LinkedHashMap(); change.put("path", path); change.put("operation", operation); change.put("linesAdded", linesAdded); change.put("linesRemoved", linesRemoved); return change; } private static String normalizeRelativePath(String path) { return path == null ? "" : path.replace('\\', '/'); } private static String toDisplayPath(BuiltInToolContext context, Path file) { Path root = context.getWorkspaceRootPath(); if (file == null) { return ""; } if (!file.startsWith(root)) { return file.toString().replace('\\', '/'); } if (file.equals(root)) { return "."; } return root.relativize(file).toString().replace('\\', '/'); } private static String safeTrim(String value) { return value == null ? null : value.trim(); } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static final class PatchDirective { private final String operation; private final String path; private PatchDirective(String operation, String path) { this.operation = operation; this.path = path; } } private static final class PatchOperation { private final int nextIndex; private final String path; private final Map fileChange; private PatchOperation(int nextIndex, String path, Map fileChange) { this.nextIndex = nextIndex; this.path = path; this.fileChange = fileChange; } } private static final class StreamCollector implements Runnable { private final InputStream inputStream; private final StringBuilder target; private final Charset charset; private StreamCollector(InputStream inputStream, StringBuilder target, Charset charset) { this.inputStream = inputStream; this.target = target; this.charset = charset; } @Override public void run() { try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) { String line; while ((line = reader.readLine()) != null) { synchronized (target) { if (target.length() > 0) { target.append('\n'); } target.append(line); } } } catch (IOException ignored) { } } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInTools.java ================================================ package io.github.lnyocly.ai4j.tool; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; public final class BuiltInTools { public static final String BASH = "bash"; public static final String READ_FILE = "read_file"; public static final String WRITE_FILE = "write_file"; public static final String APPLY_PATCH = "apply_patch"; private static final Set CODING_TOOL_NAMES = Collections.unmodifiableSet( new LinkedHashSet(Arrays.asList(BASH, READ_FILE, WRITE_FILE, APPLY_PATCH)) ); private static final Set READ_ONLY_CODING_TOOL_NAMES = Collections.unmodifiableSet( new LinkedHashSet(Arrays.asList(BASH, READ_FILE)) ); private static final List CODING_TOOLS = Collections.unmodifiableList(Arrays.asList( bashTool(), readFileTool(), writeFileTool(), applyPatchTool() )); private BuiltInTools() { } public static Tool readFileTool() { Map properties = new LinkedHashMap(); properties.put("path", property("string", "Relative file path inside the workspace, or an absolute path inside an approved read-only skill root.")); properties.put("startLine", property("integer", "First line number to read, starting from 1.")); properties.put("endLine", property("integer", "Last line number to read, inclusive.")); properties.put("maxChars", property("integer", "Maximum characters to return.")); return tool( READ_FILE, "Read a text file from the workspace or from an approved read-only skill directory.", properties, Collections.singletonList("path") ); } public static Tool bashTool() { Map properties = new LinkedHashMap(); properties.put("action", property("string", "bash action to perform.", Arrays.asList("exec", "start", "status", "logs", "write", "stop", "list"))); properties.put("command", property("string", "Command string to execute. Use exec for self-terminating commands; use start for interactive or long-running commands.")); properties.put("cwd", property("string", "Relative working directory inside the workspace.")); properties.put("timeoutMs", property("integer", "Execution timeout in milliseconds for exec.")); properties.put("processId", property("string", "Background process identifier.")); properties.put("offset", property("integer", "Log cursor offset.")); properties.put("limit", property("integer", "Maximum log characters to return.")); properties.put("input", property("string", "Text written to stdin for a background process started with action=start.")); return tool( BASH, "Execute non-interactive shell commands or manage interactive/background shell processes inside the workspace.", properties, Collections.singletonList("action") ); } public static Tool writeFileTool() { Map properties = new LinkedHashMap(); properties.put("path", property("string", "File path to write. Relative paths resolve from the workspace root; absolute paths are allowed.")); properties.put("content", property("string", "Full text content to write.")); properties.put("mode", property("string", "Write mode.", Arrays.asList("create", "overwrite", "append"))); return tool( WRITE_FILE, "Create, overwrite, or append a text file.", properties, Arrays.asList("path", "content") ); } public static Tool applyPatchTool() { Map properties = new LinkedHashMap(); properties.put("patch", property("string", "Patch text to apply. Must include *** Begin Patch and *** End Patch envelope.")); return tool( APPLY_PATCH, "Apply a structured patch to workspace files.", properties, Collections.singletonList("patch") ); } public static List codingTools() { return new ArrayList(CODING_TOOLS); } public static List tools(String... names) { List tools = new ArrayList(); if (names == null || names.length == 0) { return tools; } for (String name : names) { Tool tool = toolByName(name); if (tool != null) { tools.add(tool); } } return tools; } public static Set allCodingToolNames() { return CODING_TOOL_NAMES; } public static Set readOnlyCodingToolNames() { return READ_ONLY_CODING_TOOL_NAMES; } private static Tool tool(String name, String description, Map properties, List required) { Tool.Function.Parameter parameter = new Tool.Function.Parameter("object", properties, required); Tool.Function function = new Tool.Function(name, description, parameter); return new Tool("function", function); } private static Tool.Function.Property property(String type, String description) { return property(type, description, null); } private static Tool.Function.Property property(String type, String description, List enumValues) { return new Tool.Function.Property(type, description, enumValues, null); } private static Tool toolByName(String name) { if (READ_FILE.equals(name)) { return readFileTool(); } if (WRITE_FILE.equals(name)) { return writeFileTool(); } if (APPLY_PATCH.equals(name)) { return applyPatchTool(); } if (BASH.equals(name)) { return bashTool(); } return null; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tool/ResponseRequestToolResolver.java ================================================ package io.github.lnyocly.ai4j.tool; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.List; public final class ResponseRequestToolResolver { private ResponseRequestToolResolver() { } public static ResponseRequest resolve(ResponseRequest request) { if (request == null) { return null; } boolean hasFunctionRegistry = request.getFunctions() != null && !request.getFunctions().isEmpty(); boolean hasMcpRegistry = request.getMcpServices() != null && !request.getMcpServices().isEmpty(); if (!hasFunctionRegistry && !hasMcpRegistry) { return request; } List mergedTools = new ArrayList(); if (request.getTools() != null && !request.getTools().isEmpty()) { mergedTools.addAll(request.getTools()); } List resolvedTools = ToolUtil.getAllTools(request.getFunctions(), request.getMcpServices()); if (resolvedTools != null && !resolvedTools.isEmpty()) { mergedTools.addAll(resolvedTools); } return request.toBuilder() .tools(mergedTools) .build(); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tool/ToolUtil.java ================================================ package io.github.lnyocly.ai4j.tool; /** * @Author cly * @Description 统一工具管理器,支持 built-in tool、传统Function工具、本地MCP工具和远程MCP服务 */ import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.mcp.annotation.McpService; import io.github.lnyocly.ai4j.mcp.annotation.McpTool; import io.github.lnyocly.ai4j.mcp.annotation.McpParameter; import io.github.lnyocly.ai4j.mcp.gateway.McpGateway; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.reflections.Reflections; import org.reflections.scanners.Scanners; import org.reflections.util.ClasspathHelper; import org.reflections.util.ConfigurationBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class ToolUtil { private static final Logger log = LoggerFactory.getLogger(ToolUtil.class); // 反射扫描器,支持注解和方法扫描 private static final Reflections reflections = new Reflections(new ConfigurationBuilder() .setUrls(ClasspathHelper.forPackage("")) .setScanners(Scanners.TypesAnnotated, Scanners.MethodsAnnotated)); // === 传统Function工具缓存 === public static final Map toolEntityMap = new ConcurrentHashMap<>(); public static final Map> toolClassMap = new ConcurrentHashMap<>(); public static final Map> toolRequestMap = new ConcurrentHashMap<>(); // === 本地MCP工具缓存 === private static final Map mcpToolCache = new ConcurrentHashMap<>(); // === 初始化状态 === private static volatile boolean initialized = false; private static final ThreadLocal> builtInToolContextStack = new ThreadLocal>(); /** * 本地MCP工具信息类 */ private static class McpToolInfo { final String toolId; // 工具唯一标识符(符合OpenAI API命名规范) final String description; final Class serviceClass; final Method method; McpToolInfo(String toolId, String description, Class serviceClass, Method method) { this.toolId = toolId; this.description = description; this.serviceClass = serviceClass; this.method = method; } } /** * 统一工具调用入口(支持自动识别用户上下文) */ public static String invoke(String functionName, String argument) { return invoke(functionName, argument, currentBuiltInToolContext()); } public static String invoke(String functionName, String argument, BuiltInToolContext builtInToolContext) { ensureInitialized(); try { String builtInResult = BuiltInToolExecutor.invoke(functionName, argument, builtInToolContext); if (builtInResult != null) { return builtInResult; } // 1. 检查是否为用户工具调用 String userId = extractUserIdFromFunctionName(functionName); if (userId != null) { // 用户工具调用:user_123_tool_create_issue -> userId=123, toolName=create_issue String actualToolName = extractActualToolName(functionName); return callUserMcpTool(userId, actualToolName, argument); } // 2. 全局工具调用流程 return callGlobalTool(functionName, argument); } catch (Exception e) { throw new RuntimeException("工具调用失败: " + functionName + " - " + e.getMessage(), e); } } /** * 显式指定用户ID的调用方式 */ public static String invoke(String functionName, String argument, String userId) { if (userId != null && !userId.isEmpty()) { return callUserMcpTool(userId, functionName, argument); } else { return invoke(functionName, argument); } } public static void pushBuiltInToolContext(BuiltInToolContext context) { if (context == null) { return; } Deque stack = builtInToolContextStack.get(); if (stack == null) { stack = new ArrayDeque(); builtInToolContextStack.set(stack); } stack.push(context); } public static void popBuiltInToolContext() { Deque stack = builtInToolContextStack.get(); if (stack == null || stack.isEmpty()) { builtInToolContextStack.remove(); return; } stack.pop(); if (stack.isEmpty()) { builtInToolContextStack.remove(); } } private static BuiltInToolContext currentBuiltInToolContext() { Deque stack = builtInToolContextStack.get(); if (stack == null || stack.isEmpty()) { return null; } return stack.peek(); } /** * 从函数名中提取用户ID * @param functionName 如 "user_123_tool_create_issue" 或 "create_issue" * @return 用户ID或null */ private static String extractUserIdFromFunctionName(String functionName) { if (functionName != null && functionName.startsWith("user_") && functionName.contains("_tool_")) { // 格式:user_{userId}_tool_{toolName} String[] parts = functionName.split("_tool_"); if (parts.length == 2) { String userPart = parts[0]; // "user_123" if (userPart.startsWith("user_")) { return userPart.substring(5); // 提取 "123" } } } return null; } /** * 调用用户MCP工具 */ private static String callUserMcpTool(String userId, String toolName, String argument) { McpGateway gateway = McpGateway.getInstance(); if (gateway == null || !gateway.isInitialized()) { throw new RuntimeException("MCP网关未初始化"); } try { Object argumentObject = JSON.parseObject(argument); return gateway.callUserTool(userId, toolName, argumentObject).join(); } catch (Exception e) { throw new RuntimeException("用户MCP工具调用失败: " + toolName, e); } } /** * 调用全局工具 */ private static String callGlobalTool(String functionName, String argument) { // 1. 优先检查本地MCP工具 if (mcpToolCache.containsKey(functionName)) { return invokeMcpTool(functionName, argument); } // 2. 检查传统Function工具 if (toolClassMap.containsKey(functionName) && toolRequestMap.containsKey(functionName)) { return invokeFunctionTool(functionName, argument); } // 3. 尝试调用全局MCP服务 McpGateway gateway = McpGateway.getInstance(); if (gateway != null && gateway.isInitialized()) { try { Object argumentObject = JSON.parseObject(argument); return gateway.callTool(functionName, argumentObject).join(); } catch (Exception e) { // MCP调用失败,继续其他逻辑 log.debug("全局MCP工具调用失败: {}", functionName, e); throw new RuntimeException(e.getMessage()); } } throw new RuntimeException("工具未找到: " + functionName); } /** * 从用户工具名中提取实际工具名 * @param functionName 如 "user_123_tool_create_issue" * @return 实际工具名 如 "create_issue" */ private static String extractActualToolName(String functionName) { if (functionName != null && functionName.contains("_tool_")) { String[] parts = functionName.split("_tool_"); if (parts.length == 2) { return parts[1]; // "create_issue" } } return functionName; } /** * 确保工具已初始化 */ private static void ensureInitialized() { if (!initialized) { synchronized (ToolUtil.class) { if (!initialized) { scanAndRegisterAllTools(); initialized = true; } } } } /** * 扫描并注册所有工具 */ private static void scanAndRegisterAllTools() { log.info("开始扫描并注册所有工具..."); // 扫描传统Function工具 scanFunctionTools(); // 扫描本地MCP工具 scanMcpTools(); log.info("工具扫描完成 - Function工具: {}, 本地MCP工具: {}", toolClassMap.size(), mcpToolCache.size()); } /** * 调用本地MCP工具 */ private static String invokeMcpTool(String functionName, String argument) { McpToolInfo toolInfo = mcpToolCache.get(functionName); if (toolInfo == null) { throw new RuntimeException("本地MCP工具未找到: " + functionName); } try { log.info("调用本地MCP工具: {}, 参数: {}", toolInfo.toolId, argument); // 创建服务实例 Object serviceInstance = toolInfo.serviceClass.getDeclaredConstructor().newInstance(); // 解析参数 Map argumentMap = JSON.parseObject(argument); if (argumentMap == null) { argumentMap = new HashMap<>(); } // 准备方法参数 Parameter[] parameters = toolInfo.method.getParameters(); Object[] methodArgs = new Object[parameters.length]; for (int i = 0; i < parameters.length; i++) { Parameter param = parameters[i]; String paramName = getParameterName(param); Object value = argumentMap.get(paramName); // 类型转换 methodArgs[i] = convertParameterValue(value, param.getType()); } // 调用方法 Object result = toolInfo.method.invoke(serviceInstance, methodArgs); String response = JSON.toJSONString(result); log.info("本地MCP工具调用成功: {} -> {}", toolInfo.toolId, response); return response; } catch (Exception e) { log.error("本地MCP工具调用失败: {}", toolInfo.toolId, e); throw new RuntimeException("本地MCP工具调用失败: " + toolInfo.toolId, e); } } /** * 调用传统Function工具 */ private static String invokeFunctionTool(String functionName, String argument) { Class functionClass = toolClassMap.get(functionName); Class functionRequestClass = toolRequestMap.get(functionName); log.info("Call Function Name: {}, arguments: {}", functionName, argument); try { // 获取调用函数 Method apply = functionClass.getMethod("apply", functionRequestClass); // 解析参数 Object arg = JSON.parseObject(argument, functionRequestClass); // 调用函数 Object functionInstance = functionClass.getDeclaredConstructor().newInstance(); Object result = apply.invoke(functionInstance, arg); String response = JSON.toJSONString(result); log.info("Call Function Success: {} -> {}", functionName, response); return response; } catch (Exception e) { log.error("Call Function Name: {} failed", functionName, e); throw new RuntimeException("Call Function Error: " + functionName, e); } } /** * 调用远程MCP服务 */ private static String callRemoteMcpService(String functionName, String argument) { try { // 使用反射调用McpGateway,避免直接依赖 Class mcpGatewayClass = Class.forName("io.github.lnyocly.ai4j.mcp.gateway.McpGateway"); Method getInstanceMethod = mcpGatewayClass.getMethod("getInstance"); Object mcpGateway = getInstanceMethod.invoke(null); if (mcpGateway != null) { Method isInitializedMethod = mcpGatewayClass.getMethod("isInitialized"); Boolean isInitialized = (Boolean) isInitializedMethod.invoke(mcpGateway); if (isInitialized) { Method callToolMethod = mcpGatewayClass.getMethod("callTool", String.class, Object.class); // 解析参数为对象 Object argumentObject; try { argumentObject = JSON.parseObject(argument); } catch (Exception e) { argumentObject = argument; } Object futureResult = callToolMethod.invoke(mcpGateway, functionName, argumentObject); if (futureResult instanceof java.util.concurrent.CompletableFuture) { return (String) ((java.util.concurrent.CompletableFuture) futureResult) .get(30, java.util.concurrent.TimeUnit.SECONDS); } } } } catch (ClassNotFoundException e) { log.debug("未找到McpGateway类"); } catch (Exception e) { log.debug("远程MCP服务调用异常: {}", e.getMessage()); } return null; } /** * 获取传统Function工具列表(保持向后兼容) * * @param functionList 需要获取的Function工具名称列表 * @return 工具列表 */ public static List getAllFunctionTools(List functionList) { ensureInitialized(); List tools = new ArrayList<>(); if (functionList == null || functionList.isEmpty()) { return tools; } log.debug("获取{}个Function工具", functionList.size()); for (String functionName : functionList) { if (functionName == null || functionName.trim().isEmpty()) { continue; } try { Tool tool = toolEntityMap.get(functionName); if (tool == null) { tool = getToolEntity(functionName); if (tool != null) { toolEntityMap.put(functionName, tool); } } if (tool != null) { tools.add(tool); } } catch (Exception e) { log.error("获取Function工具失败: {}", functionName, e); } } log.info("获取Function工具完成: 请求{}个,成功{}个", functionList.size(), tools.size()); return tools; } /** * 根据MCP服务ID列表获取所有MCP工具 * * @param mcpServerIds MCP服务ID列表 * @return 工具列表 */ public static List getAllMcpTools(List mcpServerIds) { List tools = new ArrayList<>(); if (mcpServerIds == null || mcpServerIds.isEmpty()) { return tools; } log.debug("获取{}个MCP服务的工具", mcpServerIds.size()); try { // 使用反射调用McpGateway获取工具 Class mcpGatewayClass = Class.forName("io.github.lnyocly.ai4j.mcp.gateway.McpGateway"); Method getInstanceMethod = mcpGatewayClass.getMethod("getInstance"); Object mcpGateway = getInstanceMethod.invoke(null); if (mcpGateway != null) { Method isInitializedMethod = mcpGatewayClass.getMethod("isInitialized"); Boolean isInitialized = (Boolean) isInitializedMethod.invoke(mcpGateway); if (isInitialized) { Method getAvailableToolsMethod = mcpGatewayClass.getMethod("getAvailableTools"); Object futureResult = getAvailableToolsMethod.invoke(mcpGateway); if (futureResult instanceof java.util.concurrent.CompletableFuture) { List mcpTools = castToolFunctions( ((java.util.concurrent.CompletableFuture) futureResult) .get(10, java.util.concurrent.TimeUnit.SECONDS) ); // 转换为Tool对象 for (Tool.Function function : mcpTools) { Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); tools.add(tool); } } } } } catch (Exception e) { log.error("获取MCP工具失败", e); } log.info("获取MCP工具完成: 请求{}个服务,获得{}个工具", mcpServerIds.size(), tools.size()); return tools; } /** * 获取所有工具(自动识别用户上下文) */ public static List getAllTools(List functionList, List mcpServerIds) { // fix 启动MCP服务工具未初始化 ensureInitialized(); List allTools = new ArrayList<>(); if (functionList != null && !functionList.isEmpty()) { allTools.addAll(getAllFunctionTools(functionList)); } if (mcpServerIds != null && !mcpServerIds.isEmpty()) { String userId = extractUserIdFromServiceIds(mcpServerIds); if (userId != null) { List actualServiceIds = extractActualServiceIds(mcpServerIds); allTools.addAll(getUserMcpTools(actualServiceIds, userId)); } else { allTools.addAll(getGlobalMcpTools(mcpServerIds)); } } return allTools; } /** * 显式指定用户ID的工具获取 */ public static List getAllTools(List functionList, List mcpServerIds, String userId) { ensureInitialized(); List allTools = new ArrayList<>(); if (functionList != null && !functionList.isEmpty()) { allTools.addAll(getAllFunctionTools(functionList)); } if (mcpServerIds != null && !mcpServerIds.isEmpty()) { if (userId != null && !userId.isEmpty()) { allTools.addAll(getUserMcpTools(mcpServerIds, userId)); } else { allTools.addAll(getGlobalMcpTools(mcpServerIds)); } } return allTools; } /** * Get all local MCP tools. */ public static List getLocalMcpTools() { ensureInitialized(); List tools = new ArrayList<>(); for (McpToolInfo mcpTool : mcpToolCache.values()) { Tool tool = createMcpToolEntity(mcpTool); if (tool != null) { tools.add(tool); } } return tools; } /** * 获取全局MCP工具 */ private static List getGlobalMcpTools(List serviceIds) { List tools = new ArrayList<>(); McpGateway gateway = McpGateway.getInstance(); if (gateway == null || !gateway.isInitialized()) { log.warn("MCP网关未初始化,无法获取全局MCP工具"); return tools; } try { List mcpTools = gateway.getAvailableTools(serviceIds).join(); // 转换为Tool对象 for (Tool.Function function : mcpTools) { Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); tools.add(tool); } } catch (Exception e) { log.error("获取全局MCP工具失败", e); } return tools; } /** * 从服务ID列表中提取用户ID * @param serviceIds 如 ["user_123_service_github", "user_123_service_slack"] * @return 用户ID或null */ private static String extractUserIdFromServiceIds(List serviceIds) { for (String serviceId : serviceIds) { if (serviceId != null && serviceId.startsWith("user_") && serviceId.contains("_service_")) { String[] parts = serviceId.split("_service_"); if (parts.length == 2) { String userPart = parts[0]; // "user_123" if (userPart.startsWith("user_")) { return userPart.substring(5); // 提取 "123" } } } } return null; } /** * 从用户服务ID列表中提取实际服务ID * @param serviceIds 如 ["user_123_service_github", "user_123_service_slack"] * @return 实际服务ID列表 如 ["github", "slack"] */ private static List extractActualServiceIds(List serviceIds) { List actualIds = new ArrayList<>(); for (String serviceId : serviceIds) { if (serviceId != null && serviceId.contains("_service_")) { String[] parts = serviceId.split("_service_"); if (parts.length == 2) { actualIds.add(parts[1]); // 提取实际的serviceId } } else { actualIds.add(serviceId); // 全局服务ID } } return actualIds; } /** * 获取用户MCP工具 */ private static List getUserMcpTools(List serviceIds, String userId) { List tools = new ArrayList<>(); McpGateway gateway = McpGateway.getInstance(); if (gateway == null || !gateway.isInitialized()) { log.warn("MCP网关未初始化,无法获取用户MCP工具"); return tools; } try { List mcpTools = gateway.getUserAvailableTools(serviceIds, userId).join(); // 转换为Tool对象 for (Tool.Function function : mcpTools) { Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); tools.add(tool); } } catch (Exception e) { log.error("获取用户MCP工具失败: userId={}", userId, e); } return tools; } /** * 扫描传统Function工具 */ private static void scanFunctionTools() { try { Set> functionSet = reflections.getTypesAnnotatedWith(FunctionCall.class); for (Class functionClass : functionSet) { try { FunctionCall functionCall = functionClass.getAnnotation(FunctionCall.class); if (functionCall != null) { String functionName = functionCall.name(); toolClassMap.put(functionName, functionClass); // 查找Request类 Class[] innerClasses = functionClass.getDeclaredClasses(); for (Class innerClass : innerClasses) { if (innerClass.getAnnotation(FunctionRequest.class) != null) { toolRequestMap.put(functionName, innerClass); break; } } log.debug("注册Function工具: {}", functionName); } } catch (Exception e) { log.error("处理Function类失败: {}", functionClass.getName(), e); } } log.info("扫描Function工具完成: {}个", toolClassMap.size()); } catch (Exception e) { log.error("扫描Function工具失败", e); } } /** * 扫描本地MCP工具 */ private static void scanMcpTools() { try { Set> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class); for (Class serviceClass : mcpServiceClasses) { try { McpService mcpService = serviceClass.getAnnotation(McpService.class); String serviceName = mcpService.name().isEmpty() ? serviceClass.getSimpleName() : mcpService.name(); // 扫描方法 Method[] methods = serviceClass.getDeclaredMethods(); for (Method method : methods) { McpTool mcpTool = method.getAnnotation(McpTool.class); if (mcpTool != null) { String toolName = mcpTool.name().isEmpty() ? method.getName() : mcpTool.name(); // 统一使用API友好的命名方式(下划线分隔) String toolId = generateApiFunctionName(serviceName, toolName); McpToolInfo toolInfo = new McpToolInfo( toolId, mcpTool.description(), serviceClass, method); mcpToolCache.put(toolId, toolInfo); log.debug("注册本地MCP工具: {}", toolId); } } } catch (Exception e) { log.error("处理MCP服务类失败: {}", serviceClass.getName(), e); } } log.info("扫描本地MCP工具完成: {}个", mcpToolCache.size()); } catch (Exception e) { log.error("扫描本地MCP工具失败", e); } } /** * 获取工具实体对象 */ public static Tool getToolEntity(String functionName) { if (functionName == null || functionName.trim().isEmpty()) { return null; } try { Tool.Function functionEntity = getFunctionEntity(functionName); if (functionEntity != null) { Tool tool = new Tool(); tool.setType("function"); tool.setFunction(functionEntity); return tool; } } catch (Exception e) { log.error("创建工具实体失败: {}", functionName, e); } return null; } /** * 创建本地MCP工具实体 */ private static Tool createMcpToolEntity(McpToolInfo mcpTool) { try { Tool.Function function = new Tool.Function(); function.setName(mcpTool.toolId); // 使用工具ID作为函数名 function.setDescription(mcpTool.description); // 生成参数定义 Map parameters = new HashMap<>(); List requiredParameters = new ArrayList<>(); Parameter[] methodParams = mcpTool.method.getParameters(); for (Parameter param : methodParams) { McpParameter mcpParam = param.getAnnotation(McpParameter.class); String paramName = getParameterName(param); Tool.Function.Property property = createPropertyFromType(param.getType(), mcpParam != null ? mcpParam.description() : ""); parameters.put(paramName, property); if (mcpParam == null || mcpParam.required()) { requiredParameters.add(paramName); } } Tool.Function.Parameter parameter = new Tool.Function.Parameter("object", parameters, requiredParameters); function.setParameters(parameter); Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); return tool; } catch (Exception e) { log.error("创建MCP工具实体失败: {}", mcpTool.toolId, e); return null; } } /** * 获取Function实体定义 */ public static Tool.Function getFunctionEntity(String functionName) { if (functionName == null || functionName.trim().isEmpty()) { return null; } try { Set> functionSet = reflections.getTypesAnnotatedWith(FunctionCall.class); for (Class functionClass : functionSet) { try { FunctionCall functionCall = functionClass.getAnnotation(FunctionCall.class); if (functionCall != null && functionCall.name().equals(functionName)) { Tool.Function function = new Tool.Function(); function.setName(functionCall.name()); function.setDescription(functionCall.description()); setFunctionParameters(function, functionClass); toolClassMap.put(functionName, functionClass); return function; } } catch (Exception e) { log.error("处理Function类失败: {}", functionClass.getName(), e); } } } catch (Exception e) { log.error("获取Function实体失败: {}", functionName, e); } return null; } /** * 生成符合OpenAI API规范的函数名 * 规则:只能包含字母、数字、下划线和连字符,长度不超过64个字符 */ private static String generateApiFunctionName(String serviceName, String toolName) { // 将服务名和工具名组合,使用下划线连接 String combined = serviceName + "_" + toolName; // 替换不符合规范的字符 String normalized = combined .replaceAll("[^a-zA-Z0-9_-]", "_") // 替换非法字符为下划线 .replaceAll("_{2,}", "_") // 多个连续下划线替换为单个 .replaceAll("^_+|_+$", ""); // 移除开头和结尾的下划线 // 确保长度不超过64个字符 if (normalized.length() > 64) { normalized = normalized.substring(0, 64); } // 确保不为空且以字母开头 if (normalized.isEmpty() || !Character.isLetter(normalized.charAt(0))) { normalized = "tool_" + normalized; } return normalized; } /** * 获取参数名称 */ private static String getParameterName(Parameter param) { McpParameter mcpParam = param.getAnnotation(McpParameter.class); if (mcpParam != null && !mcpParam.name().isEmpty()) { return mcpParam.name(); } return param.getName(); } /** * 参数值类型转换 */ private static Object convertParameterValue(Object value, Class targetType) { if (value == null) { return null; } if (targetType.isAssignableFrom(value.getClass())) { return value; } String strValue = value.toString(); if (targetType == String.class) { return strValue; } else if (targetType == Integer.class || targetType == int.class) { return Integer.valueOf(strValue); } else if (targetType == Long.class || targetType == long.class) { return Long.valueOf(strValue); } else if (targetType == Boolean.class || targetType == boolean.class) { return Boolean.valueOf(strValue); } else if (targetType == Double.class || targetType == double.class) { return Double.valueOf(strValue); } else if (targetType == Float.class || targetType == float.class) { return Float.valueOf(strValue); } return value; } /** * 设置Function参数定义 */ private static void setFunctionParameters(Tool.Function function, Class functionClass) { try { Class[] classes = functionClass.getDeclaredClasses(); Map parameters = new HashMap<>(); List requiredParameters = new ArrayList<>(); for (Class clazz : classes) { FunctionRequest request = clazz.getAnnotation(FunctionRequest.class); if (request == null) { continue; } toolRequestMap.put(function.getName(), clazz); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { FunctionParameter parameter = field.getAnnotation(FunctionParameter.class); if (parameter == null) { continue; } Tool.Function.Property property = createPropertyFromType(field.getType(), parameter.description()); parameters.put(field.getName(), property); if (parameter.required()) { requiredParameters.add(field.getName()); } } } Tool.Function.Parameter parameter = new Tool.Function.Parameter("object", parameters, requiredParameters); function.setParameters(parameter); } catch (Exception e) { log.error("设置Function参数失败: {}", function.getName(), e); throw new RuntimeException("设置Function参数失败: " + function.getName(), e); } } /** * 从类型创建属性对象 */ private static Tool.Function.Property createPropertyFromType(Class fieldType, String description) { Tool.Function.Property property = new Tool.Function.Property(); if (fieldType.isEnum()) { property.setType("string"); property.setEnumValues(getEnumValues(fieldType)); } else if (fieldType.equals(String.class)) { property.setType("string"); } else if (fieldType.equals(int.class) || fieldType.equals(Integer.class) || fieldType.equals(long.class) || fieldType.equals(Long.class) || fieldType.equals(short.class) || fieldType.equals(Short.class)) { property.setType("integer"); } else if (fieldType.equals(float.class) || fieldType.equals(Float.class) || fieldType.equals(double.class) || fieldType.equals(Double.class)) { property.setType("number"); } else if (fieldType.equals(boolean.class) || fieldType.equals(Boolean.class)) { property.setType("boolean"); } else if (fieldType.isArray() || Collection.class.isAssignableFrom(fieldType)) { property.setType("array"); Tool.Function.Property items = new Tool.Function.Property(); Class elementType = getArrayElementType(fieldType); if (elementType != null) { if (elementType == String.class) { items.setType("string"); } else if (elementType == Integer.class || elementType == int.class || elementType == Long.class || elementType == long.class) { items.setType("integer"); } else if (elementType == Double.class || elementType == double.class || elementType == Float.class || elementType == float.class) { items.setType("number"); } else if (elementType == Boolean.class || elementType == boolean.class) { items.setType("boolean"); } else { items.setType("object"); } } else { items.setType("object"); } property.setItems(items); } else if (Map.class.isAssignableFrom(fieldType)) { property.setType("object"); } else { property.setType("object"); } property.setDescription(description); return property; } /** * 获取数组元素类型 */ private static Class getArrayElementType(Class arrayType) { if (arrayType.isArray()) { return arrayType.getComponentType(); } else if (Collection.class.isAssignableFrom(arrayType)) { return null; // 泛型擦除,无法获取确切类型 } return null; } /** * 获取枚举类型的所有可能值 */ private static List getEnumValues(Class enumType) { List enumValues = new ArrayList<>(); for (Object enumConstant : enumType.getEnumConstants()) { enumValues.add(enumConstant.toString()); } return enumValues; } // ========== 向后兼容方法 ========== /** * 向后兼容的统一工具调用方法 * @deprecated 请使用 invoke(String, String) 方法 */ @Deprecated public static String invokeUnified(String functionName, String argument) { return invoke(functionName, argument); } private static List castToolFunctions(Object value) { List functions = new ArrayList<>(); if (!(value instanceof List)) { return functions; } for (Object item : (List) value) { if (item instanceof Tool.Function) { functions.add((Tool.Function) item); } } return functions; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tools/ApplyPatchFunction.java ================================================ package io.github.lnyocly.ai4j.tools; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.tool.BuiltInToolExecutor; import io.github.lnyocly.ai4j.tool.BuiltInTools; import lombok.Data; import java.util.function.Function; @FunctionCall(name = "apply_patch", description = "Apply a structured patch to workspace files.") public class ApplyPatchFunction implements Function { @Override public String apply(Request request) { try { return BuiltInToolExecutor.invoke(BuiltInTools.APPLY_PATCH, JSON.toJSONString(request), null); } catch (Exception ex) { throw new RuntimeException("apply_patch failed", ex); } } @Data @FunctionRequest public static class Request { @FunctionParameter(description = "Patch text to apply. Must include *** Begin Patch and *** End Patch envelope.") private String patch; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tools/BashFunction.java ================================================ package io.github.lnyocly.ai4j.tools; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.tool.BuiltInToolExecutor; import io.github.lnyocly.ai4j.tool.BuiltInTools; import lombok.Data; import java.util.function.Function; @FunctionCall(name = "bash", description = "Execute non-interactive shell commands or manage interactive/background shell processes inside the workspace.") public class BashFunction implements Function { @Override public String apply(Request request) { try { return BuiltInToolExecutor.invoke(BuiltInTools.BASH, JSON.toJSONString(request), null); } catch (Exception ex) { throw new RuntimeException("bash failed", ex); } } @Data @FunctionRequest public static class Request { @FunctionParameter(description = "bash action to perform: exec, start, status, logs, write, stop, list.") private String action; @FunctionParameter(description = "Command string to execute.") private String command; @FunctionParameter(description = "Relative working directory inside the workspace.") private String cwd; @FunctionParameter(description = "Execution timeout in milliseconds for exec.") private Long timeoutMs; @FunctionParameter(description = "Background process identifier.") private String processId; @FunctionParameter(description = "Log cursor offset.") private Long offset; @FunctionParameter(description = "Maximum log characters to return.") private Integer limit; @FunctionParameter(description = "Text written to stdin for a background process.") private String input; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tools/QueryTrainInfoFunction.java ================================================ package io.github.lnyocly.ai4j.tools; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import lombok.Data; import java.util.function.Function; /** * @Author cly * @Description TODO * @Date 2024/8/12 14:45 */ @FunctionCall(name = "queryTrainInfo", description = "查询火车是否发车信息") public class QueryTrainInfoFunction implements Function { @Data @FunctionRequest public static class Request { @FunctionParameter(description = "根据天气的情况进行查询是否发车,此参数为天气的最高气温") Integer type; } public enum Type{ hao, cha } @Override public String apply(Request request) { if (request.type > 35) { return "天气情况正常,允许发车"; } return "天气情况较差,不允许发车"; } @Data public static class Response { String orderId; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tools/QueryWeatherFunction.java ================================================ package io.github.lnyocly.ai4j.tools; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.constant.Constants; import lombok.Data; import okhttp3.*; import java.util.function.Function; /** * @Author cly * @Description TODO * @Date 2024/8/13 17:40 */ @FunctionCall(name = "queryWeather", description = "查询目标地点的天气预报") public class QueryWeatherFunction implements Function { @Override public String apply(Request request) { final String key = "S3zzVyAdJjEeB18Gw"; // https://api.seniverse.com/v3/weather/hourly.json?key=your_api_key&location=beijing&start=0&hours=24 // https://api.seniverse.com/v3/weather/daily.json?key=your_api_key&location=beijing&start=0&days=5 String url = String.format("https://api.seniverse.com/v3/weather/%s.json?key=%s&location=%s&days=%d", request.type.name(), key, request.location, request.days); OkHttpClient client = new OkHttpClient(); okhttp3.Request http = new okhttp3.Request.Builder() .url(url) .get() .build(); try (Response response = client.newCall(http).execute()) { if (response.isSuccessful()) { // 解析响应体 return response.body() != null ? response.body().string() : ""; } else { return "获取天气失败 当前天气未知"; } } catch (Exception e) { // 处理异常 e.printStackTrace(); return "获取天气失败 当前天气未知"; } } @Data @FunctionRequest public static class Request{ @FunctionParameter(description = "需要查询天气的目标位置, 可以是城市中文名、城市拼音/英文名、省市名称组合、IP 地址、经纬度") private String location; @FunctionParameter(description = "需要查询未来天气的天数, 最多15日") private int days = 15; @FunctionParameter(description = "预报的天气类型,daily表示预报多天天气、hourly表示预测当天24天气、now为当前天气实况") private Type type; } public enum Type{ daily, hourly, now } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tools/ReadFileFunction.java ================================================ package io.github.lnyocly.ai4j.tools; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.tool.BuiltInToolExecutor; import io.github.lnyocly.ai4j.tool.BuiltInTools; import lombok.Data; import java.util.function.Function; @FunctionCall(name = "read_file", description = "Read a text file from the workspace or from an approved read-only skill directory.") public class ReadFileFunction implements Function { @Override public String apply(Request request) { try { return BuiltInToolExecutor.invoke(BuiltInTools.READ_FILE, JSON.toJSONString(request), null); } catch (Exception ex) { throw new RuntimeException("read_file failed", ex); } } @Data @FunctionRequest public static class Request { @FunctionParameter(description = "Relative file path inside the workspace, or an absolute path inside an approved read-only skill root.") private String path; @FunctionParameter(description = "First line number to read, starting from 1.") private Integer startLine; @FunctionParameter(description = "Last line number to read, inclusive.") private Integer endLine; @FunctionParameter(description = "Maximum characters to return.") private Integer maxChars; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/tools/WriteFileFunction.java ================================================ package io.github.lnyocly.ai4j.tools; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.tool.BuiltInToolExecutor; import io.github.lnyocly.ai4j.tool.BuiltInTools; import lombok.Data; import java.util.function.Function; @FunctionCall(name = "write_file", description = "Create, overwrite, or append a text file.") public class WriteFileFunction implements Function { @Override public String apply(Request request) { try { return BuiltInToolExecutor.invoke(BuiltInTools.WRITE_FILE, JSON.toJSONString(request), null); } catch (Exception ex) { throw new RuntimeException("write_file failed", ex); } } @Data @FunctionRequest public static class Request { @FunctionParameter(description = "File path to write. Relative paths resolve from the workspace root; absolute paths are allowed.") private String path; @FunctionParameter(description = "Full text content to write.") private String content; @FunctionParameter(description = "Write mode: create, overwrite, append.") private String mode; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/VectorDataEntity.java ================================================ package io.github.lnyocly.ai4j.vector; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/8/14 18:23 */ @Data @AllArgsConstructor @NoArgsConstructor public class VectorDataEntity { /** * 分段后的每一段的向量 */ private List> vector; /** * 每一段的内容 */ private List content; /** * 总共token数量 */ //private Integer total_token; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeDelete.java ================================================ package io.github.lnyocly.ai4j.vector.pinecone; import lombok.Builder; import lombok.Data; import java.util.List; import java.util.Map; /** * @Author cly * @Description TODO * @Date 2024/8/15 0:00 */ @Builder @Data public class PineconeDelete { private List ids; private boolean deleteAll; private String namespace; private Map filter; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeInsert.java ================================================ package io.github.lnyocly.ai4j.vector.pinecone; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/8/14 20:08 */ @Data @AllArgsConstructor @NoArgsConstructor @Builder public class PineconeInsert { /** * 需要插入的文本的向量库 */ private List vectors; /** * 命名空间,用于区分每个文本 */ private String namespace; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeInsertResponse.java ================================================ package io.github.lnyocly.ai4j.vector.pinecone; import lombok.Data; /** * @Author cly * @Description TODO * @Date 2024/8/16 17:28 */ @Data public class PineconeInsertResponse { private Integer upsertedCount; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeQuery.java ================================================ package io.github.lnyocly.ai4j.vector.pinecone; import lombok.Builder; import lombok.Data; import lombok.NonNull; import java.util.List; import java.util.Map; /** * @Author cly * @Description TODO * @Date 2024/8/14 23:59 */ @Data @Builder public class PineconeQuery { /** * 命名空间 */ @NonNull private String namespace; /** * 需要最相似的前K条向量 */ @Builder.Default private Integer topK = 10; /** * 可用于对metadata进行过滤 */ private Map filter; /** * 指示响应中是否包含向量值 */ @Builder.Default private Boolean includeValues = true; /** * 指示响应中是否包含元数据以及id */ @Builder.Default private Boolean includeMetadata = true; /** * 查询向量 */ @NonNull private List vector; /** * 向量稀疏数据。表示为索引列表和对应值列表,它们必须具有相同的长度 */ private Map sparseVector; /** * 每条向量独一无二的id */ private String id; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeQueryResponse.java ================================================ package io.github.lnyocly.ai4j.vector.pinecone; import lombok.Data; import java.util.List; import java.util.Map; /** * @Author cly * @Description TODO * @Date 2024/8/15 0:05 */ @Data public class PineconeQueryResponse { private List results; /** * 匹配的结果 */ private List matches; /** * 命名空间 */ private String namespace; @Data public static class Match { /** * 向量id */ private String id; /** * 相似度分数 */ private Float score; /** * 向量 */ private List values; /** * 向量的元数据,存放对应文本 */ private Map metadata; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeVectors.java ================================================ package io.github.lnyocly.ai4j.vector.pinecone; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; /** * @Author cly * @Description TODO * @Date 2024/8/14 20:07 */ @Data @AllArgsConstructor @NoArgsConstructor @Builder public class PineconeVectors { /** * 每条向量的id */ private String id; /** * 分段后每一段的向量 */ private List values; /** * 向量稀疏数据。表示为索引列表和对应值列表,它们必须具有相同的长度。 */ //private Map sparseValues; /** * 元数据,可以用来存储向量对应的文本 { key: "content", value: "对应文本" } */ private Map metadata; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/service/PineconeService.java ================================================ package io.github.lnyocly.ai4j.vector.service; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.PineconeConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.network.UrlUtils; import io.github.lnyocly.ai4j.vector.VectorDataEntity; import io.github.lnyocly.ai4j.vector.pinecone.*; import lombok.extern.slf4j.Slf4j; import okhttp3.*; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * @Author cly * @Description TODO * @Date 2024/8/16 17:09 */ @Slf4j public class PineconeService { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final PineconeConfig pineconeConfig; private final OkHttpClient okHttpClient; public PineconeService(Configuration configuration) { this.pineconeConfig = configuration.getPineconeConfig(); this.okHttpClient = configuration.getOkHttpClient(); } public PineconeService(Configuration configuration, PineconeConfig pineconeConfig) { this.pineconeConfig = pineconeConfig; this.okHttpClient = configuration.getOkHttpClient(); } // 插入Pinecone向量库 public Integer insert(PineconeInsert pineconeInsertReq){ Request request = new Request.Builder() .url(UrlUtils.concatUrl(pineconeConfig.getHost(), pineconeConfig.getUpsert())) .post(jsonBody(pineconeInsertReq)) .header("accept", Constants.APPLICATION_JSON) .header("content-type", Constants.APPLICATION_JSON) .header("Api-Key", pineconeConfig.getKey()) .build(); try (Response response = okHttpClient.newCall(request).execute()) { if (!response.isSuccessful()) { log.error("Error inserting into Pinecone vector store: {}", response.message()); throw new CommonException("Error inserting into Pinecone: " + response.message()); } // {"upsertedCount":3} return JSON.parseObject(response.body().string(), PineconeInsertResponse.class).getUpsertedCount(); } catch (Exception e) { log.error("OkHttpClient exception! {}", e.getMessage(), e); throw new CommonException("Failed to insert into Pinecone due to network error." + e.getMessage()); } } public Integer insert(VectorDataEntity vectorDataEntity, String namespace) { int count = vectorDataEntity.getContent().size(); List pineconeVectors = new ArrayList<>(); // 生成每个向量的id List ids = generateIDs(count); // 生成每个向量对应的文本,元数据,kv List> metadatas = generateContent(vectorDataEntity.getContent()); for(int i = 0;i < count; ++i){ pineconeVectors.add(new PineconeVectors(ids.get(i), vectorDataEntity.getVector().get(i), metadatas.get(i))); } PineconeInsert pineconeInsert = new PineconeInsert(pineconeVectors, namespace); return this.insert(pineconeInsert); } // 从Pinecone向量库中查询相似向量 public PineconeQueryResponse query(PineconeQuery pineconeQueryReq){ Request request = new Request.Builder() .url(UrlUtils.concatUrl(pineconeConfig.getHost(), pineconeConfig.getQuery())) .post(jsonBody(pineconeQueryReq)) .header("accept", Constants.APPLICATION_JSON) .header("content-type", Constants.APPLICATION_JSON) .header("Api-Key", pineconeConfig.getKey()) .build(); try (Response response = okHttpClient.newCall(request).execute()) { if (!response.isSuccessful()) { log.error("Error querying Pinecone vector store: {}", response.message()); throw new CommonException("Error querying Pinecone: " + response.message()); } String body = response.body().string(); return JSON.parseObject(body, PineconeQueryResponse.class); } catch (IOException e) { log.error("OkHttpClient exception! {}", e.getMessage(), e); throw new CommonException("Failed to query Pinecone due to network error." + e.getMessage()); } } public String query(PineconeQuery pineconeQuery, String delimiter){ PineconeQueryResponse queryResponse = this.query(pineconeQuery); if(delimiter == null) delimiter = ""; return queryResponse.getMatches().stream().map(match -> match.getMetadata().get(Constants.METADATA_KEY)).collect(Collectors.joining(delimiter)); } // 从Pinecone向量库中删除向量 public Boolean delete(PineconeDelete pineconeDeleteReq){ Request request = new Request.Builder() .url(UrlUtils.concatUrl(pineconeConfig.getHost(), pineconeConfig.getDelete())) .post(jsonBody(pineconeDeleteReq)) .header("accept", Constants.APPLICATION_JSON) .header("content-type", Constants.APPLICATION_JSON) .header("Api-Key", pineconeConfig.getKey()) .build(); try (Response response = okHttpClient.newCall(request).execute()) { if (!response.isSuccessful()) { log.error("Error deleting from Pinecone vector store: {}", response.message()); throw new CommonException("Error deleting from Pinecone: " + response.message()); } return true; } catch (IOException e) { log.error("OkHttpClient exception! {}", e.getMessage(), e); throw new CommonException("Failed to delete from Pinecone due to network error." + e.getMessage()); } } // 生成每个向量的id public List generateIDs(int count){ List ids = new ArrayList<>(); for (long i = 0L; i < count; ++i) { ids.add("id_" + i); } return ids; } // 生成每个向量对应的文本 public List> generateContent(List contents){ List> finalcontents = new ArrayList<>(); for(int i = 0; i < contents.size(); i++){ HashMap map = new HashMap<>(); map.put(Constants.METADATA_KEY, contents.get(i)); finalcontents.add(map); } return finalcontents; } private RequestBody jsonBody(Object payload) { return RequestBody.create(JSON.toJSONString(payload), JSON_MEDIA_TYPE); } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorDeleteRequest.java ================================================ package io.github.lnyocly.ai4j.vector.store; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class VectorDeleteRequest { private String dataset; private List ids; @Builder.Default private boolean deleteAll = false; private Map filter; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorRecord.java ================================================ package io.github.lnyocly.ai4j.vector.store; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class VectorRecord { private String id; private List vector; private String content; private Map metadata; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorSearchRequest.java ================================================ package io.github.lnyocly.ai4j.vector.store; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class VectorSearchRequest { private String dataset; private List vector; @Builder.Default private Integer topK = 10; private Map filter; @Builder.Default private Boolean includeMetadata = Boolean.TRUE; @Builder.Default private Boolean includeVector = Boolean.FALSE; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorSearchResult.java ================================================ package io.github.lnyocly.ai4j.vector.store; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class VectorSearchResult { private String id; private Float score; private String content; private List vector; private Map metadata; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorStore.java ================================================ package io.github.lnyocly.ai4j.vector.store; import java.util.List; /** * Unified vector store abstraction for RAG retrieval. */ public interface VectorStore { int upsert(VectorUpsertRequest request) throws Exception; List search(VectorSearchRequest request) throws Exception; boolean delete(VectorDeleteRequest request) throws Exception; VectorStoreCapabilities capabilities(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorStoreCapabilities.java ================================================ package io.github.lnyocly.ai4j.vector.store; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class VectorStoreCapabilities { private boolean dataset; private boolean metadataFilter; private boolean deleteByFilter; private boolean returnStoredVector; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorUpsertRequest.java ================================================ package io.github.lnyocly.ai4j.vector.store; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Collections; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class VectorUpsertRequest { private String dataset; @Builder.Default private List records = Collections.emptyList(); } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/milvus/MilvusVectorStore.java ================================================ package io.github.lnyocly.ai4j.vector.store.milvus; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.config.MilvusConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.network.UrlUtils; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class MilvusVectorStore implements VectorStore { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final MilvusConfig config; private final OkHttpClient okHttpClient; public MilvusVectorStore(Configuration configuration) { this(configuration, configuration == null ? null : configuration.getMilvusConfig()); } public MilvusVectorStore(Configuration configuration, MilvusConfig config) { if (configuration == null || configuration.getOkHttpClient() == null) { throw new IllegalArgumentException("OkHttpClient configuration is required"); } if (config == null) { throw new IllegalArgumentException("milvusConfig is required"); } this.okHttpClient = configuration.getOkHttpClient(); this.config = config; } @Override public int upsert(VectorUpsertRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); List records = request == null || request.getRecords() == null ? Collections.emptyList() : request.getRecords(); if (records.isEmpty()) { return 0; } JSONArray rows = new JSONArray(); int index = 0; for (VectorRecord record : records) { if (record == null || record.getVector() == null || record.getVector().isEmpty()) { index++; continue; } JSONObject row = new JSONObject(); row.put(config.getIdField(), resolveId(record.getId(), index)); row.put(config.getVectorField(), record.getVector()); String content = trimToNull(record.getContent()); if (content != null) { row.put(config.getContentField(), content); row.put(Constants.METADATA_KEY, content); } if (record.getMetadata() != null) { for (Map.Entry entry : record.getMetadata().entrySet()) { if (entry != null && entry.getKey() != null && entry.getValue() != null) { row.put(entry.getKey(), entry.getValue()); } } } rows.add(row); index++; } if (rows.isEmpty()) { return 0; } JSONObject body = new JSONObject(); applyCollectionScope(body, dataset); body.put("data", rows); executePost(config.getUpsert(), body); return rows.size(); } @Override public List search(VectorSearchRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null || request.getVector() == null || request.getVector().isEmpty()) { return Collections.emptyList(); } JSONObject body = new JSONObject(); applyCollectionScope(body, dataset); body.put("data", Collections.singletonList(request.getVector())); body.put("annsField", config.getVectorField()); body.put("limit", request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK()); body.put("outputFields", config.getOutputFields()); String filter = toFilterExpression(request.getFilter()); if (filter != null) { body.put("filter", filter); } JSONObject response = executePost(config.getSearch(), body); JSONArray results = response == null ? null : response.getJSONArray("data"); if (results == null || results.isEmpty()) { return Collections.emptyList(); } List vectorResults = new ArrayList(); for (int i = 0; i < results.size(); i++) { JSONObject item = results.getJSONObject(i); if (item == null) { continue; } Map metadata = metadataFromResult(item); Object idValue = metadata.remove(config.getIdField()); vectorResults.add(VectorSearchResult.builder() .id(stringValue(idValue)) .score(scoreValue(item)) .content(firstNonBlank( stringValue(metadata.get(config.getContentField())), stringValue(metadata.get(Constants.METADATA_KEY)))) .metadata(metadata) .build()); } return vectorResults; } @Override public boolean delete(VectorDeleteRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null) { return false; } JSONObject body = new JSONObject(); applyCollectionScope(body, dataset); if (request.getIds() != null && !request.getIds().isEmpty()) { body.put("ids", request.getIds()); } else { String filter = toFilterExpression(request.getFilter()); if (filter == null && request.isDeleteAll()) { filter = config.getIdField() + " != \"\""; } if (filter != null) { body.put("filter", filter); } } executePost(config.getDelete(), body); return true; } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder() .dataset(true) .metadataFilter(true) .deleteByFilter(true) .returnStoredVector(false) .build(); } private void applyCollectionScope(JSONObject body, String dataset) { body.put("collectionName", dataset); if (trimToNull(config.getPartitionName()) != null) { body.put("partitionName", config.getPartitionName().trim()); } if (trimToNull(config.getDbName()) != null) { body.put("dbName", config.getDbName().trim()); } } private JSONObject executePost(String path, JSONObject payload) throws Exception { Request.Builder builder = new Request.Builder() .url(UrlUtils.concatUrl(config.getHost(), path)) .post(RequestBody.create(JSON.toJSONString(payload), JSON_MEDIA_TYPE)) .header("accept", Constants.APPLICATION_JSON) .header("content-type", Constants.APPLICATION_JSON); if (trimToNull(config.getToken()) != null) { builder.header("Authorization", "Bearer " + config.getToken().trim()); } try (Response response = okHttpClient.newCall(builder.build()).execute()) { if (!response.isSuccessful()) { throw new IOException("Milvus request failed: " + response.message()); } String body = response.body() == null ? "{}" : response.body().string(); return JSON.parseObject(body); } } private Map metadataFromResult(JSONObject item) { Map metadata = new LinkedHashMap(); JSONObject entity = item.getJSONObject("entity"); if (entity != null) { for (String key : entity.keySet()) { metadata.put(key, entity.get(key)); } } for (String key : item.keySet()) { if ("distance".equals(key) || "score".equals(key) || "id".equals(key) || "entity".equals(key)) { continue; } metadata.put(key, item.get(key)); } if (!metadata.containsKey(config.getIdField()) && item.containsKey("id")) { metadata.put(config.getIdField(), item.get("id")); } return metadata; } private Float scoreValue(JSONObject item) { if (item.containsKey("score")) { return item.getFloat("score"); } if (item.containsKey("distance")) { Float distance = item.getFloat("distance"); return distance == null ? null : 1.0f - distance; } return null; } private String toFilterExpression(Map filter) { if (filter == null || filter.isEmpty()) { return null; } List clauses = new ArrayList(); for (Map.Entry entry : filter.entrySet()) { if (entry == null || trimToNull(entry.getKey()) == null || entry.getValue() == null) { continue; } String key = entry.getKey().trim(); Object value = entry.getValue(); if (value instanceof Collection) { List items = new ArrayList(); for (Object item : (Collection) value) { items.add(formatFilterValue(item)); } clauses.add(key + " in [" + join(items) + "]"); } else { clauses.add(key + " == " + formatFilterValue(value)); } } return clauses.isEmpty() ? null : joinWithAnd(clauses); } private String formatFilterValue(Object value) { if (value == null) { return "\"\""; } if (value instanceof Number || value instanceof Boolean) { return String.valueOf(value); } return "\"" + String.valueOf(value).replace("\"", "\\\"") + "\""; } private String join(List values) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < values.size(); i++) { if (i > 0) { builder.append(", "); } builder.append(values.get(i)); } return builder.toString(); } private String joinWithAnd(List values) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < values.size(); i++) { if (i > 0) { builder.append(" and "); } builder.append(values.get(i)); } return builder.toString(); } private String requiredDataset(String dataset) { String value = trimToNull(dataset); if (value == null) { throw new IllegalArgumentException("dataset is required"); } return value; } private String resolveId(String id, int index) { String value = trimToNull(id); return value == null ? "id_" + index : value; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } private String stringValue(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/pgvector/PgVectorStore.java ================================================ package io.github.lnyocly.ai4j.vector.store.pgvector; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.TypeReference; import io.github.lnyocly.ai4j.config.PgVectorConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Types; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class PgVectorStore implements VectorStore { private final PgVectorConfig config; public PgVectorStore(Configuration configuration) { this(configuration == null ? null : configuration.getPgVectorConfig()); } public PgVectorStore(PgVectorConfig config) { if (config == null) { throw new IllegalArgumentException("pgVectorConfig is required"); } this.config = config; } @Override public int upsert(VectorUpsertRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); List records = request == null || request.getRecords() == null ? Collections.emptyList() : request.getRecords(); if (records.isEmpty()) { return 0; } String sql = "insert into " + identifier(config.getTableName()) + " (" + identifier(config.getIdColumn()) + ", " + identifier(config.getDatasetColumn()) + ", " + identifier(config.getContentColumn()) + ", " + identifier(config.getMetadataColumn()) + ", " + identifier(config.getVectorColumn()) + ") values (?, ?, ?, cast(? as jsonb), cast(? as vector)) " + "on conflict (" + identifier(config.getIdColumn()) + ") do update set " + identifier(config.getDatasetColumn()) + " = excluded." + identifier(config.getDatasetColumn()) + ", " + identifier(config.getContentColumn()) + " = excluded." + identifier(config.getContentColumn()) + ", " + identifier(config.getMetadataColumn()) + " = excluded." + identifier(config.getMetadataColumn()) + ", " + identifier(config.getVectorColumn()) + " = excluded." + identifier(config.getVectorColumn()); int total = 0; try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql)) { int index = 0; for (VectorRecord record : records) { if (record == null || record.getVector() == null || record.getVector().isEmpty()) { index++; continue; } statement.setString(1, resolveId(record.getId(), index)); statement.setString(2, dataset); statement.setString(3, record.getContent()); String metadataJson = metadataJson(record); if (metadataJson == null) { statement.setNull(4, Types.VARCHAR); } else { statement.setString(4, metadataJson); } statement.setString(5, vectorLiteral(record.getVector())); statement.addBatch(); index++; } int[] results = statement.executeBatch(); for (int value : results) { total += value < 0 ? 1 : value; } } return total; } @Override public List search(VectorSearchRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null || request.getVector() == null || request.getVector().isEmpty()) { return Collections.emptyList(); } StringBuilder sql = new StringBuilder(); sql.append("select ") .append(identifier(config.getIdColumn())).append(", ") .append(identifier(config.getContentColumn())).append(", ") .append(identifier(config.getMetadataColumn())).append("::text as metadata_json, ") .append(identifier(config.getVectorColumn())).append(" ").append(config.getDistanceOperator()).append(" cast(? as vector) as distance ") .append("from ").append(identifier(config.getTableName())) .append(" where ").append(identifier(config.getDatasetColumn())).append(" = ?"); List parameters = new ArrayList(); parameters.add(vectorLiteral(request.getVector())); parameters.add(dataset); appendMetadataFilters(sql, parameters, request.getFilter()); sql.append(" order by ").append(identifier(config.getVectorColumn())) .append(" ").append(config.getDistanceOperator()).append(" cast(? as vector)") .append(" limit ?"); parameters.add(vectorLiteral(request.getVector())); parameters.add(request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK()); List results = new ArrayList(); try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql.toString())) { bindParameters(statement, parameters); try (ResultSet resultSet = statement.executeQuery()) { while (resultSet.next()) { String metadataJson = resultSet.getString("metadata_json"); Map metadata = parseMetadata(metadataJson); results.add(VectorSearchResult.builder() .id(resultSet.getString(identifier(config.getIdColumn()))) .content(firstNonBlank( resultSet.getString(identifier(config.getContentColumn())), stringValue(metadata.get(Constants.METADATA_KEY)))) .metadata(metadata) .score(1.0f - resultSet.getFloat("distance")) .build()); } } } return results; } @Override public boolean delete(VectorDeleteRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null) { return false; } StringBuilder sql = new StringBuilder(); sql.append("delete from ").append(identifier(config.getTableName())) .append(" where ").append(identifier(config.getDatasetColumn())).append(" = ?"); List parameters = new ArrayList(); parameters.add(dataset); if (request.getIds() != null && !request.getIds().isEmpty()) { sql.append(" and ").append(identifier(config.getIdColumn())).append(" in ("); for (int i = 0; i < request.getIds().size(); i++) { if (i > 0) { sql.append(", "); } sql.append("?"); parameters.add(request.getIds().get(i)); } sql.append(")"); } else if (request.getFilter() != null && !request.getFilter().isEmpty()) { appendMetadataFilters(sql, parameters, request.getFilter()); } try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql.toString())) { bindParameters(statement, parameters); statement.executeUpdate(); return true; } } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder() .dataset(true) .metadataFilter(true) .deleteByFilter(true) .returnStoredVector(false) .build(); } private void appendMetadataFilters(StringBuilder sql, List parameters, Map filter) { if (filter == null || filter.isEmpty()) { return; } for (Map.Entry entry : filter.entrySet()) { if (entry == null || safeMetadataKey(entry.getKey()) == null || entry.getValue() == null) { continue; } String key = safeMetadataKey(entry.getKey()); Object value = entry.getValue(); if (value instanceof Iterable) { List values = new ArrayList(); for (Object item : (Iterable) value) { values.add(item); } if (values.isEmpty()) { continue; } sql.append(" and ").append(identifier(config.getMetadataColumn())).append(" ->> '").append(key).append("' in ("); for (int i = 0; i < values.size(); i++) { if (i > 0) { sql.append(", "); } sql.append("?"); parameters.add(String.valueOf(values.get(i))); } sql.append(")"); } else { sql.append(" and ").append(identifier(config.getMetadataColumn())).append(" ->> '").append(key).append("' = ?"); parameters.add(String.valueOf(value)); } } } private Connection connection() throws Exception { if (trimToNull(config.getUsername()) == null) { return DriverManager.getConnection(config.getJdbcUrl()); } return DriverManager.getConnection(config.getJdbcUrl(), config.getUsername(), config.getPassword()); } private void bindParameters(PreparedStatement statement, List parameters) throws Exception { for (int i = 0; i < parameters.size(); i++) { Object value = parameters.get(i); if (value == null) { statement.setNull(i + 1, Types.VARCHAR); } else { statement.setObject(i + 1, value); } } } private String metadataJson(VectorRecord record) { Map metadata = new LinkedHashMap(); if (record.getMetadata() != null) { metadata.putAll(record.getMetadata()); } if (trimToNull(record.getContent()) != null && !metadata.containsKey(Constants.METADATA_KEY)) { metadata.put(Constants.METADATA_KEY, record.getContent().trim()); } return metadata.isEmpty() ? null : JSON.toJSONString(metadata); } private Map parseMetadata(String metadataJson) { if (metadataJson == null || metadataJson.trim().isEmpty()) { return Collections.emptyMap(); } return JSON.parseObject(metadataJson, new TypeReference>() { }); } private String vectorLiteral(List vector) { StringBuilder builder = new StringBuilder(); builder.append("["); for (int i = 0; i < vector.size(); i++) { if (i > 0) { builder.append(", "); } builder.append(vector.get(i)); } builder.append("]"); return builder.toString(); } private String safeMetadataKey(String key) { String value = trimToNull(key); if (value == null) { return null; } if (!value.matches("[A-Za-z_][A-Za-z0-9_]*")) { throw new IllegalArgumentException("Invalid metadata key: " + key); } return value; } private String identifier(String identifier) { String value = trimToNull(identifier); if (value == null || !value.matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?")) { throw new IllegalArgumentException("Invalid sql identifier: " + identifier); } return value; } private String requiredDataset(String dataset) { String value = trimToNull(dataset); if (value == null) { throw new IllegalArgumentException("dataset is required"); } return value; } private String resolveId(String id, int index) { String value = trimToNull(id); return value == null ? "id_" + index : value; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } private String stringValue(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/pinecone/PineconeVectorStore.java ================================================ package io.github.lnyocly.ai4j.vector.store.pinecone; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete; import io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert; import io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery; import io.github.lnyocly.ai4j.vector.pinecone.PineconeQueryResponse; import io.github.lnyocly.ai4j.vector.pinecone.PineconeVectors; import io.github.lnyocly.ai4j.vector.service.PineconeService; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class PineconeVectorStore implements VectorStore { private final PineconeService pineconeService; public PineconeVectorStore(PineconeService pineconeService) { if (pineconeService == null) { throw new IllegalArgumentException("pineconeService is required"); } this.pineconeService = pineconeService; } @Override public int upsert(VectorUpsertRequest request) { String dataset = requiredDataset(request == null ? null : request.getDataset()); List records = request == null || request.getRecords() == null ? Collections.emptyList() : request.getRecords(); if (records.isEmpty()) { return 0; } List vectors = new ArrayList(); int index = 0; for (VectorRecord record : records) { if (record == null || record.getVector() == null || record.getVector().isEmpty()) { index++; continue; } Map metadata = stringifyMetadata(record.getMetadata()); String content = trimToNull(record.getContent()); if (content != null && !metadata.containsKey(Constants.METADATA_KEY)) { metadata.put(Constants.METADATA_KEY, content); } vectors.add(PineconeVectors.builder() .id(resolveId(record.getId(), index)) .values(record.getVector()) .metadata(metadata) .build()); index++; } if (vectors.isEmpty()) { return 0; } return pineconeService.insert(new PineconeInsert(vectors, dataset)); } @Override public List search(VectorSearchRequest request) { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null || request.getVector() == null || request.getVector().isEmpty()) { return Collections.emptyList(); } PineconeQueryResponse response = pineconeService.query(PineconeQuery.builder() .namespace(dataset) .topK(request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK()) .filter(stringifyMetadata(request.getFilter())) .includeMetadata(request.getIncludeMetadata() == null ? Boolean.TRUE : request.getIncludeMetadata()) .includeValues(request.getIncludeVector() == null ? Boolean.FALSE : request.getIncludeVector()) .vector(request.getVector()) .build()); if (response == null || response.getMatches() == null || response.getMatches().isEmpty()) { return Collections.emptyList(); } List results = new ArrayList(); for (PineconeQueryResponse.Match match : response.getMatches()) { if (match == null) { continue; } Map metadata = objectMetadata(match.getMetadata()); String content = metadata == null ? null : stringValue(metadata.get(Constants.METADATA_KEY)); results.add(VectorSearchResult.builder() .id(match.getId()) .score(match.getScore()) .content(content) .vector(match.getValues()) .metadata(metadata) .build()); } return results; } @Override public boolean delete(VectorDeleteRequest request) { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null) { return false; } return pineconeService.delete(PineconeDelete.builder() .ids(request.getIds()) .deleteAll(request.isDeleteAll()) .namespace(dataset) .filter(stringifyMetadata(request.getFilter())) .build()); } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder() .dataset(true) .metadataFilter(true) .deleteByFilter(true) .returnStoredVector(true) .build(); } private String requiredDataset(String dataset) { String value = trimToNull(dataset); if (value == null) { throw new IllegalArgumentException("dataset is required"); } return value; } private String resolveId(String id, int index) { String value = trimToNull(id); return value == null ? "id_" + index : value; } private Map stringifyMetadata(Map metadata) { if (metadata == null || metadata.isEmpty()) { return null; } Map result = new LinkedHashMap(); for (Map.Entry entry : metadata.entrySet()) { if (entry == null || entry.getKey() == null) { continue; } String value = stringValue(entry.getValue()); if (value != null) { result.put(entry.getKey(), value); } } return result.isEmpty() ? null : result; } private Map objectMetadata(Map metadata) { if (metadata == null || metadata.isEmpty()) { return Collections.emptyMap(); } Map result = new LinkedHashMap(); for (Map.Entry entry : metadata.entrySet()) { if (entry != null && entry.getKey() != null) { result.put(entry.getKey(), entry.getValue()); } } return result; } private String stringValue(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } private String trimToNull(String value) { if (value == null) { return null; } String text = value.trim(); return text.isEmpty() ? null : text; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/qdrant/QdrantVectorStore.java ================================================ package io.github.lnyocly.ai4j.vector.store.qdrant; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.config.QdrantConfig; import io.github.lnyocly.ai4j.constant.Constants; import io.github.lnyocly.ai4j.network.UrlUtils; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class QdrantVectorStore implements VectorStore { private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON); private final QdrantConfig config; private final OkHttpClient okHttpClient; public QdrantVectorStore(Configuration configuration) { this(configuration, configuration == null ? null : configuration.getQdrantConfig()); } public QdrantVectorStore(Configuration configuration, QdrantConfig config) { if (configuration == null || configuration.getOkHttpClient() == null) { throw new IllegalArgumentException("OkHttpClient configuration is required"); } if (config == null) { throw new IllegalArgumentException("qdrantConfig is required"); } this.okHttpClient = configuration.getOkHttpClient(); this.config = config; } @Override public int upsert(VectorUpsertRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); List records = request == null || request.getRecords() == null ? Collections.emptyList() : request.getRecords(); if (records.isEmpty()) { return 0; } JSONArray points = new JSONArray(); int index = 0; for (VectorRecord record : records) { if (record == null || record.getVector() == null || record.getVector().isEmpty()) { index++; continue; } JSONObject point = new JSONObject(); point.put("id", resolveId(record.getId(), index)); point.put("vector", namedVector(record.getVector())); point.put("payload", payload(record)); points.add(point); index++; } if (points.isEmpty()) { return 0; } JSONObject body = new JSONObject(); body.put("points", points); executePost(url(config.getUpsert(), dataset), body); return points.size(); } @Override public List search(VectorSearchRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null || request.getVector() == null || request.getVector().isEmpty()) { return Collections.emptyList(); } JSONObject body = new JSONObject(); body.put("query", request.getVector()); body.put("limit", request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK()); body.put("with_payload", request.getIncludeMetadata() == null ? Boolean.TRUE : request.getIncludeMetadata()); body.put("with_vector", request.getIncludeVector() == null ? Boolean.FALSE : request.getIncludeVector()); if (trimToNull(config.getVectorName()) != null) { body.put("using", trimToNull(config.getVectorName())); } JSONObject filter = toFilter(request.getFilter()); if (filter != null) { body.put("filter", filter); } JSONObject response = executePost(url(config.getQuery(), dataset), body); JSONObject result = response == null ? null : response.getJSONObject("result"); JSONArray points = result == null ? null : result.getJSONArray("points"); if (points == null || points.isEmpty()) { return Collections.emptyList(); } List results = new ArrayList(); for (int i = 0; i < points.size(); i++) { JSONObject point = points.getJSONObject(i); if (point == null) { continue; } Map metadata = payloadMap(point.getJSONObject("payload")); results.add(VectorSearchResult.builder() .id(stringValue(point.get("id"))) .score(point.getFloat("score")) .content(stringValue(metadata.get(Constants.METADATA_KEY))) .vector(request.getIncludeVector() != null && request.getIncludeVector() ? vectorValue(point.get("vector")) : null) .metadata(metadata) .build()); } return results; } @Override public boolean delete(VectorDeleteRequest request) throws Exception { String dataset = requiredDataset(request == null ? null : request.getDataset()); if (request == null) { return false; } JSONObject body = new JSONObject(); if (request.getIds() != null && !request.getIds().isEmpty()) { body.put("points", request.getIds()); } else { JSONObject filter = toFilter(request.getFilter()); if (filter == null && request.isDeleteAll()) { filter = new JSONObject(); filter.put("must", new JSONArray()); } if (filter != null) { body.put("filter", filter); } } executePost(url(config.getDelete(), dataset), body); return true; } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder() .dataset(true) .metadataFilter(true) .deleteByFilter(true) .returnStoredVector(true) .build(); } private JSONObject payload(VectorRecord record) { JSONObject payload = new JSONObject(); if (record.getMetadata() != null) { for (Map.Entry entry : record.getMetadata().entrySet()) { if (entry != null && entry.getKey() != null && entry.getValue() != null) { payload.put(entry.getKey(), entry.getValue()); } } } String content = trimToNull(record.getContent()); if (content != null && !payload.containsKey(Constants.METADATA_KEY)) { payload.put(Constants.METADATA_KEY, content); } return payload; } private Object namedVector(List vector) { if (trimToNull(config.getVectorName()) == null) { return vector; } JSONObject named = new JSONObject(); named.put(config.getVectorName().trim(), vector); return named; } private JSONObject toFilter(Map filter) { if (filter == null || filter.isEmpty()) { return null; } JSONArray must = new JSONArray(); for (Map.Entry entry : filter.entrySet()) { if (entry == null || trimToNull(entry.getKey()) == null || entry.getValue() == null) { continue; } JSONObject condition = new JSONObject(); condition.put("key", entry.getKey()); JSONObject match = new JSONObject(); Object value = entry.getValue(); if (value instanceof Collection) { JSONArray any = new JSONArray(); for (Object item : (Collection) value) { any.add(item); } match.put("any", any); } else { match.put("value", value); } condition.put("match", match); must.add(condition); } if (must.isEmpty()) { return null; } JSONObject result = new JSONObject(); result.put("must", must); return result; } private JSONObject executePost(String url, JSONObject payload) throws Exception { Request.Builder builder = new Request.Builder() .url(url) .post(RequestBody.create(JSON.toJSONString(payload), JSON_MEDIA_TYPE)) .header("accept", Constants.APPLICATION_JSON) .header("content-type", Constants.APPLICATION_JSON); if (trimToNull(config.getApiKey()) != null) { builder.header("api-key", config.getApiKey().trim()); } try (Response response = okHttpClient.newCall(builder.build()).execute()) { if (!response.isSuccessful()) { throw new IOException("Qdrant request failed: " + response.message()); } String body = response.body() == null ? "{}" : response.body().string(); return JSON.parseObject(body); } } private List vectorValue(Object rawVector) { if (rawVector == null) { return null; } JSONArray values = null; if (rawVector instanceof JSONArray) { values = (JSONArray) rawVector; } else if (rawVector instanceof JSONObject && trimToNull(config.getVectorName()) != null) { values = ((JSONObject) rawVector).getJSONArray(config.getVectorName().trim()); } if (values == null) { return null; } List vector = new ArrayList(); for (int i = 0; i < values.size(); i++) { vector.add(values.getFloat(i)); } return vector; } private Map payloadMap(JSONObject payload) { if (payload == null || payload.isEmpty()) { return Collections.emptyMap(); } Map metadata = new LinkedHashMap(); for (String key : payload.keySet()) { metadata.put(key, payload.get(key)); } return metadata; } private String url(String template, String dataset) { return UrlUtils.concatUrl(config.getHost(), String.format(template, dataset)); } private String requiredDataset(String dataset) { String value = trimToNull(dataset); if (value == null) { throw new IllegalArgumentException("dataset is required"); } return value; } private String resolveId(String id, int index) { String value = trimToNull(id); return value == null ? "id_" + index : value; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private String stringValue(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/ChatWithWebSearchEnhance.java ================================================ package io.github.lnyocly.ai4j.websearch; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Content; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.network.UrlUtils; import io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig; import io.github.lnyocly.ai4j.websearch.searxng.SearXNGRequest; import io.github.lnyocly.ai4j.websearch.searxng.SearXNGResponse; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import org.apache.commons.lang3.StringUtils; /** * @Author cly * @Description TODO * @Date 2024/12/11 22:32 */ public class ChatWithWebSearchEnhance implements IChatService { private final IChatService chatService; private final SearXNGConfig searXNGConfig; private final OkHttpClient okHttpClient; public ChatWithWebSearchEnhance(IChatService chatService, Configuration configuration) { this.chatService = chatService; this.searXNGConfig = configuration.getSearXNGConfig(); this.okHttpClient = configuration.getOkHttpClient(); } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception { return chatService.chatCompletion(baseUrl, apiKey, addWebSearchResults(chatCompletion)); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception { return chatService.chatCompletion(addWebSearchResults(chatCompletion)); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { chatService.chatCompletionStream(baseUrl, apiKey, addWebSearchResults(chatCompletion), eventSourceListener); } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { chatService.chatCompletionStream(addWebSearchResults(chatCompletion), eventSourceListener); } private ChatCompletion addWebSearchResults(ChatCompletion chatCompletion) { int chatLen = chatCompletion.getMessages().size(); String prompt = chatCompletion.getMessages().get(chatLen - 1).getContent().getText(); // 执行联网搜索并将结果附加到提示词中 String searchResults = performWebSearch(prompt); chatCompletion.getMessages().get(chatLen - 1).setContent(Content.ofText("我将提供一段来自互联网的资料信息, 请根据这段资料以及用户提出的问题来给出回答。请确保在回答中使用Markdown格式,并在回答末尾列出参考资料。如果资料中的信息不足以回答用户的问题,可以根据自身知识库进行补充,或者说明无法提供确切的答案。\n" + "网络资料:\n" + "============\n" + searchResults + "============\n" + "用户问题:\n" + "============\n" + prompt + "============\n")); return chatCompletion; } private String performWebSearch(String query) { SearXNGRequest searXNGRequest = SearXNGRequest.builder() .q(query) .engines(searXNGConfig.getEngines()) .build(); if(StringUtils.isBlank(searXNGConfig.getUrl())){ throw new CommonException("SearXNG url is not configured"); } Request request = new Request.Builder() .url(UrlUtils.concatUrl(searXNGConfig.getUrl(), "?format=json&q=" + query + "&engines=" + searXNGConfig.getEngines())) .get() .build(); try(Response execute = okHttpClient.newCall(request).execute()) { if (execute.isSuccessful() && execute.body() != null){ SearXNGResponse searXNGResponse = JSON.parseObject(execute.body().string(), SearXNGResponse.class); if(searXNGResponse.getResults().size() > searXNGConfig.getNums()) { return JSON.toJSONString(searXNGResponse.getResults().subList(0, searXNGConfig.getNums())); } return JSON.toJSONString(searXNGResponse.getResults()); }else{ throw new CommonException("SearXNG request failed"); } } catch (Exception e) { throw new CommonException("SearXNG request failed"); } } } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/searxng/SearXNGConfig.java ================================================ package io.github.lnyocly.ai4j.websearch.searxng; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.NonNull; /** * @Author cly * @Description SearXNG网路搜索配置信息 * @Date 2024/12/11 23:05 */ @Data @AllArgsConstructor @NoArgsConstructor public class SearXNGConfig { private String url; private String engines = "duckduckgo,google,bing,brave,mojeek,presearch,qwant,startpage,yahoo,arxiv,crossref,google_scholar,internetarchivescholar,semantic_scholar"; private int nums = 20; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/searxng/SearXNGRequest.java ================================================ package io.github.lnyocly.ai4j.websearch.searxng; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @Author cly * @Description TODO * @Date 2024/12/11 21:41 */ @Data @AllArgsConstructor @NoArgsConstructor @Builder public class SearXNGRequest { @Builder.Default private final String format = "json"; private String q; @Builder.Default private String engines = "duckduckgo,google,bing,brave,mojeek,presearch,qwant,startpage,yahoo,arxiv,crossref,google_scholar,internetarchivescholar,semantic_scholar"; } ================================================ FILE: ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/searxng/SearXNGResponse.java ================================================ package io.github.lnyocly.ai4j.websearch.searxng; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; import java.util.List; /** * @Author cly * @Description TODO * @Date 2024/12/11 21:39 */ @Data @AllArgsConstructor @NoArgsConstructor public class SearXNGResponse { private String query; @JsonProperty("number_of_results") private String numberOfResults; private List results; @Data @AllArgsConstructor @NoArgsConstructor public static class Result { private String url; private String title; private String content; //@JsonProperty("parsed_url") //private List parsedUrl; //private Date publishedDate; //private List engines; //private String category; //private float score; } } ================================================ FILE: ai4j/src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.ConnectionPoolProvider ================================================ io.github.lnyocly.ai4j.network.impl.DefaultConnectionPoolProvider ================================================ FILE: ai4j/src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.DispatcherProvider ================================================ io.github.lnyocly.ai4j.network.impl.DefaultDispatcherProvider ================================================ FILE: ai4j/src/main/resources/mcp-servers-config.json ================================================ { "mcpServers": { "test_weather_http": { "type": "http", "url": "http://127.0.0.1:8000/mcp" } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/BaichuanTest.java ================================================ package io.github.lnyocly; import io.github.lnyocly.ai4j.config.BaichuanConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description 智谱测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class BaichuanTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { BaichuanConfig baichuanConfig = new BaichuanConfig(); baichuanConfig.setApiKey("sk-4e5717ac51cacaf5d590cff13630cfce"); Configuration configuration = new Configuration(); configuration.setBaichuanConfig(baichuanConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.BAICHUAN); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("Baichuan4") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-vision") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-large-fc") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/DashScopeTest.java ================================================ package io.github.lnyocly; import cn.hutool.core.util.SystemPropsUtil; import io.github.lnyocly.ai4j.config.DashScopeConfig; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description 智谱测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class DashScopeTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { DashScopeConfig dashScopeConfig = new DashScopeConfig(); dashScopeConfig.setApiKey(SystemPropsUtil.get("DASHSCOPE_API_KEY")); Configuration configuration = new Configuration(); configuration.setDashScopeConfig(dashScopeConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.DASHSCOPE); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen3-32b") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .parameter("enable_thinking", false) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen3-32b") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen3-32b") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .parameter("enable_thinking", false) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen3-32b") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/DeepSeekTest.java ================================================ package io.github.lnyocly; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.*; import io.github.lnyocly.ai4j.exception.chain.ErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import io.github.lnyocly.ai4j.document.RecursiveCharacterTextSplitter; import io.github.lnyocly.ai4j.document.TikaUtil; import io.github.lnyocly.ai4j.vector.VectorDataEntity; import io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete; import io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert; import io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery; import io.github.lnyocly.ai4j.vector.pinecone.PineconeVectors; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.apache.commons.lang3.ObjectUtils; import org.apache.tika.exception.TikaException; import org.junit.Before; import org.junit.Test; import org.reflections.Reflections; import org.xml.sax.SAXException; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; import java.net.Proxy; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * @Author cly * @Description deepseek测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class DeepSeekTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { DeepSeekConfig deepSeekConfig = new DeepSeekConfig(); deepSeekConfig.setApiKey("sk-123456789"); Configuration configuration = new Configuration(); configuration.setDeepSeekConfig(deepSeekConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.DEEPSEEK); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("deepseek-chat") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("deepseek-chat") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("deepseek-reasoner") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println("思考内容:"); System.out.println(sseListener.getReasoningOutput()); System.out.println("回答内容: "); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("deepseek-chat") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("deepseek-chat") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/DoubaoImageTest.java ================================================ package io.github.lnyocly; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration; import io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IImageService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Before; import org.junit.Test; import org.junit.Assume; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description 豆包图片生成测试 * @Date 2026/1/31 */ public class DoubaoImageTest { private IImageService imageService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); imageService = aiService.getImageService(PlatformType.DOUBAO); } @Test public void test_image_generate() throws Exception { ImageGeneration request = ImageGeneration.builder() .model("doubao-seedream-4-5-251128") .prompt("一只戴着飞行员护目镜的小猫,卡通风格,明亮配色") .size("2K") .responseFormat("url") .build(); ImageGenerationResponse response = imageService.generate(request); System.out.println(response); } @Test public void test_image_generate_stream() throws Exception { ImageGeneration request = ImageGeneration.builder() .model("doubao-seedream-4-5-251128") .prompt("一只戴着飞行员护目镜的小猫,卡通风格,明亮配色") .size("2K") .responseFormat("url") .stream(true) .build(); imageService.generateStream(request, new io.github.lnyocly.ai4j.listener.ImageSseListener() { @Override protected void onEvent() { System.out.println(getCurrEvent()); } }); System.out.println("stream finished"); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/DoubaoResponsesTest.java ================================================ package io.github.lnyocly; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.ResponseSseListener; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IResponsesService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; public class DoubaoResponsesTest { private IResponsesService responsesService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); responsesService = aiService.getResponsesService(PlatformType.DOUBAO); } @Test public void test_responses_create() throws Exception { ResponseRequest request = ResponseRequest.builder() .model("doubao-seed-1-8-251228") .input("Summarize the Responses API in one sentence") .build(); Response response = responsesService.create(request); System.out.println(response); } @Test public void test_responses_stream() throws Exception { ResponseRequest request = ResponseRequest.builder() .model("doubao-seed-1-8-251228") .input("Describe the Responses API in one sentence") .build(); ResponseSseListener listener = new ResponseSseListener() { @Override protected void onEvent() { if (!getCurrText().isEmpty()) { System.out.print(getCurrText()); } } }; responsesService.createStream(request, listener); System.out.println(); System.out.println("stream finished"); System.out.println(listener.getResponse()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/DoubaoTest.java ================================================ package io.github.lnyocly; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.doubao.chat.DoubaoChatService; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; @Slf4j public class DoubaoTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { DoubaoConfig doubaoConfig = new DoubaoConfig(); String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } if (apiKey == null || apiKey.isEmpty()) { apiKey = "************"; } doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); chatService = new DoubaoChatService(configuration); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("doubao-seed-1-6-250615") .message(ChatMessage.withUser("你好,请介绍一下你自己")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("doubao-seed-1-6-250615") .message(ChatMessage.withUser("先有鸡还是先有蛋?")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { // 当前流式输出的data的数据,即当前输出的token的内容,可能为content,reasoning_content, function call log.info(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("\n请求成功"); System.out.println("完整回答内容:"); System.out.println(sseListener.getOutput()); System.out.println("Token使用:"); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_multimodal_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("doubao-seed-1-6-250615") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { log.info(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/HunyuanTest.java ================================================ package io.github.lnyocly; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.config.HunyuanConfig; import io.github.lnyocly.ai4j.exception.chain.ErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.apache.commons.lang3.ObjectUtils; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import org.reflections.Reflections; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description 腾讯混元测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class HunyuanTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("HUNYUAN_API_KEY"); if (isBlank(apiKey)) { apiKey = System.getProperty("hunyuan.api.key"); } Assume.assumeTrue("Skip because Hunyuan API key is not configured", !isBlank(apiKey)); HunyuanConfig hunyuanConfig = new HunyuanConfig(); hunyuanConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setHunyuanConfig(hunyuanConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.HUNYUAN); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("hunyuan-lite") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-vision") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("hunyuan-lite") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("hunyuan-lite") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("hunyuan-lite") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/LingyiTest.java ================================================ package io.github.lnyocly; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.config.LingyiConfig; import io.github.lnyocly.ai4j.exception.chain.ErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.apache.commons.lang3.ObjectUtils; import org.junit.Before; import org.junit.Test; import org.reflections.Reflections; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description 01万物测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class LingyiTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { LingyiConfig lingyiConfig = new LingyiConfig(); lingyiConfig.setApiKey("sk-123456789"); Configuration configuration = new Configuration(); configuration.setLingyiConfig(lingyiConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.LINGYI); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-vision") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-large-fc") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/MinimaxTest.java ================================================ package io.github.lnyocly; import io.github.lnyocly.ai4j.config.MinimaxConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author : isxuwl * @Date: 2024/10/15 16:08 * @Model Description: minimax 测试类 * @Description: */ @Slf4j public class MinimaxTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { MinimaxConfig minimaxConfig = new MinimaxConfig(); // minimaxConfig.setApiKey("sk-123456789"); Configuration configuration = new Configuration(); configuration.setMinimaxConfig(minimaxConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.MINIMAX); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("abab6.5s-chat") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-vision") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("abab6.5s-chat") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-large-fc") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/MoonshotTest.java ================================================ package io.github.lnyocly; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.config.MoonshotConfig; import io.github.lnyocly.ai4j.exception.chain.ErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.apache.commons.lang3.ObjectUtils; import org.junit.Before; import org.junit.Test; import org.reflections.Reflections; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description Moonshot测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class MoonshotTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { MoonshotConfig moonshotConfig = new MoonshotConfig(); moonshotConfig.setApiKey("**********"); Configuration configuration = new Configuration(); configuration.setMoonshotConfig(moonshotConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.MOONSHOT); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("moonshot-v1-8k") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } // kimi 不支持http url,且需要携带base64头 @Test public void test_chatCompletions_multimodal() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("moonshot-v1-8k-vision-preview") .message(ChatMessage.withUser("图片中有什么?", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAG0AAABmCAYAAADBPx+VAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA3VSURBVHgB7Z27r0zdG8fX743i1bi1ikMoFMQloXRpKFFIqI7LH4BEQ+NWIkjQuSWCRIEoULk0gsK1kCBI0IhrQVT7tz/7zZo888yz1r7MnDl7z5xvsjkzs2fP3uu71nNfa7lkAsm7d++Sffv2JbNmzUqcc8m0adOSzZs3Z+/XES4ZckAWJEGWPiCxjsQNLWmQsWjRIpMseaxcuTKpG/7HP27I8P79e7dq1ars/yL4/v27S0ejqwv+cUOGEGGpKHR37tzJCEpHV9tnT58+dXXCJDdECBE2Ojrqjh071hpNECjx4cMHVycM1Uhbv359B2F79+51586daxN/+pyRkRFXKyRDAqxEp4yMlDDzXG1NPnnyJKkThoK0VFd1ELZu3TrzXKxKfW7dMBQ6bcuWLW2v0VlHjx41z717927ba22U9APcw7Nnz1oGEPeL3m3p2mTAYYnFmMOMXybPPXv2bNIPpFZr1NHn4HMw0KRBjg9NuRw95s8PEcz/6DZELQd/09C9QGq5RsmSRybqkwHGjh07OsJSsYYm3ijPpyHzoiacg35MLdDSIS/O1yM778jOTwYUkKNHWUzUWaOsylE00MyI0fcnOwIdjvtNdW/HZwNLGg+sR1kMepSNJXmIwxBZiG8tDTpEZzKg0GItNsosY8USkxDhD0Rinuiko2gfL/RbiD2LZAjU9zKQJj8RDR0vJBR1/Phx9+PHj9Z7REF4nTZkxzX4LCXHrV271qXkBAPGfP/atWvu/PnzHe4C97F48eIsRLZ9+3a3f/9+87dwP1JxaF7/3r17ba+5l4EcaVo0lj3SBq5kGTJSQmLWMjgYNei2GPT1MuMqGTDEFHzeQSP2wi/jGnkmPJ/nhccs44jvDAxpVcxnq0F6eT8h4ni/iIWpR5lPyA6ETkNXoSukvpJAD3AsXLiwpZs49+fPn5ke4j10TqYvegSfn0OnafC+Tv9ooA/JPkgQysqQNBzagXY55nO/oa1F7qvIPWkRL12WRpMWUvpVDYmxAPehxWSe8ZEXL20sadYIozfmNch4QJPAfeJgW3rNsnzphBKNJM2KKODo1rVOMRYik5ETy3ix4qWNI81qAAirizgMIc+yhTytx0JWZuNI03qsrgWlGtwjoS9XwgUhWGyhUaRZZQNNIEwCiXD16tXcAHUs79co0vSD8rrJCIW98pzvxpAWyyo3HYwqS0+H0BjStClcZJT5coMm6D2LOF8TolGJtK9fvyZpyiC5ePFi9nc/oJU4eiEP0jVoAnHa9wyJycITMP78+eMeP37sXrx44d6+fdt6f82aNdkx1pg9e3Zb5W+RSRE+n+VjksQWifvVaTKFhn5O8my63K8Qabdv33b379/PiAP//vuvW7BggZszZ072/+TJk91YgkafPn166zXB1rQHFvouAWHq9z3SEevSUerqCn2/dDCeta2jxYbr69evk4MHDyY7d+7MjhMnTiTPnz9Pfv/+nfQT2ggpO2dMF8cghuoM7Ygj5iWCqRlGFml0QC/ftGmTmzt3rmsaKDsgBSPh0/8yPeLLBihLkOKJc0jp8H8vUzcxIA1k6QJ/c78tWEyj5P3o4u9+jywNPdJi5rAH9x0KHcl4Hg570eQp3+vHXGyrmEeigzQsQsjavXt38ujRo44LQuDDhw+TW7duRS1HGgMxhNXHgflaNTOsHyKvHK5Ijo2jbFjJBQK9YwFd6RVMzfgRBmEfP37suBBm/p49e1qjEP2mwTViNRo0VJWH1deMXcNK08uUjVUu7s/zRaL+oLNxz1bpANco4npUgX4G2eFbpDFyQoQxojBCpEGSytmOH8qrH5Q9vuzD6ofQylkCUmh8DBAr+q8JCyVNtWQIidKQE9wNtLSQnS4jDSsxNHogzFuQBw4cyM61UKVsjfr3ooBkPSqqQHesUPWVtzi9/vQi1T+rJj7WiTz4Pt/l3LxUkr5P2VYZaZ4URpsE+st/dujQoaBBYokbrz/8TJNQYLSonrPS9kUaSkPeZyj1AWSj+d+VBoy1pIWVNed8P0Ll/ee5HdGRhrHhR5GGN0r4LGZBaj8oFDJitBTJzIZgFcmU0Y8ytWMZMzJOaXUSrUs5RxKnrxmbb5YXO9VGUhtpXldhEUogFr3IzIsvlpmdosVcGVGXFWp2oU9kLFL3dEkSz6NHEY1sjSRdIuDFWEhd8KxFqsRi1uM/nz9/zpxnwlESONdg6dKlbsaMGS4EHFHtjFIDHwKOo46l4TxSuxgDzi+rE2jg+BaFruOX4HXa0Nnf1lwAPufZeF8/r6zD97WK2qFnGjBxTw5qNGPxT+5T/r7/7RawFC3j4vTp09koCxkeHjqbHJqArmH5UrFKKksnxrK7FuRIs8STfBZv+luugXZ2pR/pP9Ois4z+TiMzUUkUjD0iEi1fzX8GmXyuxUBRcaUfykV0YZnlJGKQpOiGB76x5GeWkWWJc3mOrK6S7xdND+W5N6XyaRgtWJFe13GkaZnKOsYqGdOVVVbGupsyA/l7emTLHi7vwTdirNEt0qxnzAvBFcnQF16xh/TMpUuXHDowhlA9vQVraQhkudRdzOnK+04ZSP3DUhVSP61YsaLtd/ks7ZgtPcXqPqEafHkdqa84X6aCeL7YWlv6edGFHb+ZFICPlljHhg0bKuk0CSvVznWsotRu433alNdFrqG45ejoaPCaUkWERpLXjzFL2Rpllp7PJU2a/v7Ab8N05/9t27Z16KUqoFGsxnI9EosS2niSYg9SpU6B4JgTrvVW1flt1sT+0ADIJU2maXzcUTraGCRaL1Wp9rUMk16PMom8QhruxzvZIegJjFU7LLCePfS8uaQdPny4jTTL0dbee5mYokQsXTIWNY46kuMbnt8Kmec+LGWtOVIl9cT1rCB0V8WqkjAsRwta93TbwNYoGKsUSChN44lgBNCoHLHzquYKrU6qZ8lolCIN0Rh6cP0Q3U6I6IXILYOQI513hJaSKAorFpuHXJNfVlpRtmYBk1Su1obZr5dnKAO+L10Hrj3WZW+E3qh6IszE37F6EB+68mGpvKm4eb9bFrlzrok7fvr0Kfv727dvWRmdVTJHw0qiiCUSZ6wCK+7XL/AcsgNyL74DQQ730sv78Su7+t/A36MdY0sW5o40ahslXr58aZ5HtZB8GH64m9EmMZ7FpYw4T6QnrZfgenrhFxaSiSGXtPnz57e9TkNZLvTjeqhr734CNtrK41L40sUQckmj1lGKQ0rC37x544r8eNXRpnVE3ZZY7zXo8NomiO0ZUCj2uHz58rbXoZ6gc0uA+F6ZeKS/jhRDUq8MKrTho9fEkihMmhxtBI1DxKFY9XLpVcSkfoi8JGnToZO5sU5aiDQIW716ddt7ZLYtMQlhECdBGXZZMWldY5BHm5xgAroWj4C0hbYkSc/jBmggIrXJWlZM6pSETsEPGqZOndr2uuuR5rF169a2HoHPdurUKZM4CO1WTPqaDaAd+GFGKdIQkxAn9RuEWcTRyN2KSUgiSgF5aWzPTeA/lN5rZubMmR2bE4SIC4nJoltgAV/dVefZm72AtctUCJU2CMJ327hxY9t7EHbkyJFseq+EJSY16RPo3Dkq1kkr7+q0bNmyDuLQcZBEPYmHVdOBiJyIlrRDq41YPWfXOxUysi5fvtyaj+2BpcnsUV/oSoEMOk2CQGlr4ckhBwaetBhjCwH0ZHtJROPJkyc7UjcYLDjmrH7ADTEBXFfOYmB0k9oYBOjJ8b4aOYSe7QkKcYhFlq3QYLQhSidNmtS2RATwy8YOM3EQJsUjKiaWZ+vZToUQgzhkHXudb/PW5YMHD9yZM2faPsMwoc7RciYJXbGuBqJ1UIGKKLv915jsvgtJxCZDubdXr165mzdvtr1Hz5LONA8jrUwKPqsmVesKa49S3Q4WxmRPUEYdTjgiUcfUwLx589ySJUva3oMkP6IYddq6HMS4o55xBJBUeRjzfa4Zdeg56QZ43LhxoyPo7Lf1kNt7oO8wWAbNwaYjIv5lhyS7kRf96dvm5Jah8vfvX3flyhX35cuX6HfzFHOToS1H4BenCaHvO8pr8iDuwoUL7tevX+b5ZdbBair0xkFIlFDlW4ZknEClsp/TzXyAKVOmmHWFVSbDNw1l1+4f90U6IY/q4V27dpnE9bJ+v87QEydjqx/UamVVPRG+mwkNTYN+9tjkwzEx+atCm/X9WvWtDtAb68Wy9LXa1UmvCDDIpPkyOQ5ZwSzJ4jMrvFcr0rSjOUh+GcT4LSg5ugkW1Io0/SCDQBojh0hPlaJdah+tkVYrnTZowP8iq1F1TgMBBauufyB33x1v+NWFYmT5KmppgHC+NkAgbmRkpD3yn9QIseXymoTQFGQmIOKTxiZIWpvAatenVqRVXf2nTrAWMsPnKrMZHz6bJq5jvce6QK8J1cQNgKxlJapMPdZSR64/UivS9NztpkVEdKcrs5alhhWP9NeqlfWopzhZScI6QxseegZRGeg5a8C3Re1Mfl1ScP36ddcUaMuv24iOJtz7sbUjTS4qBvKmstYJoUauiuD3k5qhyr7QdUHMeCgLa1Ear9NquemdXgmum4fvJ6w1lqsuDhNrg1qSpleJK7K3TF0Q2jSd94uSZ60kK1e3qyVpQK6PVWXp2/FC3mp6jBhKKOiY2h3gtUV64TWM6wDETRPLDfSakXmH3w8g9Jlug8ZtTt4kVF0kLUYYmCCtD/DrQ5YhMGbA9L3ucdjh0y8kOHW5gU/VEEmJTcL4Pz/f7mgoAbYkAAAAAElFTkSuQmCC") ).build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("moonshot-v1-8k") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("moonshot-v1-8k") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("moonshot-v1-8k") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/OllamaTest.java ================================================ package io.github.lnyocly; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.config.OllamaConfig; import io.github.lnyocly.ai4j.interceptor.ContentTypeInterceptor; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Before; import org.junit.Test; import java.net.InetSocketAddress; import java.net.Proxy; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description ollama测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class OllamaTest { private IChatService chatService; private IChatService webEnhance; private IEmbeddingService embeddingService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { SearXNGConfig searXNGConfig = new SearXNGConfig(); searXNGConfig.setUrl("http://127.0.0.1:8080/search"); OllamaConfig ollamaConfig = new OllamaConfig(); Configuration configuration = new Configuration(); configuration.setOllamaConfig(ollamaConfig); configuration.setSearXNGConfig(searXNGConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .addInterceptor(new ContentTypeInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) //.sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) //.hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.OLLAMA); webEnhance = aiService.webSearchEnhance(chatService); embeddingService = aiService.getEmbeddingService(PlatformType.OLLAMA); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen2.5:7b") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_common_websearch_enhance() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen2.5:7b") .message(ChatMessage.withUser("鸡你太美是什么梗")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = webEnhance.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { // ollama只支持传输base64图片 ChatCompletion chatCompletion = ChatCompletion.builder() .model("llava") .message(ChatMessage.withUser("图片中有什么?", "iVBORw0KGgoAAAANSUhEUgAAAG0AAABmCAYAAADBPx+VAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA3VSURBVHgB7Z27r0zdG8fX743i1bi1ikMoFMQloXRpKFFIqI7LH4BEQ+NWIkjQuSWCRIEoULk0gsK1kCBI0IhrQVT7tz/7zZo888yz1r7MnDl7z5xvsjkzs2fP3uu71nNfa7lkAsm7d++Sffv2JbNmzUqcc8m0adOSzZs3Z+/XES4ZckAWJEGWPiCxjsQNLWmQsWjRIpMseaxcuTKpG/7HP27I8P79e7dq1ars/yL4/v27S0ejqwv+cUOGEGGpKHR37tzJCEpHV9tnT58+dXXCJDdECBE2Ojrqjh071hpNECjx4cMHVycM1Uhbv359B2F79+51586daxN/+pyRkRFXKyRDAqxEp4yMlDDzXG1NPnnyJKkThoK0VFd1ELZu3TrzXKxKfW7dMBQ6bcuWLW2v0VlHjx41z717927ba22U9APcw7Nnz1oGEPeL3m3p2mTAYYnFmMOMXybPPXv2bNIPpFZr1NHn4HMw0KRBjg9NuRw95s8PEcz/6DZELQd/09C9QGq5RsmSRybqkwHGjh07OsJSsYYm3ijPpyHzoiacg35MLdDSIS/O1yM778jOTwYUkKNHWUzUWaOsylE00MyI0fcnOwIdjvtNdW/HZwNLGg+sR1kMepSNJXmIwxBZiG8tDTpEZzKg0GItNsosY8USkxDhD0Rinuiko2gfL/RbiD2LZAjU9zKQJj8RDR0vJBR1/Phx9+PHj9Z7REF4nTZkxzX4LCXHrV271qXkBAPGfP/atWvu/PnzHe4C97F48eIsRLZ9+3a3f/9+87dwP1JxaF7/3r17ba+5l4EcaVo0lj3SBq5kGTJSQmLWMjgYNei2GPT1MuMqGTDEFHzeQSP2wi/jGnkmPJ/nhccs44jvDAxpVcxnq0F6eT8h4ni/iIWpR5lPyA6ETkNXoSukvpJAD3AsXLiwpZs49+fPn5ke4j10TqYvegSfn0OnafC+Tv9ooA/JPkgQysqQNBzagXY55nO/oa1F7qvIPWkRL12WRpMWUvpVDYmxAPehxWSe8ZEXL20sadYIozfmNch4QJPAfeJgW3rNsnzphBKNJM2KKODo1rVOMRYik5ETy3ix4qWNI81qAAirizgMIc+yhTytx0JWZuNI03qsrgWlGtwjoS9XwgUhWGyhUaRZZQNNIEwCiXD16tXcAHUs79co0vSD8rrJCIW98pzvxpAWyyo3HYwqS0+H0BjStClcZJT5coMm6D2LOF8TolGJtK9fvyZpyiC5ePFi9nc/oJU4eiEP0jVoAnHa9wyJycITMP78+eMeP37sXrx44d6+fdt6f82aNdkx1pg9e3Zb5W+RSRE+n+VjksQWifvVaTKFhn5O8my63K8Qabdv33b379/PiAP//vuvW7BggZszZ072/+TJk91YgkafPn166zXB1rQHFvouAWHq9z3SEevSUerqCn2/dDCeta2jxYbr69evk4MHDyY7d+7MjhMnTiTPnz9Pfv/+nfQT2ggpO2dMF8cghuoM7Ygj5iWCqRlGFml0QC/ftGmTmzt3rmsaKDsgBSPh0/8yPeLLBihLkOKJc0jp8H8vUzcxIA1k6QJ/c78tWEyj5P3o4u9+jywNPdJi5rAH9x0KHcl4Hg570eQp3+vHXGyrmEeigzQsQsjavXt38ujRo44LQuDDhw+TW7duRS1HGgMxhNXHgflaNTOsHyKvHK5Ijo2jbFjJBQK9YwFd6RVMzfgRBmEfP37suBBm/p49e1qjEP2mwTViNRo0VJWH1deMXcNK08uUjVUu7s/zRaL+oLNxz1bpANco4npUgX4G2eFbpDFyQoQxojBCpEGSytmOH8qrH5Q9vuzD6ofQylkCUmh8DBAr+q8JCyVNtWQIidKQE9wNtLSQnS4jDSsxNHogzFuQBw4cyM61UKVsjfr3ooBkPSqqQHesUPWVtzi9/vQi1T+rJj7WiTz4Pt/l3LxUkr5P2VYZaZ4URpsE+st/dujQoaBBYokbrz/8TJNQYLSonrPS9kUaSkPeZyj1AWSj+d+VBoy1pIWVNed8P0Ll/ee5HdGRhrHhR5GGN0r4LGZBaj8oFDJitBTJzIZgFcmU0Y8ytWMZMzJOaXUSrUs5RxKnrxmbb5YXO9VGUhtpXldhEUogFr3IzIsvlpmdosVcGVGXFWp2oU9kLFL3dEkSz6NHEY1sjSRdIuDFWEhd8KxFqsRi1uM/nz9/zpxnwlESONdg6dKlbsaMGS4EHFHtjFIDHwKOo46l4TxSuxgDzi+rE2jg+BaFruOX4HXa0Nnf1lwAPufZeF8/r6zD97WK2qFnGjBxTw5qNGPxT+5T/r7/7RawFC3j4vTp09koCxkeHjqbHJqArmH5UrFKKksnxrK7FuRIs8STfBZv+luugXZ2pR/pP9Ois4z+TiMzUUkUjD0iEi1fzX8GmXyuxUBRcaUfykV0YZnlJGKQpOiGB76x5GeWkWWJc3mOrK6S7xdND+W5N6XyaRgtWJFe13GkaZnKOsYqGdOVVVbGupsyA/l7emTLHi7vwTdirNEt0qxnzAvBFcnQF16xh/TMpUuXHDowhlA9vQVraQhkudRdzOnK+04ZSP3DUhVSP61YsaLtd/ks7ZgtPcXqPqEafHkdqa84X6aCeL7YWlv6edGFHb+ZFICPlljHhg0bKuk0CSvVznWsotRu433alNdFrqG45ejoaPCaUkWERpLXjzFL2Rpllp7PJU2a/v7Ab8N05/9t27Z16KUqoFGsxnI9EosS2niSYg9SpU6B4JgTrvVW1flt1sT+0ADIJU2maXzcUTraGCRaL1Wp9rUMk16PMom8QhruxzvZIegJjFU7LLCePfS8uaQdPny4jTTL0dbee5mYokQsXTIWNY46kuMbnt8Kmec+LGWtOVIl9cT1rCB0V8WqkjAsRwta93TbwNYoGKsUSChN44lgBNCoHLHzquYKrU6qZ8lolCIN0Rh6cP0Q3U6I6IXILYOQI513hJaSKAorFpuHXJNfVlpRtmYBk1Su1obZr5dnKAO+L10Hrj3WZW+E3qh6IszE37F6EB+68mGpvKm4eb9bFrlzrok7fvr0Kfv727dvWRmdVTJHw0qiiCUSZ6wCK+7XL/AcsgNyL74DQQ730sv78Su7+t/A36MdY0sW5o40ahslXr58aZ5HtZB8GH64m9EmMZ7FpYw4T6QnrZfgenrhFxaSiSGXtPnz57e9TkNZLvTjeqhr734CNtrK41L40sUQckmj1lGKQ0rC37x544r8eNXRpnVE3ZZY7zXo8NomiO0ZUCj2uHz58rbXoZ6gc0uA+F6ZeKS/jhRDUq8MKrTho9fEkihMmhxtBI1DxKFY9XLpVcSkfoi8JGnToZO5sU5aiDQIW716ddt7ZLYtMQlhECdBGXZZMWldY5BHm5xgAroWj4C0hbYkSc/jBmggIrXJWlZM6pSETsEPGqZOndr2uuuR5rF169a2HoHPdurUKZM4CO1WTPqaDaAd+GFGKdIQkxAn9RuEWcTRyN2KSUgiSgF5aWzPTeA/lN5rZubMmR2bE4SIC4nJoltgAV/dVefZm72AtctUCJU2CMJ327hxY9t7EHbkyJFseq+EJSY16RPo3Dkq1kkr7+q0bNmyDuLQcZBEPYmHVdOBiJyIlrRDq41YPWfXOxUysi5fvtyaj+2BpcnsUV/oSoEMOk2CQGlr4ckhBwaetBhjCwH0ZHtJROPJkyc7UjcYLDjmrH7ADTEBXFfOYmB0k9oYBOjJ8b4aOYSe7QkKcYhFlq3QYLQhSidNmtS2RATwy8YOM3EQJsUjKiaWZ+vZToUQgzhkHXudb/PW5YMHD9yZM2faPsMwoc7RciYJXbGuBqJ1UIGKKLv915jsvgtJxCZDubdXr165mzdvtr1Hz5LONA8jrUwKPqsmVesKa49S3Q4WxmRPUEYdTjgiUcfUwLx589ySJUva3oMkP6IYddq6HMS4o55xBJBUeRjzfa4Zdeg56QZ43LhxoyPo7Lf1kNt7oO8wWAbNwaYjIv5lhyS7kRf96dvm5Jah8vfvX3flyhX35cuX6HfzFHOToS1H4BenCaHvO8pr8iDuwoUL7tevX+b5ZdbBair0xkFIlFDlW4ZknEClsp/TzXyAKVOmmHWFVSbDNw1l1+4f90U6IY/q4V27dpnE9bJ+v87QEydjqx/UamVVPRG+mwkNTYN+9tjkwzEx+atCm/X9WvWtDtAb68Wy9LXa1UmvCDDIpPkyOQ5ZwSzJ4jMrvFcr0rSjOUh+GcT4LSg5ugkW1Io0/SCDQBojh0hPlaJdah+tkVYrnTZowP8iq1F1TgMBBauufyB33x1v+NWFYmT5KmppgHC+NkAgbmRkpD3yn9QIseXymoTQFGQmIOKTxiZIWpvAatenVqRVXf2nTrAWMsPnKrMZHz6bJq5jvce6QK8J1cQNgKxlJapMPdZSR64/UivS9NztpkVEdKcrs5alhhWP9NeqlfWopzhZScI6QxseegZRGeg5a8C3Re1Mfl1ScP36ddcUaMuv24iOJtz7sbUjTS4qBvKmstYJoUauiuD3k5qhyr7QdUHMeCgLa1Ear9NquemdXgmum4fvJ6w1lqsuDhNrg1qSpleJK7K3TF0Q2jSd94uSZ60kK1e3qyVpQK6PVWXp2/FC3mp6jBhKKOiY2h3gtUV64TWM6wDETRPLDfSakXmH3w8g9Jlug8ZtTt4kVF0kLUYYmCCtD/DrQ5YhMGbA9L3ucdjh0y8kOHW5gU/VEEmJTcL4Pz/f7mgoAbYkAAAAAElFTkSuQmCC") ).build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("llava") .message(ChatMessage.withUser("图片中有什么?", "iVBORw0KGgoAAAANSUhEUgAAAG0AAABmCAYAAADBPx+VAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA3VSURBVHgB7Z27r0zdG8fX743i1bi1ikMoFMQloXRpKFFIqI7LH4BEQ+NWIkjQuSWCRIEoULk0gsK1kCBI0IhrQVT7tz/7zZo888yz1r7MnDl7z5xvsjkzs2fP3uu71nNfa7lkAsm7d++Sffv2JbNmzUqcc8m0adOSzZs3Z+/XES4ZckAWJEGWPiCxjsQNLWmQsWjRIpMseaxcuTKpG/7HP27I8P79e7dq1ars/yL4/v27S0ejqwv+cUOGEGGpKHR37tzJCEpHV9tnT58+dXXCJDdECBE2Ojrqjh071hpNECjx4cMHVycM1Uhbv359B2F79+51586daxN/+pyRkRFXKyRDAqxEp4yMlDDzXG1NPnnyJKkThoK0VFd1ELZu3TrzXKxKfW7dMBQ6bcuWLW2v0VlHjx41z717927ba22U9APcw7Nnz1oGEPeL3m3p2mTAYYnFmMOMXybPPXv2bNIPpFZr1NHn4HMw0KRBjg9NuRw95s8PEcz/6DZELQd/09C9QGq5RsmSRybqkwHGjh07OsJSsYYm3ijPpyHzoiacg35MLdDSIS/O1yM778jOTwYUkKNHWUzUWaOsylE00MyI0fcnOwIdjvtNdW/HZwNLGg+sR1kMepSNJXmIwxBZiG8tDTpEZzKg0GItNsosY8USkxDhD0Rinuiko2gfL/RbiD2LZAjU9zKQJj8RDR0vJBR1/Phx9+PHj9Z7REF4nTZkxzX4LCXHrV271qXkBAPGfP/atWvu/PnzHe4C97F48eIsRLZ9+3a3f/9+87dwP1JxaF7/3r17ba+5l4EcaVo0lj3SBq5kGTJSQmLWMjgYNei2GPT1MuMqGTDEFHzeQSP2wi/jGnkmPJ/nhccs44jvDAxpVcxnq0F6eT8h4ni/iIWpR5lPyA6ETkNXoSukvpJAD3AsXLiwpZs49+fPn5ke4j10TqYvegSfn0OnafC+Tv9ooA/JPkgQysqQNBzagXY55nO/oa1F7qvIPWkRL12WRpMWUvpVDYmxAPehxWSe8ZEXL20sadYIozfmNch4QJPAfeJgW3rNsnzphBKNJM2KKODo1rVOMRYik5ETy3ix4qWNI81qAAirizgMIc+yhTytx0JWZuNI03qsrgWlGtwjoS9XwgUhWGyhUaRZZQNNIEwCiXD16tXcAHUs79co0vSD8rrJCIW98pzvxpAWyyo3HYwqS0+H0BjStClcZJT5coMm6D2LOF8TolGJtK9fvyZpyiC5ePFi9nc/oJU4eiEP0jVoAnHa9wyJycITMP78+eMeP37sXrx44d6+fdt6f82aNdkx1pg9e3Zb5W+RSRE+n+VjksQWifvVaTKFhn5O8my63K8Qabdv33b379/PiAP//vuvW7BggZszZ072/+TJk91YgkafPn166zXB1rQHFvouAWHq9z3SEevSUerqCn2/dDCeta2jxYbr69evk4MHDyY7d+7MjhMnTiTPnz9Pfv/+nfQT2ggpO2dMF8cghuoM7Ygj5iWCqRlGFml0QC/ftGmTmzt3rmsaKDsgBSPh0/8yPeLLBihLkOKJc0jp8H8vUzcxIA1k6QJ/c78tWEyj5P3o4u9+jywNPdJi5rAH9x0KHcl4Hg570eQp3+vHXGyrmEeigzQsQsjavXt38ujRo44LQuDDhw+TW7duRS1HGgMxhNXHgflaNTOsHyKvHK5Ijo2jbFjJBQK9YwFd6RVMzfgRBmEfP37suBBm/p49e1qjEP2mwTViNRo0VJWH1deMXcNK08uUjVUu7s/zRaL+oLNxz1bpANco4npUgX4G2eFbpDFyQoQxojBCpEGSytmOH8qrH5Q9vuzD6ofQylkCUmh8DBAr+q8JCyVNtWQIidKQE9wNtLSQnS4jDSsxNHogzFuQBw4cyM61UKVsjfr3ooBkPSqqQHesUPWVtzi9/vQi1T+rJj7WiTz4Pt/l3LxUkr5P2VYZaZ4URpsE+st/dujQoaBBYokbrz/8TJNQYLSonrPS9kUaSkPeZyj1AWSj+d+VBoy1pIWVNed8P0Ll/ee5HdGRhrHhR5GGN0r4LGZBaj8oFDJitBTJzIZgFcmU0Y8ytWMZMzJOaXUSrUs5RxKnrxmbb5YXO9VGUhtpXldhEUogFr3IzIsvlpmdosVcGVGXFWp2oU9kLFL3dEkSz6NHEY1sjSRdIuDFWEhd8KxFqsRi1uM/nz9/zpxnwlESONdg6dKlbsaMGS4EHFHtjFIDHwKOo46l4TxSuxgDzi+rE2jg+BaFruOX4HXa0Nnf1lwAPufZeF8/r6zD97WK2qFnGjBxTw5qNGPxT+5T/r7/7RawFC3j4vTp09koCxkeHjqbHJqArmH5UrFKKksnxrK7FuRIs8STfBZv+luugXZ2pR/pP9Ois4z+TiMzUUkUjD0iEi1fzX8GmXyuxUBRcaUfykV0YZnlJGKQpOiGB76x5GeWkWWJc3mOrK6S7xdND+W5N6XyaRgtWJFe13GkaZnKOsYqGdOVVVbGupsyA/l7emTLHi7vwTdirNEt0qxnzAvBFcnQF16xh/TMpUuXHDowhlA9vQVraQhkudRdzOnK+04ZSP3DUhVSP61YsaLtd/ks7ZgtPcXqPqEafHkdqa84X6aCeL7YWlv6edGFHb+ZFICPlljHhg0bKuk0CSvVznWsotRu433alNdFrqG45ejoaPCaUkWERpLXjzFL2Rpllp7PJU2a/v7Ab8N05/9t27Z16KUqoFGsxnI9EosS2niSYg9SpU6B4JgTrvVW1flt1sT+0ADIJU2maXzcUTraGCRaL1Wp9rUMk16PMom8QhruxzvZIegJjFU7LLCePfS8uaQdPny4jTTL0dbee5mYokQsXTIWNY46kuMbnt8Kmec+LGWtOVIl9cT1rCB0V8WqkjAsRwta93TbwNYoGKsUSChN44lgBNCoHLHzquYKrU6qZ8lolCIN0Rh6cP0Q3U6I6IXILYOQI513hJaSKAorFpuHXJNfVlpRtmYBk1Su1obZr5dnKAO+L10Hrj3WZW+E3qh6IszE37F6EB+68mGpvKm4eb9bFrlzrok7fvr0Kfv727dvWRmdVTJHw0qiiCUSZ6wCK+7XL/AcsgNyL74DQQ730sv78Su7+t/A36MdY0sW5o40ahslXr58aZ5HtZB8GH64m9EmMZ7FpYw4T6QnrZfgenrhFxaSiSGXtPnz57e9TkNZLvTjeqhr734CNtrK41L40sUQckmj1lGKQ0rC37x544r8eNXRpnVE3ZZY7zXo8NomiO0ZUCj2uHz58rbXoZ6gc0uA+F6ZeKS/jhRDUq8MKrTho9fEkihMmhxtBI1DxKFY9XLpVcSkfoi8JGnToZO5sU5aiDQIW716ddt7ZLYtMQlhECdBGXZZMWldY5BHm5xgAroWj4C0hbYkSc/jBmggIrXJWlZM6pSETsEPGqZOndr2uuuR5rF169a2HoHPdurUKZM4CO1WTPqaDaAd+GFGKdIQkxAn9RuEWcTRyN2KSUgiSgF5aWzPTeA/lN5rZubMmR2bE4SIC4nJoltgAV/dVefZm72AtctUCJU2CMJ327hxY9t7EHbkyJFseq+EJSY16RPo3Dkq1kkr7+q0bNmyDuLQcZBEPYmHVdOBiJyIlrRDq41YPWfXOxUysi5fvtyaj+2BpcnsUV/oSoEMOk2CQGlr4ckhBwaetBhjCwH0ZHtJROPJkyc7UjcYLDjmrH7ADTEBXFfOYmB0k9oYBOjJ8b4aOYSe7QkKcYhFlq3QYLQhSidNmtS2RATwy8YOM3EQJsUjKiaWZ+vZToUQgzhkHXudb/PW5YMHD9yZM2faPsMwoc7RciYJXbGuBqJ1UIGKKLv915jsvgtJxCZDubdXr165mzdvtr1Hz5LONA8jrUwKPqsmVesKa49S3Q4WxmRPUEYdTjgiUcfUwLx589ySJUva3oMkP6IYddq6HMS4o55xBJBUeRjzfa4Zdeg56QZ43LhxoyPo7Lf1kNt7oO8wWAbNwaYjIv5lhyS7kRf96dvm5Jah8vfvX3flyhX35cuX6HfzFHOToS1H4BenCaHvO8pr8iDuwoUL7tevX+b5ZdbBair0xkFIlFDlW4ZknEClsp/TzXyAKVOmmHWFVSbDNw1l1+4f90U6IY/q4V27dpnE9bJ+v87QEydjqx/UamVVPRG+mwkNTYN+9tjkwzEx+atCm/X9WvWtDtAb68Wy9LXa1UmvCDDIpPkyOQ5ZwSzJ4jMrvFcr0rSjOUh+GcT4LSg5ugkW1Io0/SCDQBojh0hPlaJdah+tkVYrnTZowP8iq1F1TgMBBauufyB33x1v+NWFYmT5KmppgHC+NkAgbmRkpD3yn9QIseXymoTQFGQmIOKTxiZIWpvAatenVqRVXf2nTrAWMsPnKrMZHz6bJq5jvce6QK8J1cQNgKxlJapMPdZSR64/UivS9NztpkVEdKcrs5alhhWP9NeqlfWopzhZScI6QxseegZRGeg5a8C3Re1Mfl1ScP36ddcUaMuv24iOJtz7sbUjTS4qBvKmstYJoUauiuD3k5qhyr7QdUHMeCgLa1Ear9NquemdXgmum4fvJ6w1lqsuDhNrg1qSpleJK7K3TF0Q2jSd94uSZ60kK1e3qyVpQK6PVWXp2/FC3mp6jBhKKOiY2h3gtUV64TWM6wDETRPLDfSakXmH3w8g9Jlug8ZtTt4kVF0kLUYYmCCtD/DrQ5YhMGbA9L3ucdjh0y8kOHW5gU/VEEmJTcL4Pz/f7mgoAbYkAAAAAElFTkSuQmCC") ).build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen:0.5b") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen2.5:7b") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("qwen2.5:7b") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } @Test public void test_embedding() throws Exception { Embedding build = Embedding.builder().model("all-minilm").input("Why is the sky blue?").build(); EmbeddingResponse embeddingResponse = embeddingService.embedding(build); System.out.println(embeddingResponse); } @Test public void test_embedding_multiple_input() throws Exception { List inputs = new ArrayList<>(); inputs.add("Why is the sky blue?"); inputs.add("Why is the grass green?"); Embedding build = Embedding.builder().model("all-minilm").input(inputs).build(); EmbeddingResponse embeddingResponse = embeddingService.embedding(build); System.out.println(embeddingResponse); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/OpenAiTest.java ================================================ package io.github.lnyocly; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.tool.ToolUtil; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.interceptor.ContentTypeInterceptor; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.RealtimeListener; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.network.ConnectionPoolProvider; import io.github.lnyocly.ai4j.network.DispatcherProvider; import io.github.lnyocly.ai4j.platform.openai.audio.entity.*; import io.github.lnyocly.ai4j.platform.openai.audio.enums.AudioEnum; import io.github.lnyocly.ai4j.platform.openai.chat.entity.*; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.service.*; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import io.github.lnyocly.ai4j.document.RecursiveCharacterTextSplitter; import io.github.lnyocly.ai4j.service.spi.ServiceLoaderUtil; import io.github.lnyocly.ai4j.document.TikaUtil; import io.github.lnyocly.ai4j.vector.VectorDataEntity; import io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete; import io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert; import io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery; import io.github.lnyocly.ai4j.vector.pinecone.PineconeVectors; import io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig; import lombok.extern.slf4j.Slf4j; import okhttp3.*; import okhttp3.logging.HttpLoggingInterceptor; import okio.ByteString; import org.apache.commons.io.FileUtils; import org.apache.tika.exception.TikaException; import org.junit.Before; import org.junit.Test; import org.reflections.Reflections; import org.xml.sax.SAXException; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; import java.net.Proxy; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * @Author cly * @Description OpenAi测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class OpenAiTest { private IEmbeddingService embeddingService; private IChatService chatService; private IChatService webEnhance; private IAudioService audioService; private IRealtimeService realtimeService; Reflections reflections = new Reflections(); @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { SearXNGConfig searXNGConfig = new SearXNGConfig(); searXNGConfig.setUrl("http://127.0.0.1:8080/search"); OpenAiConfig openAiConfig = new OpenAiConfig(); openAiConfig.setApiHost("************"); openAiConfig.setApiKey("*************"); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); configuration.setSearXNGConfig(searXNGConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); DispatcherProvider dispatcherProvider = ServiceLoaderUtil.load(DispatcherProvider.class); ConnectionPoolProvider connectionPoolProvider = ServiceLoaderUtil.load(ConnectionPoolProvider.class); Dispatcher dispatcher = dispatcherProvider.getDispatcher(); ConnectionPool connectionPool = connectionPoolProvider.getConnectionPool(); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ContentTypeInterceptor()) //.addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .dispatcher(dispatcher) .connectionPool(connectionPool) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI); //chatService = aiService.getChatService(PlatformType.getPlatform("OPENAI")); chatService = aiService.getChatService(PlatformType.OPENAI); audioService = aiService.getAudioService(PlatformType.OPENAI); realtimeService = aiService.getRealtimeService(PlatformType.OPENAI); webEnhance = aiService.webSearchEnhance(chatService); } @Test public void test_embed() throws Exception { Embedding build = Embedding.builder() .input("The food was delicious and the waiter...") .model("text-embedding-ada-002") .build(); System.out.println(build); EmbeddingResponse embedding = embeddingService.embedding(null, null, build); System.out.println(embedding); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_history() throws Exception { List history = new ArrayList<>(); ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 向历史中添加刚刚问过的消息 history.add(chatCompletion.getMessages().get(chatCompletion.getMessages().size()-1)); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse.getChoices().get(0).getMessage()); // 将返回的消息添加到历史中 history.add(chatCompletionResponse.getChoices().get(0).getMessage()); // 开始第二次问答 history.add(ChatMessage.withUser("我刚刚问了什么问题")); ChatCompletion chatCompletionWithHistory = ChatCompletion.builder() .model("gpt-4o-mini") .messages(history) .build(); ChatCompletionResponse chatCompletionResponseWithHistory = chatService.chatCompletion(chatCompletionWithHistory); System.out.println("请求成功"); System.out.println(chatCompletionResponseWithHistory); } @Test public void test_chatCompletions_common_websearch_enhance() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鸡你太美")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = webEnhance.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { // 当传递base64图片时的格式 // "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}, ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); System.out.println(new ObjectMapper().writeValueAsString(chatCompletion)); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { log.info(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("deepseek-reasoner") .message(ChatMessage.withUser("请思考,先有鸡还是先有蛋")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { long aaa = System.currentTimeMillis(); //System.out.println(aaa - currentTimeMillis); log.info(this.getCurrStr()); } }; long currentTimeMillis = System.currentTimeMillis(); log.info("开始请求"); chatService.chatCompletionStream(chatCompletion, sseListener); log.info("请求结束"); long aaa = System.currentTimeMillis(); System.out.println(aaa - currentTimeMillis); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getReasoningOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_stream_cancel() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4.1-nano") .message(ChatMessage.withUser("你好,你是谁")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void error(Throwable t, Response response) { log.error("出错了"); log.error(t.getMessage()); log.error(response.message()); } @Override protected void send() { long aaa = System.currentTimeMillis(); //System.out.println(aaa - currentTimeMillis); if("我".equals(this.getCurrStr())) { this.getEventSource().cancel(); log.warn("取消"); } log.info(this.getCurrData()); } }; long currentTimeMillis = System.currentTimeMillis(); log.info("开始请求"); chatService.chatCompletionStream(chatCompletion, sseListener); log.info("请求结束"); long aaa = System.currentTimeMillis(); System.out.println(aaa - currentTimeMillis); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getReasoningOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("获取当前的时间")) .functions("queryWeather", "queryTrainInfo") .mcpService("TestService") .build(); System.out.println("请求参数"); ObjectMapper objectMapper = new ObjectMapper(); System.out.println(objectMapper.writeValueAsString(chatCompletion)); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(objectMapper.writeValueAsString(chatCompletion)); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } @Test public void test_text_to_speech() throws IOException { TextToSpeech speechRequest = TextToSpeech.builder() .input("你好,有什么我可以帮助你的吗?") .voice(AudioEnum.Voice.ECHO.getValue()) .build(); InputStream inputStream = audioService.textToSpeech(speechRequest); FileUtils.copyToFile(inputStream, new File("C:\\Users\\1\\Desktop\\audio.mp3")); } @Test public void test_transcription(){ Transcription request = Transcription.builder() .file(new File("C:\\Users\\1\\Desktop\\audio.mp3")) .model("whisper-1") .build(); TranscriptionResponse transcription = audioService.transcription(request); System.out.println(transcription); } @Test public void test_translation(){ Translation request = Translation.builder() .file(new File("C:\\Users\\1\\Desktop\\audio.mp3")) .model("whisper-1") .build(); TranslationResponse translation = audioService.translation(request); System.out.println(translation); } @Test public void test_create_websocket(){ CountDownLatch countDownLatch = new CountDownLatch(1); WebSocket realtimeClient = realtimeService.createRealtimeClient("gpt-4o-realtime-preview", new RealtimeListener() { @Override protected void onOpen(WebSocket webSocket) { log.info("OpenAi Realtime 连接成功"); log.info("准备发送消息"); webSocket.send("{\"type\":\"response.create\",\"response\":{\"modalities\":[\"text\"],\"instructions\":\"Please assist the user.\"}}"); webSocket.close(1000, "OpenAi realtime client 关闭"); //countDownLatch.countDown(); } @Override protected void onMessage(ByteString bytes) { log.info("收到消息:{}", bytes.toString()); } @Override protected void onMessage(String text) { log.info("收到消息:{}", text); } @Override protected void onFailure() { System.out.println("连接失败"); } }); System.out.println(11111111); try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } } @Test public void test__() throws Exception { // 读取文件 // ...... // 分割文本 String word = "
上传pdf
"; RecursiveCharacterTextSplitter recursiveCharacterTextSplitter = new RecursiveCharacterTextSplitter(1000, 200); List strings = recursiveCharacterTextSplitter.splitText(word); System.out.println(strings.size()); // 转为向量 Embedding build = Embedding.builder() .input(strings) .model("text-embedding-3-small") .build(); System.out.println(build); EmbeddingResponse embedding = embeddingService.embedding(null, null, build); System.out.println(embedding); List> vectors = embedding.getData().stream().map(EmbeddingObject::getEmbedding).collect(Collectors.toList()); // 存储转存 VectorDataEntity vectorDataEntity = new VectorDataEntity(); vectorDataEntity.setVector(vectors); vectorDataEntity.setContent(strings); /** * { * id * [0.1, 0.1] * * } * * */ // 封装请求类 int count = vectors.size(); List pineconeVectors = new ArrayList<>(); List ids = generateIDs(count); // 生成每个向量的id List> contents = generateContent(strings); // 生成每个向量对应的文本,元数据,kv for (int i = 0; i < count; ++i) { pineconeVectors.add(new PineconeVectors(ids.get(i), vectors.get(i), contents.get(i))); } PineconeInsert pineconeInsert = new PineconeInsert(pineconeVectors, "userId"); // 执行插入 //String res = PineconeUtil.insertEmbedding(pineconeInsert, "aa"); //log.info("插入结果{}" ,res); } @Test public void test__query() throws Exception { // Given the following conversation and a follow up question, rephrase the follow up question to be a standalone English question.\nChat History is below:\n%s\nFollow Up Input: \n%s\nStandalone English question: // 聊天历史(role:content) , 新消息 // String aaa // 构建要查询的问题,转为向量 Embedding build = Embedding.builder() .input("aaaaa") .model("text-embedding-3-small") .build(); EmbeddingResponse embedding = embeddingService.embedding(null, null, build); List question = embedding.getData().get(0).getEmbedding(); // 构建向量数据库的查询对象 PineconeQuery pineconeQueryReq = PineconeQuery.builder() .namespace("") .topK(20) .includeMetadata(true) .vector(question) .build(); // 执行查询 // PineconeQueryResponse response = PineconeUtil.queryEmbedding(pineconeQueryReq, "aa"); // 从向量数据库拿出的数据, 拼接为一个String //String collect = response.getMatches().stream().map(match -> match.getMetadata().get("content")).collect(Collectors.joining(" ")); // "You are an AI assistant providing helpful advice. You are given the following extracted parts of a long document and a part of the chat history, along with a current question. Provide a conversational answer based on the context and the chat histories provided (You can refer to the chat history to know what the user has asked and thus better answer the current question, but you are not allowed to reply to the previous question asked by the user again). If you can\'t find the answer in the context below, just say \"Hmm, I\'m not sure.\" Don\'t try to make up an answer. If the question is not related to the context, politely respond that you are tuned to only answer questions that are related to the context. \nContext information is below:\n=========\n%s\n=========\nChat history is below:\n=========\n%s\n=========\nCurrent Question: %s (Note: Remember, you only need to reply to me in Chinese and try to increase the content of the reply as much as possible to improve the user experience. I believe you can definitely)" // 上下文, 历史,原始消息 // 发送chat请求进行对话 } @Test public void test__delet() { PineconeDelete request = PineconeDelete.builder() .deleteAll(true) .namespace("userId") .build(); // String res = String.valueOf(PineconeUtil.deleteEmbedding(request, "aa")); // System.out.println(res); } @Test public void test__tika() throws TikaException, IOException, SAXException { File file = new File("C:\\Users\\1\\Desktop\\新建文本文档 (2).txt"); InputStream inputStream = new FileInputStream(file); String s = TikaUtil.parseInputStream(inputStream); System.out.println("文本内容"); System.out.println(s); String s1 = TikaUtil.detectMimeType(file); System.out.println(s1); //AbstractListener abstractListener = new AbstractListener(); } // 生成每个向量的id private List generateIDs(int count) { List ids = new ArrayList<>(); for (long i = 0L; i < count; ++i) { ids.add("id_" + i); } return ids; } // 生成每个向量对应的文本 private List> generateContent(List contents) { List> finalcontents = new ArrayList<>(); for (int i = 0; i < contents.size(); i++) { HashMap map = new HashMap<>(); map.put("content", contents.get(i)); finalcontents.add(map); } return finalcontents; } @FunctionCall(name = "test_push_files", description = "Test push multiple files to repository") public static class TestPushFilesFunction implements java.util.function.Function { @lombok.Data @FunctionRequest public static class Request { @FunctionParameter(description = "List of files to push") private java.util.List files; @FunctionParameter(description = "Commit message") private String message; } @Override public String apply(Request request) { return "Files pushed: " + request.files.size(); } } @org.junit.Test public void testArraySchemaGeneration() { System.out.println("=== 测试数组Schema生成 ==="); try { // 测试传统Function工具 Tool.Function function = ToolUtil.getFunctionEntity("test_push_files"); if (function != null) { System.out.println("传统Function工具生成成功:"); System.out.println("名称: " + function.getName()); System.out.println("描述: " + function.getDescription()); java.util.Map properties = function.getParameters().getProperties(); Tool.Function.Property filesProperty = properties.get("files"); if (filesProperty != null) { System.out.println("files属性类型: " + filesProperty.getType()); if (filesProperty.getItems() != null) { System.out.println("files.items类型: " + filesProperty.getItems().getType()); System.out.println("✅ 数组Schema包含items定义 - 修复成功!"); } else { System.out.println("❌ 数组Schema缺少items定义 - 修复失败!"); } } else { System.out.println("❌ 未找到files属性"); } } else { System.out.println("❌ 传统Function工具生成失败"); } System.out.println("=== 测试完成 ==="); } catch (Exception e) { System.err.println("测试失败: " + e.getMessage()); e.printStackTrace(); } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/OtherTest.java ================================================ package io.github.lnyocly; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import lombok.extern.slf4j.Slf4j; import org.junit.Test; import java.time.Instant; import java.util.List; import java.util.UUID; /** * @Author cly * @Description TODO */ @Slf4j public class OtherTest { @Test public void test(){ long currentTimeMillis = System.currentTimeMillis(); String isoDateTime = "2024-07-22T20:33:28.123648Z"; Instant instant = Instant.parse(isoDateTime); long epochSeconds = instant.getEpochSecond(); System.out.println("Epoch seconds: " + epochSeconds); System.out.println(System.currentTimeMillis() - currentTimeMillis); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ZhipuTest.java ================================================ package io.github.lnyocly; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.exception.chain.ErrorHandler; import io.github.lnyocly.ai4j.exception.error.Error; import io.github.lnyocly.ai4j.exception.error.OpenAiError; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.apache.commons.lang3.ObjectUtils; import org.junit.Before; import org.junit.Test; import org.reflections.Reflections; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * @Author cly * @Description 智谱测试类 * @Date 2024/8/3 18:22 */ @Slf4j public class ZhipuTest { private IChatService chatService; @Before public void test_init() throws NoSuchAlgorithmException, KeyManagementException { ZhipuConfig zhipuConfig = new ZhipuConfig(); zhipuConfig.setApiKey("sk-123456789"); Configuration configuration = new Configuration(); configuration.setZhipuConfig(zhipuConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10809))) .build(); configuration.setOkHttpClient(okHttpClient); AiService aiService = new AiService(configuration); chatService = aiService.getChatService(PlatformType.ZHIPU); } @Test public void test_chatCompletions_common() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_multimodal() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-vision") .message(ChatMessage.withUser("这几张图片,分别有什么动物, 并且是什么品种", "https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7", "https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); } @Test public void test_chatCompletions_stream() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("鲁迅为什么打周树人")) .build(); System.out.println("请求参数"); System.out.println(chatCompletion); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("请求成功"); System.out.println(sseListener.getOutput()); System.out.println(sseListener.getUsage()); } @Test public void test_chatCompletions_function() throws Exception { ChatCompletion chatCompletion = ChatCompletion.builder() .model("gpt-4o-mini") .message(ChatMessage.withUser("查询洛阳明天的天气,并告诉我火车是否发车")) .functions("queryWeather", "queryTrainInfo") .build(); System.out.println("请求参数"); System.out.println(chatCompletion); ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion); System.out.println("请求成功"); System.out.println(chatCompletionResponse); System.out.println(chatCompletion); } @Test public void test_chatCompletions_stream_function() throws Exception { // 构造请求参数 ChatCompletion chatCompletion = ChatCompletion.builder() .model("yi-large-fc") .message(ChatMessage.withUser("查询洛阳明天的天气")) .functions("queryWeather", "queryTrainInfo") .build(); // 构造监听器 SseListener sseListener = new SseListener() { @Override protected void send() { System.out.println(this.getCurrStr()); } }; // 显示函数参数,默认不显示 sseListener.setShowToolArgs(true); // 发送SSE请求 chatService.chatCompletionStream(chatCompletion, sseListener); System.out.println("完整内容: "); System.out.println(sseListener.getOutput()); System.out.println("内容花费: "); System.out.println(sseListener.getUsage()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/AgentFlowTraceSupportTest.java ================================================ package io.github.lnyocly.ai4j.agentflow; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse; import io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.OkHttpClient; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.List; public class AgentFlowTraceSupportTest { @Test public void test_trace_listener_receives_lifecycle_events() { RecordingTraceListener listener = new RecordingTraceListener(); TestAgentFlowSupport support = new TestAgentFlowSupport(config(listener)); AgentFlowChatRequest request = AgentFlowChatRequest.builder() .prompt("plan a trip") .build(); AgentFlowChatEvent event = AgentFlowChatEvent.builder() .type("message") .contentDelta("hello") .build(); AgentFlowChatResponse response = AgentFlowChatResponse.builder() .content("hello world") .taskId("task-1") .build(); AgentFlowTraceContext context = support.begin("chat", true, request); support.emitEvent(context, event); support.finish(context, response); Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.contexts.size())); Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.events.size())); Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.responses.size())); Assert.assertEquals(AgentFlowType.DIFY, listener.contexts.get(0).getType()); Assert.assertEquals("chat", listener.contexts.get(0).getOperation()); Assert.assertTrue(listener.contexts.get(0).isStreaming()); Assert.assertEquals("task-1", listener.responses.get(0).getTaskId()); } @Test public void test_trace_listener_receives_error_and_does_not_break_call_path() { RecordingTraceListener listener = new RecordingTraceListener(); ThrowingTraceListener throwingListener = new ThrowingTraceListener(); TestAgentFlowSupport support = new TestAgentFlowSupport(config(listener, throwingListener)); AgentFlowTraceContext context = support.begin("workflow", false, null); RuntimeException failure = new RuntimeException("boom"); support.fail(context, failure); Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.errors.size())); Assert.assertEquals("boom", listener.errors.get(0).getMessage()); } private AgentFlowConfig config(AgentFlowTraceListener... listeners) { List values = new ArrayList(); if (listeners != null) { for (AgentFlowTraceListener listener : listeners) { values.add(listener); } } return AgentFlowConfig.builder() .type(AgentFlowType.DIFY) .baseUrl("http://localhost:8080") .traceListeners(values) .build(); } private static final class TestAgentFlowSupport extends AgentFlowSupport { private TestAgentFlowSupport(AgentFlowConfig config) { super(configuration(), config); } private AgentFlowTraceContext begin(String operation, boolean streaming, Object request) { return startTrace(operation, streaming, request); } private void emitEvent(AgentFlowTraceContext context, Object event) { traceEvent(context, event); } private void finish(AgentFlowTraceContext context, Object response) { traceComplete(context, response); } private void fail(AgentFlowTraceContext context, Throwable throwable) { traceError(context, throwable); } private static Configuration configuration() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); return configuration; } } private static final class RecordingTraceListener implements AgentFlowTraceListener { private final List contexts = new ArrayList(); private final List events = new ArrayList(); private final List responses = new ArrayList(); private final List errors = new ArrayList(); @Override public void onStart(AgentFlowTraceContext context) { contexts.add(context); } @Override public void onEvent(AgentFlowTraceContext context, Object event) { if (event instanceof AgentFlowChatEvent) { events.add((AgentFlowChatEvent) event); } } @Override public void onComplete(AgentFlowTraceContext context, Object response) { if (response instanceof AgentFlowChatResponse) { responses.add((AgentFlowChatResponse) response); } } @Override public void onError(AgentFlowTraceContext context, Throwable throwable) { errors.add(throwable); } } private static final class ThrowingTraceListener implements AgentFlowTraceListener { @Override public void onStart(AgentFlowTraceContext context) { throw new IllegalStateException("ignored"); } @Override public void onEvent(AgentFlowTraceContext context, Object event) { throw new IllegalStateException("ignored"); } @Override public void onComplete(AgentFlowTraceContext context, Object response) { throw new IllegalStateException("ignored"); } @Override public void onError(AgentFlowTraceContext context, Throwable throwable) { throw new IllegalStateException("ignored"); } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/CozeAgentFlowServiceTest.java ================================================ package io.github.lnyocly.ai4j.agentflow; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatListener; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse; import io.github.lnyocly.ai4j.agentflow.chat.CozeAgentFlowChatService; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowEvent; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowListener; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse; import io.github.lnyocly.ai4j.agentflow.workflow.CozeAgentFlowWorkflowService; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.OkHttpClient; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; public class CozeAgentFlowServiceTest { @Test public void test_coze_chat_blocking() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(jsonResponse("{\"code\":0,\"msg\":\"success\",\"data\":{\"id\":\"chat-1\",\"conversation_id\":\"conv-1\",\"status\":\"created\"}}")); server.enqueue(jsonResponse("{\"code\":0,\"msg\":\"success\",\"data\":{\"id\":\"chat-1\",\"conversation_id\":\"conv-1\",\"status\":\"completed\",\"usage\":{\"token_count\":9,\"input_tokens\":4,\"output_tokens\":5}}}")); server.enqueue(jsonResponse("{\"code\":0,\"msg\":\"success\",\"data\":[{\"id\":\"msg-user\",\"role\":\"user\",\"content\":\"hello\"},{\"id\":\"msg-1\",\"role\":\"assistant\",\"content\":\"Travel plan ready\"}]}")); server.start(); try { CozeAgentFlowChatService service = new CozeAgentFlowChatService(configuration(), cozeConfig(server)); AgentFlowChatResponse response = service.chat(AgentFlowChatRequest.builder().prompt("hello").conversationId("conv-1").build()); Assert.assertEquals("Travel plan ready", response.getContent()); Assert.assertEquals("conv-1", response.getConversationId()); Assert.assertEquals("chat-1", response.getTaskId()); Assert.assertEquals(Integer.valueOf(9), response.getUsage().getTotalTokens()); RecordedRequest createRequest = server.takeRequest(1, TimeUnit.SECONDS); RecordedRequest retrieveRequest = server.takeRequest(1, TimeUnit.SECONDS); RecordedRequest listRequest = server.takeRequest(1, TimeUnit.SECONDS); Assert.assertTrue(createRequest.getPath().startsWith("/v3/chat")); Assert.assertTrue(retrieveRequest.getPath().startsWith("/v3/chat/retrieve")); Assert.assertTrue(listRequest.getPath().startsWith("/v1/conversation/message/list")); } finally { server.shutdown(); } } @Test public void test_coze_chat_streaming() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(sseResponse( "event: conversation.chat.created\n" + "data: {\"id\":\"chat-1\",\"conversation_id\":\"conv-1\",\"status\":\"created\"}\n\n" + "event: conversation.message.delta\n" + "data: {\"id\":\"msg-1\",\"conversation_id\":\"conv-1\",\"chat_id\":\"chat-1\",\"role\":\"assistant\",\"type\":\"answer\",\"content\":\"Travel \",\"content_type\":\"text\"}\n\n" + "event: conversation.message.delta\n" + "data: {\"id\":\"msg-1\",\"conversation_id\":\"conv-1\",\"chat_id\":\"chat-1\",\"role\":\"assistant\",\"type\":\"answer\",\"content\":\"ready\",\"content_type\":\"text\"}\n\n" + "event: conversation.chat.completed\n" + "data: {\"id\":\"chat-1\",\"conversation_id\":\"conv-1\",\"status\":\"completed\",\"usage\":{\"token_count\":7,\"input_tokens\":3,\"output_tokens\":4}}\n\n" + "event: done\n" + "data: [DONE]\n\n" )); server.start(); try { CozeAgentFlowChatService service = new CozeAgentFlowChatService(configuration(), cozeConfig(server)); final List events = new ArrayList(); final AtomicReference completion = new AtomicReference(); service.chatStream(AgentFlowChatRequest.builder().prompt("hello").build(), new AgentFlowChatListener() { @Override public void onEvent(AgentFlowChatEvent event) { events.add(event); } @Override public void onComplete(AgentFlowChatResponse response) { completion.set(response); } }); Assert.assertEquals("Travel ready", completion.get().getContent()); Assert.assertEquals("chat-1", completion.get().getTaskId()); Assert.assertEquals(Integer.valueOf(7), completion.get().getUsage().getTotalTokens()); Assert.assertTrue(events.size() >= 4); } finally { server.shutdown(); } } @Test public void test_coze_workflow_blocking() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(jsonResponse("{\"code\":0,\"msg\":\"success\",\"execute_id\":\"exec-1\",\"data\":\"{\\\"answer\\\":\\\"Workflow ready\\\",\\\"city\\\":\\\"Paris\\\"}\",\"usage\":{\"token_count\":6,\"input_tokens\":2,\"output_tokens\":4}}")); server.start(); try { CozeAgentFlowWorkflowService service = new CozeAgentFlowWorkflowService(configuration(), cozeConfig(server)); AgentFlowWorkflowResponse response = service.run(AgentFlowWorkflowRequest.builder().workflowId("wf-1").build()); Assert.assertEquals("Workflow ready", response.getOutputText()); Assert.assertEquals("exec-1", response.getWorkflowRunId()); Assert.assertEquals("Paris", response.getOutputs().get("city")); RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); Assert.assertEquals("/v1/workflow/run", request.getPath()); JSONObject body = JSON.parseObject(request.getBody().readUtf8()); Assert.assertEquals("wf-1", body.getString("workflow_id")); } finally { server.shutdown(); } } @Test public void test_coze_workflow_streaming() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(sseResponse( "event: Message\n" + "data: {\"content\":\"Workflow \",\"usage\":{\"token_count\":3,\"input_tokens\":1,\"output_tokens\":2}}\n\n" + "event: Message\n" + "data: {\"content\":\"ready\"}\n\n" + "event: Done\n" + "data: {}\n\n" )); server.start(); try { CozeAgentFlowWorkflowService service = new CozeAgentFlowWorkflowService(configuration(), cozeConfig(server)); final List events = new ArrayList(); final AtomicReference completion = new AtomicReference(); service.runStream(AgentFlowWorkflowRequest.builder().workflowId("wf-1").build(), new AgentFlowWorkflowListener() { @Override public void onEvent(AgentFlowWorkflowEvent event) { events.add(event); } @Override public void onComplete(AgentFlowWorkflowResponse response) { completion.set(response); } }); Assert.assertEquals("Workflow ready", completion.get().getOutputText()); Assert.assertEquals(Integer.valueOf(3), completion.get().getUsage().getTotalTokens()); Assert.assertTrue(events.get(events.size() - 1).isDone()); } finally { server.shutdown(); } } private Configuration configuration() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) .build()); return configuration; } private AgentFlowConfig cozeConfig(MockWebServer server) { return AgentFlowConfig.builder() .type(AgentFlowType.COZE) .baseUrl(server.url("/").toString()) .apiKey("test-key") .botId("bot-1") .pollIntervalMillis(1L) .pollTimeoutMillis(3_000L) .build(); } private MockResponse jsonResponse(String body) { return new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") .setBody(body); } private MockResponse sseResponse(String body) { return new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "text/event-stream") .setBody(body); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/DifyAgentFlowServiceTest.java ================================================ package io.github.lnyocly.ai4j.agentflow; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatListener; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse; import io.github.lnyocly.ai4j.agentflow.chat.DifyAgentFlowChatService; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowEvent; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowListener; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse; import io.github.lnyocly.ai4j.agentflow.workflow.DifyAgentFlowWorkflowService; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.OkHttpClient; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; public class DifyAgentFlowServiceTest { @Test public void test_dify_chat_blocking() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(jsonResponse("{\"answer\":\"Hello from Dify\",\"conversation_id\":\"conv-1\",\"message_id\":\"msg-1\",\"task_id\":\"task-1\",\"metadata\":{\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":5,\"total_tokens\":8}}}")); server.start(); try { DifyAgentFlowChatService service = new DifyAgentFlowChatService(configuration(), difyConfig(server)); AgentFlowChatResponse response = service.chat(AgentFlowChatRequest.builder() .prompt("hello") .conversationId("conv-1") .build()); Assert.assertEquals("Hello from Dify", response.getContent()); Assert.assertEquals("conv-1", response.getConversationId()); Assert.assertEquals(Integer.valueOf(8), response.getUsage().getTotalTokens()); RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); Assert.assertNotNull(recordedRequest); Assert.assertEquals("/v1/chat-messages", recordedRequest.getPath()); JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8()); Assert.assertEquals("hello", body.getString("query")); Assert.assertEquals("blocking", body.getString("response_mode")); } finally { server.shutdown(); } } @Test public void test_dify_chat_streaming() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(sseResponse( "event: message\n" + "data: {\"event\":\"message\",\"answer\":\"Hel\",\"conversation_id\":\"conv-1\",\"message_id\":\"msg-1\",\"task_id\":\"task-1\"}\n\n" + "event: message\n" + "data: {\"event\":\"message\",\"answer\":\"lo\"}\n\n" + "event: message_end\n" + "data: {\"event\":\"message_end\",\"conversation_id\":\"conv-1\",\"message_id\":\"msg-1\",\"task_id\":\"task-1\",\"metadata\":{\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}}\n\n" )); server.start(); try { DifyAgentFlowChatService service = new DifyAgentFlowChatService(configuration(), difyConfig(server)); final List events = new ArrayList(); final AtomicReference completion = new AtomicReference(); service.chatStream(AgentFlowChatRequest.builder().prompt("hello").build(), new AgentFlowChatListener() { @Override public void onEvent(AgentFlowChatEvent event) { events.add(event); } @Override public void onComplete(AgentFlowChatResponse response) { completion.set(response); } }); Assert.assertEquals(3, events.size()); Assert.assertEquals("Hel", events.get(0).getContentDelta()); Assert.assertEquals("Hello", completion.get().getContent()); Assert.assertEquals(Integer.valueOf(3), completion.get().getUsage().getTotalTokens()); RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8()); Assert.assertEquals("streaming", body.getString("response_mode")); } finally { server.shutdown(); } } @Test public void test_dify_workflow_blocking() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(jsonResponse("{\"task_id\":\"task-1\",\"workflow_run_id\":\"run-1\",\"data\":{\"status\":\"succeeded\",\"outputs\":{\"answer\":\"Plan ready\",\"city\":\"Paris\"},\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}}")); server.start(); try { DifyAgentFlowWorkflowService service = new DifyAgentFlowWorkflowService(configuration(), difyConfig(server)); AgentFlowWorkflowResponse response = service.run(AgentFlowWorkflowRequest.builder().build()); Assert.assertEquals("succeeded", response.getStatus()); Assert.assertEquals("Plan ready", response.getOutputText()); Assert.assertEquals("run-1", response.getWorkflowRunId()); Assert.assertEquals("Paris", response.getOutputs().get("city")); RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); Assert.assertEquals("/v1/workflows/run", recordedRequest.getPath()); JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8()); Assert.assertEquals("blocking", body.getString("response_mode")); } finally { server.shutdown(); } } @Test public void test_dify_workflow_streaming() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(sseResponse( "event: workflow_started\n" + "data: {\"event\":\"workflow_started\",\"task_id\":\"task-1\",\"workflow_run_id\":\"run-1\"}\n\n" + "event: workflow_finished\n" + "data: {\"event\":\"workflow_finished\",\"task_id\":\"task-1\",\"workflow_run_id\":\"run-1\",\"data\":{\"status\":\"succeeded\",\"outputs\":{\"answer\":\"Plan ready\",\"city\":\"Paris\"},\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}}\n\n" )); server.start(); try { DifyAgentFlowWorkflowService service = new DifyAgentFlowWorkflowService(configuration(), difyConfig(server)); final List events = new ArrayList(); final AtomicReference completion = new AtomicReference(); service.runStream(AgentFlowWorkflowRequest.builder().build(), new AgentFlowWorkflowListener() { @Override public void onEvent(AgentFlowWorkflowEvent event) { events.add(event); } @Override public void onComplete(AgentFlowWorkflowResponse response) { completion.set(response); } }); Assert.assertEquals(2, events.size()); Assert.assertTrue(events.get(1).isDone()); Assert.assertEquals("Plan ready", completion.get().getOutputText()); Assert.assertEquals("Paris", completion.get().getOutputs().get("city")); RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8()); Assert.assertEquals("streaming", body.getString("response_mode")); } finally { server.shutdown(); } } private Configuration configuration() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) .build()); return configuration; } private AgentFlowConfig difyConfig(MockWebServer server) { return AgentFlowConfig.builder() .type(AgentFlowType.DIFY) .baseUrl(server.url("/").toString()) .apiKey("test-key") .pollTimeoutMillis(3_000L) .build(); } private MockResponse jsonResponse(String body) { return new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") .setBody(body); } private MockResponse sseResponse(String body) { return new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "text/event-stream") .setBody(body); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/N8nAgentFlowWorkflowServiceTest.java ================================================ package io.github.lnyocly.ai4j.agentflow; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse; import io.github.lnyocly.ai4j.agentflow.workflow.N8nAgentFlowWorkflowService; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.factory.AiService; import okhttp3.OkHttpClient; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.Assert; import org.junit.Test; import java.util.concurrent.TimeUnit; public class N8nAgentFlowWorkflowServiceTest { @Test public void test_n8n_workflow_blocking() throws Exception { MockWebServer server = new MockWebServer(); server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") .setBody("{\"ok\":true,\"result\":\"Booked\",\"city\":\"Paris\"}")); server.start(); try { N8nAgentFlowWorkflowService service = new N8nAgentFlowWorkflowService(configuration(), AgentFlowConfig.builder() .type(AgentFlowType.N8N) .webhookUrl(server.url("/travel-hook").toString()) .build()); AgentFlowWorkflowResponse response = service.run(AgentFlowWorkflowRequest.builder() .inputs(java.util.Collections.singletonMap("city", "Paris")) .build()); Assert.assertEquals("Booked", response.getOutputText()); Assert.assertEquals(Boolean.TRUE, response.getOutputs().get("ok")); Assert.assertEquals("Paris", response.getOutputs().get("city")); RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); Assert.assertEquals("/travel-hook", request.getPath()); JSONObject body = JSON.parseObject(request.getBody().readUtf8()); Assert.assertEquals("Paris", body.getString("city")); } finally { server.shutdown(); } } @Test public void test_ai_service_exposes_agent_flow() { Configuration configuration = configuration(); AiService aiService = new AiService(configuration); AgentFlow agentFlow = aiService.getAgentFlow(AgentFlowConfig.builder() .type(AgentFlowType.N8N) .webhookUrl("https://example.com/hook") .build()); Assert.assertNotNull(agentFlow); Assert.assertEquals(AgentFlowType.N8N, agentFlow.getConfig().getType()); Assert.assertNotNull(agentFlow.workflow()); } private Configuration configuration() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) .build()); return configuration; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/function/TestFunction.java ================================================ package io.github.lnyocly.ai4j.function; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; /** * 测试用的传统Function工具 */ @FunctionCall(name = "weather", description = "获取指定城市的天气信息") public class TestFunction { public WeatherResponse apply(WeatherRequest request) { WeatherResponse response = new WeatherResponse(); response.city = request.city; response.temperature = 25; // 模拟温度 response.condition = "晴天"; response.humidity = 60; response.timestamp = System.currentTimeMillis(); return response; } @FunctionRequest public static class WeatherRequest { @FunctionParameter(description = "城市名称", required = true) public String city; @FunctionParameter(description = "温度单位", required = false) public String unit = "celsius"; } public static class WeatherResponse { public String city; public int temperature; public String condition; public int humidity; public long timestamp; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpClientResponseSupportTest.java ================================================ package io.github.lnyocly.ai4j.mcp; import io.github.lnyocly.ai4j.mcp.client.McpClientResponseSupport; import io.github.lnyocly.ai4j.mcp.client.McpClient; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.entity.McpPrompt; import io.github.lnyocly.ai4j.mcp.entity.McpPromptResult; import io.github.lnyocly.ai4j.mcp.entity.McpRequest; import io.github.lnyocly.ai4j.mcp.entity.McpResource; import io.github.lnyocly.ai4j.mcp.entity.McpResourceContent; import io.github.lnyocly.ai4j.mcp.entity.McpResponse; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.transport.McpTransport; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; public class McpClientResponseSupportTest { @Test public void shouldParseToolsListResponse() { Map inputSchema = new HashMap(); inputSchema.put("type", "object"); Map tool = new HashMap(); tool.put("name", "queryWeather"); tool.put("description", "Query current weather"); tool.put("inputSchema", inputSchema); Map result = new HashMap(); result.put("tools", Collections.singletonList(tool)); List tools = McpClientResponseSupport.parseToolsListResponse(result); Assert.assertEquals(1, tools.size()); Assert.assertEquals("queryWeather", tools.get(0).getName()); Assert.assertEquals("Query current weather", tools.get(0).getDescription()); Assert.assertEquals("object", tools.get(0).getInputSchema().get("type")); } @Test public void shouldParseToolCallTextResponse() { Map part1 = new HashMap(); part1.put("type", "text"); part1.put("text", "Hello "); Map part2 = new HashMap(); part2.put("type", "text"); part2.put("text", "World"); Map result = new HashMap(); result.put("content", Arrays.asList(part1, part2)); String text = McpClientResponseSupport.parseToolCallResponse(result); Assert.assertEquals("Hello World", text); } @Test public void shouldParseResourcesListResponse() { Map resource = new HashMap(); resource.put("uri", "file://docs/readme.md"); resource.put("name", "README"); resource.put("description", "Project readme"); resource.put("mimeType", "text/markdown"); resource.put("size", 128L); Map result = new HashMap(); result.put("resources", Collections.singletonList(resource)); List resources = McpClientResponseSupport.parseResourcesListResponse(result); Assert.assertEquals(1, resources.size()); Assert.assertEquals("file://docs/readme.md", resources.get(0).getUri()); Assert.assertEquals("README", resources.get(0).getName()); Assert.assertEquals("text/markdown", resources.get(0).getMimeType()); Assert.assertEquals(Long.valueOf(128L), resources.get(0).getSize()); } @Test public void shouldParseResourceReadResponse() { Map content = new HashMap(); content.put("uri", "file://docs/readme.md"); content.put("mimeType", "text/markdown"); content.put("text", "# Hello"); Map result = new HashMap(); result.put("contents", Collections.singletonList(content)); McpResourceContent resourceContent = McpClientResponseSupport.parseResourceReadResponse(result); Assert.assertNotNull(resourceContent); Assert.assertEquals("file://docs/readme.md", resourceContent.getUri()); Assert.assertEquals("text/markdown", resourceContent.getMimeType()); Assert.assertEquals("# Hello", resourceContent.getContents()); } @Test public void shouldParsePromptsListResponse() { Map arguments = new HashMap(); arguments.put("city", "string"); Map prompt = new HashMap(); prompt.put("name", "weather_prompt"); prompt.put("description", "Build weather prompt"); prompt.put("arguments", arguments); Map result = new HashMap(); result.put("prompts", Collections.singletonList(prompt)); List prompts = McpClientResponseSupport.parsePromptsListResponse(result); Assert.assertEquals(1, prompts.size()); Assert.assertEquals("weather_prompt", prompts.get(0).getName()); Assert.assertEquals("Build weather prompt", prompts.get(0).getDescription()); Assert.assertEquals("string", prompts.get(0).getArguments().get("city")); } @Test public void shouldParsePromptGetResponse() { Map content = new HashMap(); content.put("type", "text"); content.put("text", "Weather for Luoyang"); Map message = new HashMap(); message.put("role", "user"); message.put("content", content); Map result = new HashMap(); result.put("description", "Weather prompt"); result.put("messages", Collections.singletonList(message)); McpPromptResult promptResult = McpClientResponseSupport.parsePromptGetResponse("weather_prompt", result); Assert.assertNotNull(promptResult); Assert.assertEquals("weather_prompt", promptResult.getName()); Assert.assertEquals("Weather prompt", promptResult.getDescription()); Assert.assertEquals("Weather for Luoyang", promptResult.getContent()); } @Test public void shouldSupportResourceAndPromptApisThroughClient() { Map resourcesListResult = new HashMap(); resourcesListResult.put("resources", Collections.singletonList(mapOf( "uri", "file://docs/readme.md", "name", "README", "description", "Project readme", "mimeType", "text/markdown", "size", 128L ))); Map resourceReadResult = new HashMap(); resourceReadResult.put("contents", Collections.singletonList(mapOf( "uri", "file://docs/readme.md", "mimeType", "text/markdown", "text", "# Hello" ))); Map promptsListResult = new HashMap(); promptsListResult.put("prompts", Collections.singletonList(mapOf( "name", "weather_prompt", "description", "Build weather prompt", "arguments", mapOf("city", "string") ))); Map promptGetResult = new HashMap(); promptGetResult.put("description", "Weather prompt"); promptGetResult.put("messages", Collections.singletonList(mapOf( "role", "user", "content", mapOf("type", "text", "text", "Weather for Luoyang") ))); FakeTransport transport = new FakeTransport() .respond("resources/list", resourcesListResult) .respond("resources/read", resourceReadResult) .respond("prompts/list", promptsListResult) .respond("prompts/get", promptGetResult); McpClient client = new McpClient("test", "1.0.0", transport, false); List resources = client.getAvailableResources().join(); McpResourceContent resourceContent = client.readResource("file://docs/readme.md").join(); List prompts = client.getAvailablePrompts().join(); McpPromptResult promptResult = client.getPrompt("weather_prompt", Collections.singletonMap("city", "Luoyang")).join(); Assert.assertEquals(1, resources.size()); Assert.assertEquals("README", resources.get(0).getName()); Assert.assertEquals("# Hello", resourceContent.getContents()); Assert.assertEquals(1, prompts.size()); Assert.assertEquals("weather_prompt", prompts.get(0).getName()); Assert.assertEquals("Weather for Luoyang", promptResult.getContent()); } private static Map mapOf(Object... values) { Map map = new LinkedHashMap(); for (int i = 0; i + 1 < values.length; i += 2) { map.put(String.valueOf(values[i]), values[i + 1]); } return map; } private static final class FakeTransport implements McpTransport { private final Map responses = new HashMap(); private McpMessageHandler handler; private FakeTransport respond(String method, Object result) { responses.put(method, result); return this; } @Override public CompletableFuture start() { return CompletableFuture.completedFuture(null); } @Override public CompletableFuture stop() { return CompletableFuture.completedFuture(null); } @Override public CompletableFuture sendMessage(McpMessage message) { if (message instanceof McpRequest && handler != null) { Object result = responses.get(message.getMethod()); handler.handleMessage(new McpResponse(message.getId(), result)); } return CompletableFuture.completedFuture(null); } @Override public void setMessageHandler(McpMessageHandler handler) { this.handler = handler; } @Override public boolean isConnected() { return true; } @Override public boolean needsHeartbeat() { return false; } @Override public String getTransportType() { return "test"; } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpGatewaySupportTest.java ================================================ package io.github.lnyocly.ai4j.mcp; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.gateway.McpGatewayKeySupport; import io.github.lnyocly.ai4j.mcp.util.McpToolConversionSupport; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; public class McpGatewaySupportTest { @Test public void shouldBuildAndParseUserGatewayKeys() { String clientKey = McpGatewayKeySupport.buildUserClientKey("123", "github"); String toolKey = McpGatewayKeySupport.buildUserToolKey("123", "search_repositories"); Assert.assertEquals("user_123_service_github", clientKey); Assert.assertEquals("user_123_tool_search_repositories", toolKey); Assert.assertTrue(McpGatewayKeySupport.isUserClientKey(clientKey)); Assert.assertEquals("123", McpGatewayKeySupport.extractUserIdFromClientKey(clientKey)); } @Test public void shouldConvertMcpToolDefinitionToOpenAiFunction() { Map arrayItems = new HashMap(); arrayItems.put("type", "string"); arrayItems.put("description", "tag"); Map tagsProperty = new HashMap(); tagsProperty.put("type", "array"); tagsProperty.put("description", "Tags"); tagsProperty.put("items", arrayItems); Map statusProperty = new HashMap(); statusProperty.put("type", "string"); statusProperty.put("description", "Issue status"); statusProperty.put("enum", Arrays.asList("open", "closed")); Map properties = new HashMap(); properties.put("status", statusProperty); properties.put("tags", tagsProperty); Map inputSchema = new HashMap(); inputSchema.put("properties", properties); inputSchema.put("required", Collections.singletonList("status")); McpToolDefinition definition = McpToolDefinition.builder() .name("create_issue") .description("Create issue") .inputSchema(inputSchema) .build(); Tool.Function function = McpToolConversionSupport.convertToOpenAiTool(definition); Assert.assertEquals("create_issue", function.getName()); Assert.assertEquals("Create issue", function.getDescription()); Assert.assertEquals("object", function.getParameters().getType()); Assert.assertEquals(Collections.singletonList("status"), function.getParameters().getRequired()); Assert.assertEquals(Arrays.asList("open", "closed"), function.getParameters().getProperties().get("status").getEnumValues()); Assert.assertEquals("string", function.getParameters().getProperties().get("tags").getItems().getType()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpServerTest.java ================================================ package io.github.lnyocly.ai4j.mcp; import io.github.lnyocly.ai4j.mcp.server.McpServer; import io.github.lnyocly.ai4j.mcp.server.McpServerFactory; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.tool.ToolUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; /** * MCP服务器测试类 * 测试创建MCP服务器并提供工具服务 */ public class McpServerTest { private static final Logger log = LoggerFactory.getLogger(McpServerTest.class); public static void main(String[] args) { log.info("开始MCP服务器测试..."); try { // 测试1: 创建并启动SSE MCP服务器 //testSseMcpServer(); // 测试2: 创建并启动Stdio MCP服务器 testStdioMcpServer(); // 测试3: 创建并启动Streamable HTTP MCP服务器 //testStreamableHttpMcpServer(); log.info("所有MCP服务器测试完成!"); } catch (Exception e) { log.error("MCP服务器测试失败", e); } } /** * 测试SSE MCP服务器 */ private static void testSseMcpServer() { log.info("=== 测试1: SSE MCP服务器 ==="); try { // 创建SSE服务器 McpServer server = McpServerFactory.createServer("sse", "name", "1.0.0", 5000); log.info("创建SSE MCP服务器: {}", server.getServerInfo()); // 启动服务器 CompletableFuture startFuture = server.start(); startFuture.get(5, TimeUnit.SECONDS); if (server.isRunning()) { log.info("SSE MCP服务器启动成功,端口: 8080"); log.info("SSE端点: http://localhost:8080/sse"); log.info("消息端点: http://localhost:8080/message"); // 显示可用工具 showAvailableTools(); // 运行一段时间后停止 Thread.sleep(2000); // 停止服务器 CompletableFuture stopFuture = server.stop(); stopFuture.get(5, TimeUnit.SECONDS); log.info("SSE MCP服务器已停止"); } else { log.error("SSE MCP服务器启动失败"); } } catch (Exception e) { log.error("SSE MCP服务器测试失败", e); } } /** * 测试Stdio MCP服务器 */ private static void testStdioMcpServer() { log.info("=== 测试2: Stdio MCP服务器 ==="); try { // 创建Stdio服务器 McpServer server = McpServerFactory.createServer("stdio", "name", "1.0.0"); log.info("创建Stdio MCP服务器: {}", server.getServerInfo()); // 注意:Stdio服务器通常在后台运行,这里只是演示创建过程 log.info("Stdio MCP服务器创建成功,可以通过标准输入输出进行通信"); // 显示可用工具 showAvailableTools(); } catch (Exception e) { log.error("Stdio MCP服务器测试失败", e); } } /** * 测试Streamable HTTP MCP服务器 */ private static void testStreamableHttpMcpServer() { log.info("=== 测试3: Streamable HTTP MCP服务器 ==="); try { // 创建Streamable HTTP服务器 McpServer server = McpServerFactory.createServer("http", "name", "1.0.0", 8081); log.info("创建Streamable HTTP MCP服务器: {}", server.getServerInfo()); // 启动服务器 CompletableFuture startFuture = server.start(); startFuture.get(5, TimeUnit.SECONDS); if (server.isRunning()) { log.info("Streamable HTTP MCP服务器启动成功,端口: 8081"); log.info("MCP端点: http://localhost:8081/mcp"); // 显示可用工具 showAvailableTools(); // 运行一段时间后停止 Thread.sleep(2000); // 停止服务器 CompletableFuture stopFuture = server.stop(); stopFuture.get(5, TimeUnit.SECONDS); log.info("Streamable HTTP MCP服务器已停止"); } else { log.error("Streamable HTTP MCP服务器启动失败"); } } catch (Exception e) { log.error("Streamable HTTP MCP服务器测试失败", e); } } /** * 显示可用工具 */ private static void showAvailableTools() { try { log.info("--- 可用工具列表 ---"); // 获取所有工具 List allTools = ToolUtil.getAllTools(new ArrayList<>(), new ArrayList<>()); log.info("总计 {} 个工具:", allTools.size()); for (Tool tool : allTools) { if (tool.getFunction() != null) { log.info("- {}: {}", tool.getFunction().getName(), tool.getFunction().getDescription()); } } // 测试调用几个工具 log.info("--- 工具调用测试 ---"); testToolCall("TestService_greet", "{\"name\":\"MCP测试用户\"}"); testToolCall("TestService_add", "{\"a\":100,\"b\":200}"); } catch (Exception e) { log.error("显示可用工具失败", e); } } /** * 测试工具调用 */ private static void testToolCall(String toolName, String arguments) { try { log.info("调用工具: {} 参数: {}", toolName, arguments); String result = ToolUtil.invoke(toolName, arguments); log.info("调用结果: {}", result); } catch (Exception e) { log.error("工具调用失败: {} - {}", toolName, e.getMessage()); } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpTypeSupportTest.java ================================================ package io.github.lnyocly.ai4j.mcp; import io.github.lnyocly.ai4j.mcp.config.McpServerConfig; import io.github.lnyocly.ai4j.mcp.entity.McpServerReference; import io.github.lnyocly.ai4j.mcp.transport.TransportConfig; import io.github.lnyocly.ai4j.mcp.util.McpTypeSupport; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; public class McpTypeSupportTest { @Test public void shouldNormalizeKnownAliases() { Assert.assertEquals(McpTypeSupport.TYPE_STDIO, McpTypeSupport.normalizeType("process")); Assert.assertEquals(McpTypeSupport.TYPE_SSE, McpTypeSupport.normalizeType("event-stream")); Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, McpTypeSupport.normalizeType("http")); Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, McpTypeSupport.normalizeType("streamable-http")); } @Test public void shouldCreateTransportConfigFromServerInfo() { McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo(); serverInfo.setTransport("server-sent-events"); serverInfo.setUrl("http://localhost:8080/sse"); serverInfo.setHeaders(new HashMap(Collections.singletonMap("Authorization", "Bearer test"))); TransportConfig config = TransportConfig.fromServerInfo(serverInfo); Assert.assertEquals(McpTypeSupport.TYPE_SSE, config.getType()); Assert.assertEquals("http://localhost:8080/sse", config.getUrl()); Assert.assertEquals("Bearer test", config.getHeaders().get("Authorization")); } @Test public void shouldCreateStdioTransportConfigFromServerInfo() { McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo(); serverInfo.setType("local"); serverInfo.setCommand("npx"); serverInfo.setArgs(Arrays.asList("-y", "@modelcontextprotocol/server-filesystem")); TransportConfig config = TransportConfig.fromServerInfo(serverInfo); Assert.assertEquals(McpTypeSupport.TYPE_STDIO, config.getType()); Assert.assertEquals("npx", config.getCommand()); Assert.assertEquals(2, config.getArgs().size()); } @Test public void shouldResolveTypeFromServerReference() { McpServerReference serverReference = McpServerReference.http("demo", "http://localhost:8080/mcp"); Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, McpTypeSupport.resolveType(serverReference)); Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, serverReference.getResolvedType()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/TestMcpService.java ================================================ package io.github.lnyocly.ai4j.mcp; import io.github.lnyocly.ai4j.mcp.annotation.McpService; import io.github.lnyocly.ai4j.mcp.annotation.McpTool; import io.github.lnyocly.ai4j.mcp.annotation.McpParameter; /** * 测试用的MCP服务 */ @McpService(name = "TestService", description = "测试MCP服务") public class TestMcpService { /** * 简单的问候工具 */ @McpTool(name = "greet", description = "向指定的人问候") public String greet(@McpParameter(name = "name", description = "要问候的人的名字", required = true) String name) { return "Hello, " + name + "! Welcome to MCP service!"; } /** * 数学计算工具 */ @McpTool(name = "add", description = "计算两个数字的和") public int add( @McpParameter(name = "a", description = "第一个数字", required = true) int a, @McpParameter(name = "b", description = "第二个数字", required = true) int b) { return a + b; } /** * 字符串处理工具 */ @McpTool(name = "reverse", description = "反转字符串") public String reverse(@McpParameter(name = "text", description = "要反转的文本", required = true) String text) { if (text == null) { return ""; } return new StringBuilder(text).reverse().toString(); } /** * 获取系统信息工具 */ @McpTool(name = "systemInfo", description = "获取系统信息") public SystemInfo getSystemInfo() { SystemInfo info = new SystemInfo(); info.javaVersion = System.getProperty("java.version"); info.osName = System.getProperty("os.name"); info.osVersion = System.getProperty("os.version"); info.timestamp = System.currentTimeMillis(); return info; } /** * 系统信息类 */ public static class SystemInfo { public String javaVersion; public String osName; public String osVersion; public long timestamp; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/config/McpConfigManagerTest.java ================================================ package io.github.lnyocly.ai4j.mcp.config; import org.junit.Assert; import org.junit.Test; import java.util.concurrent.atomic.AtomicInteger; public class McpConfigManagerTest { @Test public void shouldValidateSseConfigWithModernTypeField() { McpConfigManager configManager = new McpConfigManager(); McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo(); serverInfo.setName("weather"); serverInfo.setType("sse"); serverInfo.setUrl("http://localhost:8080/sse"); Assert.assertTrue(configManager.validateConfig(serverInfo)); } @Test public void shouldRejectUnknownTransportType() { McpConfigManager configManager = new McpConfigManager(); McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo(); serverInfo.setName("weather"); serverInfo.setType("custom-bus"); serverInfo.setCommand("npx"); Assert.assertFalse(configManager.validateConfig(serverInfo)); } @Test public void shouldNotifyListenerWhenUpdatingExistingConfig() { McpConfigManager configManager = new McpConfigManager(); AtomicInteger addedCount = new AtomicInteger(0); AtomicInteger updatedCount = new AtomicInteger(0); configManager.addConfigChangeListener(new McpConfigSource.ConfigChangeListener() { @Override public void onConfigAdded(String serverId, McpServerConfig.McpServerInfo config) { addedCount.incrementAndGet(); } @Override public void onConfigRemoved(String serverId) { } @Override public void onConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) { updatedCount.incrementAndGet(); } }); configManager.addConfig("demo", createStdioConfig("npx")); configManager.updateConfig("demo", createStdioConfig("uvx")); Assert.assertEquals(1, addedCount.get()); Assert.assertEquals(1, updatedCount.get()); Assert.assertEquals("uvx", configManager.getConfig("demo").getCommand()); } private McpServerConfig.McpServerInfo createStdioConfig(String command) { McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo(); serverInfo.setName("filesystem"); serverInfo.setType("stdio"); serverInfo.setCommand(command); return serverInfo; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayConfigSourceTest.java ================================================ package io.github.lnyocly.ai4j.mcp.gateway; import io.github.lnyocly.ai4j.mcp.client.McpClient; import io.github.lnyocly.ai4j.mcp.config.McpConfigManager; import io.github.lnyocly.ai4j.mcp.config.McpServerConfig; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.transport.McpTransport; import org.junit.Assert; import org.junit.Test; import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; public class McpGatewayConfigSourceTest { @Test public void shouldLoadConfigSourceOnlyOnceDuringInitialize() { AtomicInteger createCount = new AtomicInteger(0); McpGateway gateway = new McpGateway(new CountingClientFactory(createCount)); McpConfigManager configManager = new McpConfigManager(); configManager.addConfig("demo", createStdioConfig("npx")); gateway.setConfigSource(configManager); gateway.initialize().join(); Assert.assertTrue(gateway.isInitialized()); Assert.assertEquals(1, createCount.get()); Assert.assertEquals(1, ((Number) gateway.getGatewayStatus().get("totalClients")).intValue()); gateway.shutdown().join(); } @Test public void shouldDisconnectPreviousClientWhenReplacingSameKey() { McpGateway gateway = new McpGateway(new McpGatewayClientFactory()); TestMcpClient firstClient = new TestMcpClient("demo", "first_tool"); TestMcpClient secondClient = new TestMcpClient("demo", "second_tool"); gateway.addMcpClient("demo", firstClient).join(); gateway.addMcpClient("demo", secondClient).join(); Assert.assertFalse(firstClient.isConnected()); Assert.assertTrue(secondClient.isConnected()); Assert.assertFalse(gateway.getToolToClientMap().containsKey("first_tool")); Assert.assertEquals("demo", gateway.getToolToClientMap().get("second_tool")); gateway.shutdown().join(); } private McpServerConfig.McpServerInfo createStdioConfig(String command) { McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo(); serverInfo.setName("filesystem"); serverInfo.setType("stdio"); serverInfo.setCommand(command); return serverInfo; } private static final class CountingClientFactory extends McpGatewayClientFactory { private final AtomicInteger createCount; private CountingClientFactory(AtomicInteger createCount) { this.createCount = createCount; } @Override public McpClient create(String serverId, McpServerConfig.McpServerInfo serverInfo) { createCount.incrementAndGet(); return new TestMcpClient(serverId, serverId + "_tool"); } } private static final class TestMcpClient extends McpClient { private final AtomicBoolean connected = new AtomicBoolean(false); private final List tools; private TestMcpClient(String clientName, String toolName) { super(clientName, "test", new NoopTransport()); this.tools = Collections.singletonList(McpToolDefinition.builder() .name(toolName) .description("test tool") .build()); } @Override public CompletableFuture connect() { connected.set(true); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture disconnect() { connected.set(false); return CompletableFuture.completedFuture(null); } @Override public boolean isConnected() { return connected.get(); } @Override public boolean isInitialized() { return connected.get(); } @Override public CompletableFuture> getAvailableTools() { return CompletableFuture.completedFuture(tools); } } private static final class NoopTransport implements McpTransport { @Override public CompletableFuture start() { return CompletableFuture.completedFuture(null); } @Override public CompletableFuture stop() { return CompletableFuture.completedFuture(null); } @Override public CompletableFuture sendMessage(McpMessage message) { return CompletableFuture.completedFuture(null); } @Override public void setMessageHandler(McpMessageHandler handler) { } @Override public boolean isConnected() { return true; } @Override public boolean needsHeartbeat() { return false; } @Override public String getTransportType() { return "test"; } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/transport/SseTransportTest.java ================================================ package io.github.lnyocly.ai4j.mcp.transport; import io.github.lnyocly.ai4j.mcp.entity.McpMessage; import io.github.lnyocly.ai4j.mcp.entity.McpRequest; import org.junit.After; import org.junit.Assert; import org.junit.Test; import java.io.BufferedReader; import java.io.Closeable; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; public class SseTransportTest { private RawSseFixture fixture; @After public void tearDown() throws Exception { if (fixture != null) { fixture.close(); fixture = null; } } @Test public void receivesExplicitMessageEvents() throws Exception { fixture = new RawSseFixture(false); fixture.start(); SseTransport transport = new SseTransport(fixture.getSseUrl()); CapturingHandler handler = new CapturingHandler(); transport.setMessageHandler(handler); transport.start().get(5, TimeUnit.SECONDS); transport.sendMessage(new McpRequest("initialize", 1L, Collections.emptyMap())) .get(5, TimeUnit.SECONDS); Assert.assertTrue("expected a response event", handler.messageLatch.await(5, TimeUnit.SECONDS)); Assert.assertNull("unexpected transport error", handler.lastError); Assert.assertNotNull(handler.lastMessage); Assert.assertTrue(handler.lastMessage.isResponse()); Assert.assertEquals(1L, ((Number) handler.lastMessage.getId()).longValue()); Assert.assertEquals("POST", fixture.lastRequestMethod.get()); Assert.assertTrue(fixture.lastRequestBody.get().contains("\"initialize\"")); Assert.assertNull("fixture server failed", fixture.failure.get()); transport.stop().get(5, TimeUnit.SECONDS); } @Test public void defaultsUnnamedEventsToMessageAndJoinsMultilineData() throws Exception { fixture = new RawSseFixture(true); fixture.start(); SseTransport transport = new SseTransport(fixture.getSseUrl()); CapturingHandler handler = new CapturingHandler(); transport.setMessageHandler(handler); transport.start().get(5, TimeUnit.SECONDS); transport.sendMessage(new McpRequest("initialize", 2L, Collections.emptyMap())) .get(5, TimeUnit.SECONDS); Assert.assertTrue("expected a response event", handler.messageLatch.await(5, TimeUnit.SECONDS)); Assert.assertNull("unexpected transport error", handler.lastError); Assert.assertNotNull(handler.lastMessage); Assert.assertTrue(handler.lastMessage.isResponse()); Assert.assertEquals(2L, ((Number) handler.lastMessage.getId()).longValue()); Assert.assertNotNull(handler.lastMessage.getResult()); Assert.assertTrue(String.valueOf(handler.lastMessage.getResult()).contains("multi-line")); Assert.assertNull("fixture server failed", fixture.failure.get()); transport.stop().get(5, TimeUnit.SECONDS); } private static final class CapturingHandler implements McpTransport.McpMessageHandler { private final CountDownLatch messageLatch = new CountDownLatch(1); private volatile McpMessage lastMessage; private volatile Throwable lastError; @Override public void handleMessage(McpMessage message) { this.lastMessage = message; messageLatch.countDown(); } @Override public void onConnected() { } @Override public void onDisconnected(String reason) { } @Override public void onError(Throwable error) { this.lastError = error; messageLatch.countDown(); } } private static final class RawSseFixture implements Closeable { private final boolean unnamedMessageEvent; private final ServerSocket serverSocket; private final AtomicReference lastRequestMethod = new AtomicReference(); private final AtomicReference lastRequestBody = new AtomicReference(); private final AtomicReference failure = new AtomicReference(); private final CountDownLatch endpointServed = new CountDownLatch(1); private volatile Thread serverThread; private volatile Socket sseSocket; private volatile Socket postSocket; private RawSseFixture(boolean unnamedMessageEvent) throws IOException { this.unnamedMessageEvent = unnamedMessageEvent; this.serverSocket = new ServerSocket(0); } private void start() { serverThread = new Thread(new Runnable() { @Override public void run() { serve(); } }, "raw-sse-fixture"); serverThread.setDaemon(true); serverThread.start(); } private String getSseUrl() { return "http://127.0.0.1:" + serverSocket.getLocalPort() + "/sse"; } private void serve() { try { sseSocket = serverSocket.accept(); handleSseConnection(sseSocket); } catch (Throwable t) { if (!serverSocket.isClosed()) { failure.compareAndSet(null, t); } } } private void handleSseConnection(Socket socket) throws Exception { BufferedReader requestReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); String requestLine = requestReader.readLine(); if (requestLine == null || !requestLine.startsWith("GET /sse ")) { throw new IOException("unexpected SSE request line: " + requestLine); } drainHeaders(requestReader); OutputStream sseOutput = socket.getOutputStream(); writeAscii(sseOutput, "HTTP/1.1 200 OK\r\n" + "Content-Type: text/event-stream; charset=utf-8\r\n" + "Cache-Control: no-cache\r\n" + "Connection: keep-alive\r\n\r\n"); writeAscii(sseOutput, "event: endpoint\n" + "data: /message?session_id=test-session\n\n"); sseOutput.flush(); endpointServed.countDown(); postSocket = serverSocket.accept(); handlePostConnection(postSocket, sseOutput); } private void handlePostConnection(Socket socket, OutputStream sseOutput) throws Exception { BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); String requestLine = reader.readLine(); if (requestLine == null || !requestLine.startsWith("POST /message?session_id=test-session ")) { throw new IOException("unexpected POST request line: " + requestLine); } lastRequestMethod.set(requestLine.substring(0, requestLine.indexOf(' '))); int contentLength = 0; String line; while ((line = reader.readLine()) != null && !line.isEmpty()) { String lower = line.toLowerCase(); if (lower.startsWith("content-length:")) { contentLength = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim()); } } char[] body = new char[contentLength]; int offset = 0; while (offset < contentLength) { int read = reader.read(body, offset, contentLength - offset); if (read == -1) { break; } offset += read; } lastRequestBody.set(new String(body, 0, offset)); OutputStream postOutput = socket.getOutputStream(); writeAscii(postOutput, "HTTP/1.1 202 Accepted\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: 8\r\n" + "Connection: close\r\n\r\n" + "Accepted"); postOutput.flush(); try { if (!endpointServed.await(5, TimeUnit.SECONDS)) { throw new IOException("endpoint event was not served"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("interrupted while waiting for endpoint event", e); } if (unnamedMessageEvent) { writeAscii(sseOutput, "data: {\"jsonrpc\":\"2.0\",\"id\":2,\n" + "data: \"result\":{\"value\":\"multi-line\"}}\n\n"); } else { writeAscii(sseOutput, "event: message\n" + "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"value\":\"ok\"}}\n\n"); } sseOutput.flush(); Thread.sleep(100); } private void drainHeaders(BufferedReader reader) throws IOException { String line; while ((line = reader.readLine()) != null && !line.isEmpty()) { // drain request headers } } private void writeAscii(OutputStream outputStream, String value) throws IOException { outputStream.write(value.getBytes(StandardCharsets.UTF_8)); } @Override public void close() throws IOException { closeQuietly(postSocket); closeQuietly(sseSocket); serverSocket.close(); if (serverThread != null) { try { serverThread.join(TimeUnit.SECONDS.toMillis(2)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("interrupted while stopping raw SSE fixture", e); } } } private void closeQuietly(Socket socket) { if (socket != null) { try { socket.close(); } catch (IOException ignored) { } } } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/util/TestMcpService.java ================================================ package io.github.lnyocly.ai4j.mcp.util; import io.github.lnyocly.ai4j.mcp.annotation.McpParameter; import io.github.lnyocly.ai4j.mcp.annotation.McpService; import io.github.lnyocly.ai4j.mcp.annotation.McpTool; /** * @Author cly * @Description 测试用的MCP服务 * @Date 2024/12/29 02:30 */ @McpService( name = "test-service", description = "测试MCP服务", version = "1.0.0" ) public class TestMcpService { @McpTool( name = "add_numbers", description = "计算两个数字的和" ) public int addNumbers( @McpParameter(name = "a", description = "第一个数字", required = true) int a, @McpParameter(name = "b", description = "第二个数字", required = true) int b ) { return a + b; } @McpTool( name = "greet_user", description = "向用户问候" ) public String greetUser( @McpParameter(name = "name", description = "用户名称", required = true) String name, @McpParameter(name = "greeting", description = "问候语", required = false, defaultValue = "Hello") String greeting ) { return greeting + ", " + name + "!"; } @McpTool( description = "获取当前时间戳" ) public long getCurrentTimestamp() { return System.currentTimeMillis(); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/memory/InMemoryChatMemoryTest.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import org.junit.Test; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class InMemoryChatMemoryTest { @Test public void shouldKeepAllMessagesByDefault() { InMemoryChatMemory memory = new InMemoryChatMemory(); memory.addSystem("You are helpful"); memory.addUser("Hello"); memory.addAssistant("Hi"); assertEquals(3, memory.getItems().size()); List messages = memory.toChatMessages(); assertEquals(3, messages.size()); assertEquals("system", messages.get(0).getRole()); assertEquals("user", messages.get(1).getRole()); assertEquals("assistant", messages.get(2).getRole()); } @Test public void shouldRetainSystemMessagesAndTrimWindow() { InMemoryChatMemory memory = new InMemoryChatMemory(new MessageWindowChatMemoryPolicy(2)); memory.addSystem("system"); memory.addUser("u1"); memory.addAssistant("a1"); memory.addUser("u2"); List items = memory.getItems(); assertEquals(3, items.size()); assertEquals("system", items.get(0).getRole()); assertEquals("assistant", items.get(1).getRole()); assertEquals("u2", items.get(2).getText()); } @Test @SuppressWarnings("unchecked") public void shouldConvertToResponsesInputWithToolCallsAndOutputs() { InMemoryChatMemory memory = new InMemoryChatMemory(); ToolCall toolCall = new ToolCall( "call_1", "function", new ToolCall.Function("queryWeather", "{\"city\":\"Luoyang\"}") ); memory.addAssistant("Let me check", java.util.Collections.singletonList(toolCall)); memory.addToolOutput("call_1", "{\"weather\":\"sunny\"}"); List input = memory.toResponsesInput(); assertEquals(2, input.size()); Map assistant = (Map) input.get(0); assertEquals("message", assistant.get("type")); assertEquals("assistant", assistant.get("role")); assertNotNull(assistant.get("tool_calls")); Map toolOutput = (Map) input.get(1); assertEquals("function_call_output", toolOutput.get("type")); assertEquals("call_1", toolOutput.get("call_id")); assertEquals("{\"weather\":\"sunny\"}", toolOutput.get("output")); } @Test public void shouldSupportSnapshotAndRestore() { InMemoryChatMemory memory = new InMemoryChatMemory(); memory.addUser("hello", "https://example.com/cat.png"); ChatMemorySnapshot snapshot = memory.snapshot(); memory.clear(); assertTrue(memory.getItems().isEmpty()); memory.restore(snapshot); assertEquals(1, memory.getItems().size()); ChatMemoryItem item = memory.getItems().get(0); assertEquals("user", item.getRole()); assertEquals("hello", item.getText()); assertNotNull(item.getImageUrls()); assertEquals(1, item.getImageUrls().size()); } @Test public void shouldCompactMessagesWithSummaryPolicy() { InMemoryChatMemory memory = new InMemoryChatMemory( new SummaryChatMemoryPolicy( SummaryChatMemoryPolicyConfig.builder() .summarizer(new ChatMemorySummarizer() { @Override public String summarize(ChatMemorySummaryRequest request) { StringBuilder summary = new StringBuilder(); if (request != null && request.getItemsToSummarize() != null) { for (ChatMemoryItem item : request.getItemsToSummarize()) { if (item == null) { continue; } summary.append(item.getRole()).append(":").append(item.getText()).append(";"); } } return summary.toString(); } }) .maxRecentMessages(2) .summaryTriggerMessages(3) .summaryTextPrefix("SUMMARY:\n") .build() ) ); memory.addSystem("system"); memory.addUser("u1"); memory.addAssistant("a1"); memory.addUser("u2"); memory.addAssistant("a2"); List items = memory.getItems(); assertEquals(4, items.size()); assertTrue(items.get(1).isSummary()); assertTrue(items.get(1).getText().startsWith("SUMMARY:\n")); assertEquals("u2", items.get(2).getText()); assertEquals("a2", items.get(3).getText()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/memory/JdbcChatMemoryTest.java ================================================ package io.github.lnyocly.ai4j.memory; import org.junit.Test; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class JdbcChatMemoryTest { @Test public void shouldPersistMessagesAcrossInstances() { String jdbcUrl = jdbcUrl("persist"); JdbcChatMemory first = new JdbcChatMemory(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId("chat-1") .build()); first.addSystem("You are helpful"); first.addUser("Hello"); first.addAssistant("Hi"); JdbcChatMemory second = new JdbcChatMemory(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId("chat-1") .build()); List items = second.getItems(); assertEquals(3, items.size()); assertEquals("system", items.get(0).getRole()); assertEquals("Hello", items.get(1).getText()); assertEquals("Hi", items.get(2).getText()); } @Test public void shouldApplyConfiguredWindowPolicy() { JdbcChatMemory memory = new JdbcChatMemory(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl("policy")) .sessionId("chat-2") .policy(new MessageWindowChatMemoryPolicy(2)) .build()); memory.addSystem("system"); memory.addUser("u1"); memory.addAssistant("a1"); memory.addUser("u2"); List items = memory.getItems(); assertEquals(3, items.size()); assertEquals("system", items.get(0).getRole()); assertEquals("a1", items.get(1).getText()); assertEquals("u2", items.get(2).getText()); } @Test public void shouldSupportSnapshotRestoreAndClear() { JdbcChatMemory memory = new JdbcChatMemory(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl("snapshot")) .sessionId("chat-3") .build()); memory.addUser("hello", "https://example.com/cat.png"); ChatMemorySnapshot snapshot = memory.snapshot(); memory.clear(); assertTrue(memory.getItems().isEmpty()); memory.restore(snapshot); assertEquals(1, memory.getItems().size()); assertEquals("hello", memory.getItems().get(0).getText()); assertEquals(1, memory.getItems().get(0).getImageUrls().size()); } @Test public void shouldPersistSummaryEntriesAcrossInstances() { String jdbcUrl = jdbcUrl("summary"); JdbcChatMemory first = new JdbcChatMemory(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId("chat-4") .policy(new SummaryChatMemoryPolicy( SummaryChatMemoryPolicyConfig.builder() .summarizer(new ChatMemorySummarizer() { @Override public String summarize(ChatMemorySummaryRequest request) { StringBuilder summary = new StringBuilder(); if (request != null && request.getItemsToSummarize() != null) { for (ChatMemoryItem item : request.getItemsToSummarize()) { if (item == null) { continue; } summary.append(item.getRole()).append(":").append(item.getText()).append(";"); } } return summary.toString(); } }) .maxRecentMessages(2) .summaryTriggerMessages(3) .summaryTextPrefix("SUMMARY:\n") .build())) .build()); first.addSystem("system"); first.addUser("u1"); first.addAssistant("a1"); first.addUser("u2"); first.addAssistant("a2"); JdbcChatMemory second = new JdbcChatMemory(JdbcChatMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId("chat-4") .build()); List items = second.getItems(); assertEquals(4, items.size()); assertTrue(items.get(1).isSummary()); assertTrue(items.get(1).getText().startsWith("SUMMARY:\n")); assertEquals("u2", items.get(2).getText()); assertEquals("a2", items.get(3).getText()); } private String jdbcUrl(String suffix) { return "jdbc:h2:mem:ai4j_chat_memory_" + suffix + ";MODE=MYSQL;DB_CLOSE_DELAY=-1"; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/memory/SummaryChatMemoryPolicyTest.java ================================================ package io.github.lnyocly.ai4j.memory; import io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType; import org.junit.Test; import java.util.ArrayList; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class SummaryChatMemoryPolicyTest { @Test public void shouldSummarizeOlderMessagesAndKeepRecentWindow() { SummaryChatMemoryPolicy policy = new SummaryChatMemoryPolicy( SummaryChatMemoryPolicyConfig.builder() .summarizer(new RecordingSummarizer()) .maxRecentMessages(2) .summaryTriggerMessages(3) .summaryTextPrefix("SUMMARY:\n") .build() ); List compacted = policy.apply(items( ChatMemoryItem.system("system"), ChatMemoryItem.user("u1"), ChatMemoryItem.assistant("a1"), ChatMemoryItem.user("u2"), ChatMemoryItem.assistant("a2") )); assertEquals(4, compacted.size()); assertEquals(ChatMessageType.SYSTEM.getRole(), compacted.get(0).getRole()); assertTrue(compacted.get(1).isSummary()); assertEquals(ChatMessageType.ASSISTANT.getRole(), compacted.get(1).getRole()); assertTrue(compacted.get(1).getText().startsWith("SUMMARY:\n")); assertTrue(compacted.get(1).getText().contains("user:u1")); assertTrue(compacted.get(1).getText().contains("assistant:a1")); assertEquals("u2", compacted.get(2).getText()); assertEquals("a2", compacted.get(3).getText()); } @Test public void shouldMergePreviousSummaryIntoNextCompaction() { RecordingSummarizer summarizer = new RecordingSummarizer(); SummaryChatMemoryPolicy policy = new SummaryChatMemoryPolicy( SummaryChatMemoryPolicyConfig.builder() .summarizer(summarizer) .maxRecentMessages(2) .summaryTriggerMessages(3) .summaryTextPrefix("SUMMARY:\n") .build() ); List first = policy.apply(items( ChatMemoryItem.user("u1"), ChatMemoryItem.assistant("a1"), ChatMemoryItem.user("u2"), ChatMemoryItem.assistant("a2") )); List secondInput = new ArrayList(first); secondInput.add(ChatMemoryItem.user("u3")); secondInput.add(ChatMemoryItem.assistant("a3")); List second = policy.apply(secondInput); assertEquals(2, summarizer.getInvocationCount()); ChatMemorySummaryRequest request = summarizer.getLastRequest(); assertNotNull(request); assertEquals("user:u1;assistant:a1;", request.getExistingSummary()); assertEquals(2, request.getItemsToSummarize().size()); assertEquals("u2", request.getItemsToSummarize().get(0).getText()); assertEquals("a2", request.getItemsToSummarize().get(1).getText()); assertEquals(3, second.size()); assertTrue(second.get(0).isSummary()); assertEquals("u3", second.get(1).getText()); assertEquals("a3", second.get(2).getText()); } @Test public void shouldKeepOriginalItemsWhenSummaryOutputIsBlank() { SummaryChatMemoryPolicy policy = new SummaryChatMemoryPolicy( SummaryChatMemoryPolicyConfig.builder() .summarizer(new ChatMemorySummarizer() { @Override public String summarize(ChatMemorySummaryRequest request) { return " "; } }) .maxRecentMessages(1) .summaryTriggerMessages(2) .build() ); List original = items( ChatMemoryItem.user("u1"), ChatMemoryItem.assistant("a1"), ChatMemoryItem.user("u2") ); List compacted = policy.apply(original); assertEquals(3, compacted.size()); assertEquals("u1", compacted.get(0).getText()); assertEquals("a1", compacted.get(1).getText()); assertEquals("u2", compacted.get(2).getText()); } private List items(ChatMemoryItem... items) { List list = new ArrayList(); if (items != null) { for (ChatMemoryItem item : items) { list.add(item); } } return list; } private static class RecordingSummarizer implements ChatMemorySummarizer { private int invocationCount; private ChatMemorySummaryRequest lastRequest; @Override public String summarize(ChatMemorySummaryRequest request) { invocationCount++; lastRequest = request; StringBuilder summary = new StringBuilder(); if (request != null && request.getExistingSummary() != null && !request.getExistingSummary().trim().isEmpty()) { summary.append("prev=").append(request.getExistingSummary()).append("|"); } if (request != null && request.getItemsToSummarize() != null) { for (ChatMemoryItem item : request.getItemsToSummarize()) { if (item == null) { continue; } summary.append(item.getRole()).append(":").append(item.getText()).append(";"); } } return summary.toString(); } public int getInvocationCount() { return invocationCount; } public ChatMemorySummaryRequest getLastRequest() { return lastRequest; } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/platform/doubao/rerank/DoubaoRerankServiceTest.java ================================================ package io.github.lnyocly.ai4j.platform.doubao.rerank; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.rerank.entity.RerankDocument; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.OkHttpClient; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.concurrent.atomic.AtomicReference; public class DoubaoRerankServiceTest { @Test public void shouldConvertDoubaoKnowledgeRerankApi() throws Exception { AtomicReference requestBody = new AtomicReference(); AtomicReference authorization = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/api/knowledge/service/rerank", jsonHandler( "{\"request_id\":\"req-1\",\"token_usage\":12,\"data\":[0.12,0.82]}", requestBody, authorization)); server.start(); try { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); configuration.setDoubaoConfig(new DoubaoConfig( "https://ark.cn-beijing.volces.com/api/v3/", "ark-key", "chat/completions", "images/generations", "responses", "http://127.0.0.1:" + server.getAddress().getPort(), "api/knowledge/service/rerank" )); DoubaoRerankService service = new DoubaoRerankService(configuration); RerankResponse response = service.rerank(RerankRequest.builder() .model("doubao-rerank") .query("vacation policy") .documents(Arrays.asList( RerankDocument.builder().content("doc-a").build(), RerankDocument.builder().content("doc-b").build() )) .instruction("只判断相关性") .build()); Assert.assertEquals("Bearer ark-key", authorization.get()); Assert.assertTrue(requestBody.get().contains("\"rerank_model\":\"doubao-rerank\"")); Assert.assertTrue(requestBody.get().contains("\"rerank_instruction\":\"只判断相关性\"")); Assert.assertTrue(requestBody.get().contains("\"query\":\"vacation policy\"")); Assert.assertTrue(requestBody.get().contains("\"content\":\"doc-a\"")); Assert.assertEquals(2, response.getResults().size()); Assert.assertEquals(Integer.valueOf(1), response.getResults().get(0).getIndex()); Assert.assertEquals(0.82d, response.getResults().get(0).getRelevanceScore(), 0.0001d); Assert.assertEquals(Integer.valueOf(12), response.getUsage().getInputTokens()); } finally { server.stop(0); } } private HttpHandler jsonHandler(final String responseBody, final AtomicReference requestBody, final AtomicReference authorization) { return new HttpHandler() { @Override public void handle(HttpExchange exchange) { try { requestBody.set(read(exchange.getRequestBody())); authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, response.length); OutputStream outputStream = exchange.getResponseBody(); try { outputStream.write(response); } finally { outputStream.close(); } } catch (Exception ex) { throw new RuntimeException(ex); } } }; } private String read(InputStream inputStream) throws Exception { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[256]; int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/platform/jina/rerank/JinaRerankServiceTest.java ================================================ package io.github.lnyocly.ai4j.platform.jina.rerank; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import io.github.lnyocly.ai4j.config.JinaConfig; import io.github.lnyocly.ai4j.rerank.entity.RerankDocument; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.OkHttpClient; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; public class JinaRerankServiceTest { @Test public void shouldCallJinaCompatibleRerankApi() throws Exception { AtomicReference requestBody = new AtomicReference(); AtomicReference authorization = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/v1/rerank", jsonHandler( "{\"model\":\"jina-reranker-v2-base-multilingual\",\"results\":[{\"index\":1,\"relevance_score\":0.91,\"document\":\"document-b\"},{\"index\":0,\"relevance_score\":0.34,\"document\":\"document-a\"}],\"usage\":{\"total_tokens\":9}}", requestBody, authorization)); server.start(); try { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); configuration.setJinaConfig(new JinaConfig( "http://127.0.0.1:" + server.getAddress().getPort(), "jina-key", "v1/rerank" )); JinaRerankService service = new JinaRerankService(configuration); RerankResponse response = service.rerank(RerankRequest.builder() .model("jina-reranker-v2-base-multilingual") .query("vacation policy") .documents(Arrays.asList( RerankDocument.builder().text("document-a").content("document-a").build(), RerankDocument.builder().text("document-b").content("document-b").build() )) .topN(2) .returnDocuments(true) .build()); Assert.assertEquals("Bearer jina-key", authorization.get()); Assert.assertTrue(requestBody.get().contains("\"top_n\":2")); Assert.assertTrue(requestBody.get().contains("\"query\":\"vacation policy\"")); Assert.assertTrue(requestBody.get().contains("document-a")); Assert.assertEquals(2, response.getResults().size()); Assert.assertEquals(Integer.valueOf(1), response.getResults().get(0).getIndex()); Assert.assertEquals(0.91d, response.getResults().get(0).getRelevanceScore(), 0.0001d); Assert.assertEquals(Integer.valueOf(9), response.getUsage().getTotalTokens()); Assert.assertEquals("document-b", response.getResults().get(0).getDocument().getContent()); } finally { server.stop(0); } } private HttpHandler jsonHandler(final String responseBody, final AtomicReference requestBody, final AtomicReference authorization) { return new HttpHandler() { @Override public void handle(HttpExchange exchange) { try { requestBody.set(read(exchange.getRequestBody())); authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, response.length); OutputStream outputStream = exchange.getResponseBody(); try { outputStream.write(response); } finally { outputStream.close(); } } catch (Exception ex) { throw new RuntimeException(ex); } } }; } private String read(InputStream inputStream) throws Exception { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[256]; int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/platform/minimax/chat/MinimaxChatServiceTest.java ================================================ package io.github.lnyocly.ai4j.platform.minimax.chat; import io.github.lnyocly.ai4j.config.MinimaxConfig; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Protocol; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; import org.junit.Assert; import org.junit.Test; import java.util.Collections; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; public class MinimaxChatServiceTest { @Test public void chatCompletionShouldReturnProviderToolCallsWhenPassThroughEnabled() throws Exception { final AtomicInteger requestCount = new AtomicInteger(); final AtomicReference recordedRequest = new AtomicReference(); final String responseJson = "{" + "\"id\":\"resp_1\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000000," + "\"model\":\"MiniMax-M2.1\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"\"," + "\"tool_calls\":[{" + "\"id\":\"call_1\"," + "\"type\":\"function\"," + "\"function\":{" + "\"name\":\"read_file\"," + "\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"" + "}" + "}]" + "}," + "\"finish_reason\":\"tool_calls\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":11," + "\"completion_tokens\":7," + "\"total_tokens\":18" + "}" + "}"; OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(chain -> { requestCount.incrementAndGet(); recordedRequest.set(chain.request()); return new Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .body(ResponseBody.create(responseJson, MediaType.get("application/json"))) .build(); }) .build(); MinimaxConfig minimaxConfig = new MinimaxConfig(); minimaxConfig.setApiHost("https://unit.test/"); minimaxConfig.setApiKey("config-api-key"); Configuration configuration = new Configuration(); configuration.setMinimaxConfig(minimaxConfig); configuration.setOkHttpClient(okHttpClient); MinimaxChatService service = new MinimaxChatService(configuration); ChatCompletion completion = ChatCompletion.builder() .model("MiniMax-M2.1") .messages(Collections.singletonList(ChatMessage.withUser("Read README.md"))) .tools(Collections.singletonList(tool("read_file"))) .build(); completion.setPassThroughToolCalls(Boolean.TRUE); ChatCompletionResponse response = service.chatCompletion(completion); Assert.assertNotNull(response); Assert.assertNotNull(response.getChoices()); Assert.assertEquals(1, response.getChoices().size()); Assert.assertEquals(1, requestCount.get()); Assert.assertEquals("tool_calls", response.getChoices().get(0).getFinishReason()); Assert.assertNotNull(response.getChoices().get(0).getMessage()); Assert.assertNotNull(response.getChoices().get(0).getMessage().getToolCalls()); Assert.assertEquals(1, response.getChoices().get(0).getMessage().getToolCalls().size()); Assert.assertEquals("read_file", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getName()); Assert.assertEquals("{\"path\":\"README.md\"}", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getArguments()); Assert.assertEquals("Bearer config-api-key", recordedRequest.get().header("Authorization")); Assert.assertEquals("https://unit.test/v1/text/chatcompletion_v2", recordedRequest.get().url().toString()); Assert.assertEquals(18L, response.getUsage().getTotalTokens()); } private Tool tool(String name) { Tool.Function function = new Tool.Function(); function.setName(name); function.setDescription("test tool"); return new Tool("function", function); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/platform/ollama/rerank/OllamaRerankServiceTest.java ================================================ package io.github.lnyocly.ai4j.platform.ollama.rerank; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import io.github.lnyocly.ai4j.config.OllamaConfig; import io.github.lnyocly.ai4j.rerank.entity.RerankDocument; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.OkHttpClient; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; public class OllamaRerankServiceTest { @Test public void shouldCallOllamaRerankEndpointWithoutAuthWhenApiKeyMissing() throws Exception { AtomicReference requestBody = new AtomicReference(); AtomicReference authorization = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/api/rerank", jsonHandler( "{\"model\":\"bge-reranker-v2-m3\",\"results\":[{\"index\":0,\"relevance_score\":0.77,\"document\":\"document-a\"}]}", requestBody, authorization)); server.start(); try { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); configuration.setOllamaConfig(new OllamaConfig( "http://127.0.0.1:" + server.getAddress().getPort(), "", "api/chat", "api/embed", "api/rerank" )); OllamaRerankService service = new OllamaRerankService(configuration); RerankResponse response = service.rerank(RerankRequest.builder() .model("bge-reranker-v2-m3") .query("vacation policy") .documents(Collections.singletonList( RerankDocument.builder().text("document-a").content("document-a").build() )) .topN(1) .build()); Assert.assertNull(authorization.get()); Assert.assertTrue(requestBody.get().contains("\"model\":\"bge-reranker-v2-m3\"")); Assert.assertEquals(1, response.getResults().size()); Assert.assertEquals(0.77d, response.getResults().get(0).getRelevanceScore(), 0.0001d); } finally { server.stop(0); } } private HttpHandler jsonHandler(final String responseBody, final AtomicReference requestBody, final AtomicReference authorization) { return new HttpHandler() { @Override public void handle(HttpExchange exchange) { try { requestBody.set(read(exchange.getRequestBody())); authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, response.length); OutputStream outputStream = exchange.getResponseBody(); try { outputStream.write(response); } finally { outputStream.close(); } } catch (Exception ex) { throw new RuntimeException(ex); } } }; } private String read(InputStream inputStream) throws Exception { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[256]; int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/platform/openai/audio/OpenAiAudioServiceTest.java ================================================ package io.github.lnyocly.ai4j.platform.openai.audio; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.platform.openai.audio.entity.TextToSpeech; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Protocol; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicReference; public class OpenAiAudioServiceTest { @Test public void test_text_to_speech_stream_remains_readable_after_method_returns() throws Exception { final byte[] expectedAudio = "fake-mp3-audio".getBytes(StandardCharsets.UTF_8); final AtomicReference recordedRequest = new AtomicReference(); OpenAiConfig openAiConfig = new OpenAiConfig(); openAiConfig.setApiHost("https://unit.test/"); openAiConfig.setApiKey("config-api-key"); OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(chain -> { recordedRequest.set(chain.request()); return new Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .body(ResponseBody.create(expectedAudio, MediaType.get("audio/mpeg"))) .build(); }) .build(); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); configuration.setOkHttpClient(okHttpClient); OpenAiAudioService service = new OpenAiAudioService(configuration); try (InputStream stream = service.textToSpeech(TextToSpeech.builder() .input("hello") .build())) { Assert.assertNotNull(stream); Assert.assertArrayEquals(expectedAudio, readAll(stream)); } Assert.assertNotNull(recordedRequest.get()); Assert.assertEquals("Bearer config-api-key", recordedRequest.get().header("Authorization")); Assert.assertEquals("https://unit.test/v1/audio/speech", recordedRequest.get().url().toString()); } private static byte[] readAll(InputStream inputStream) throws Exception { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[256]; int read; while ((read = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, read); } return outputStream.toByteArray(); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/platform/openai/chat/OpenAiChatServicePassThroughTest.java ================================================ package io.github.lnyocly.ai4j.platform.openai.chat; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.service.Configuration; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Protocol; import okhttp3.Response; import okhttp3.ResponseBody; import okio.Buffer; import org.junit.Assert; import org.junit.Test; import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicInteger; public class OpenAiChatServicePassThroughTest { @Test public void chatCompletionShouldReturnProviderToolCallsWhenPassThroughEnabled() throws Exception { final AtomicInteger requestCount = new AtomicInteger(); final String responseJson = "{" + "\"id\":\"resp_1\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000000," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"\"," + "\"tool_calls\":[{" + "\"id\":\"call_1\"," + "\"type\":\"function\"," + "\"function\":{" + "\"name\":\"read_file\"," + "\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"" + "}" + "}]" + "}," + "\"finish_reason\":\"tool_calls\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":10," + "\"completion_tokens\":5," + "\"total_tokens\":15" + "}" + "}"; OpenAiChatService service = new OpenAiChatService(configurationWithJsonResponse(responseJson, requestCount)); ChatCompletion completion = ChatCompletion.builder() .model("gpt-test") .messages(Collections.singletonList(ChatMessage.withUser("Read README.md"))) .tools(Collections.singletonList(tool("read_file"))) .build(); completion.setPassThroughToolCalls(Boolean.TRUE); ChatCompletionResponse response = service.chatCompletion(completion); Assert.assertNotNull(response); Assert.assertEquals(1, requestCount.get()); Assert.assertEquals("tool_calls", response.getChoices().get(0).getFinishReason()); Assert.assertEquals("read_file", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getName()); Assert.assertEquals("{\"path\":\"README.md\"}", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getArguments()); Assert.assertEquals(15L, response.getUsage().getTotalTokens()); } @Test public void chatCompletionShouldKeepLegacyProviderToolLoopWhenPassThroughDisabled() throws Exception { final String responseJson = "{" + "\"id\":\"resp_2\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000000," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"\"," + "\"tool_calls\":[{" + "\"id\":\"call_2\"," + "\"type\":\"function\"," + "\"function\":{" + "\"name\":\"unit_test_missing_tool\"," + "\"arguments\":\"{}\"" + "}" + "}]" + "}," + "\"finish_reason\":\"tool_calls\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":10," + "\"completion_tokens\":5," + "\"total_tokens\":15" + "}" + "}"; OpenAiChatService service = new OpenAiChatService(configurationWithJsonResponse(responseJson, null)); ChatCompletion completion = ChatCompletion.builder() .model("gpt-test") .messages(Collections.singletonList(ChatMessage.withUser("Call a tool"))) .tools(Collections.singletonList(tool("unit_test_missing_tool"))) .build(); try { service.chatCompletion(completion); Assert.fail("Expected legacy provider tool loop to invoke ToolUtil when pass-through is disabled"); } catch (RuntimeException ex) { Assert.assertTrue(String.valueOf(ex.getMessage()).contains("工具调用失败")); } } @Test public void chatCompletionShouldKeepLegacyFunctionAutoLoopWorking() throws Exception { final AtomicInteger requestCount = new AtomicInteger(); final AtomicReference secondRequestBody = new AtomicReference(); final String firstResponseJson = "{" + "\"id\":\"resp_legacy_tool_1\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000000," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"\"," + "\"tool_calls\":[{" + "\"id\":\"call_weather_1\"," + "\"type\":\"function\"," + "\"function\":{" + "\"name\":\"weather\"," + "\"arguments\":\"{\\\"city\\\":\\\"Hangzhou\\\"}\"" + "}" + "}]" + "}," + "\"finish_reason\":\"tool_calls\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":10," + "\"completion_tokens\":5," + "\"total_tokens\":15" + "}" + "}"; final String secondResponseJson = "{" + "\"id\":\"resp_legacy_tool_2\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000001," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"Hangzhou is sunny and 25C.\"" + "}," + "\"finish_reason\":\"stop\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":12," + "\"completion_tokens\":7," + "\"total_tokens\":19" + "}" + "}"; OpenAiChatService service = new OpenAiChatService(configurationWithJsonResponses( requestCount, secondRequestBody, firstResponseJson, secondResponseJson )); ChatCompletion completion = ChatCompletion.builder() .model("gpt-test") .messages(Collections.singletonList(ChatMessage.withUser("What is the weather in Hangzhou?"))) .functions("weather") .build(); ChatCompletionResponse response = service.chatCompletion(completion); Assert.assertNotNull(response); Assert.assertEquals(2, requestCount.get()); Assert.assertEquals("stop", response.getChoices().get(0).getFinishReason()); Assert.assertEquals("Hangzhou is sunny and 25C.", response.getChoices().get(0).getMessage().getContent().getText()); Assert.assertTrue(secondRequestBody.get().contains("\"tool_call_id\":\"call_weather_1\"")); Assert.assertTrue(secondRequestBody.get().contains("\"role\":\"tool\"")); } private Configuration configurationWithJsonResponse(String responseJson, AtomicInteger requestCount) { OpenAiConfig openAiConfig = new OpenAiConfig(); openAiConfig.setApiHost("https://unit.test/"); openAiConfig.setApiKey("config-api-key"); OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(chain -> { if (requestCount != null) { requestCount.incrementAndGet(); } return new Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .body(ResponseBody.create(responseJson, MediaType.get("application/json"))) .build(); }) .build(); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); configuration.setOkHttpClient(okHttpClient); return configuration; } private Configuration configurationWithJsonResponses(AtomicInteger requestCount, AtomicReference secondRequestBody, String firstResponseJson, String secondResponseJson) { OpenAiConfig openAiConfig = new OpenAiConfig(); openAiConfig.setApiHost("https://unit.test/"); openAiConfig.setApiKey("config-api-key"); OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(chain -> { int current = requestCount.incrementAndGet(); if (current == 2 && secondRequestBody != null) { Buffer buffer = new Buffer(); if (chain.request().body() != null) { chain.request().body().writeTo(buffer); } secondRequestBody.set(buffer.readUtf8()); } String responseJson = current == 1 ? firstResponseJson : secondResponseJson; return new Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .body(ResponseBody.create(responseJson, MediaType.get("application/json"))) .build(); }) .build(); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); configuration.setOkHttpClient(okHttpClient); return configuration; } private Tool tool(String name) { Tool.Function function = new Tool.Function(); function.setName(name); function.setDescription("test tool"); return new Tool("function", function); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/platform/openai/response/ResponseRequestToolResolverTest.java ================================================ package io.github.lnyocly.ai4j.platform.openai.response; import io.github.lnyocly.ai4j.annotation.FunctionCall; import io.github.lnyocly.ai4j.annotation.FunctionParameter; import io.github.lnyocly.ai4j.annotation.FunctionRequest; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver; import org.junit.Test; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Function; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class ResponseRequestToolResolverTest { @Test public void shouldResolveAnnotatedFunctionsIntoResponsesTools() { ResponseRequest request = ResponseRequest.builder() .model("test-model") .input("hello") .functions("responses_test_weather") .build(); ResponseRequest resolved = ResponseRequestToolResolver.resolve(request); assertNotNull(resolved.getTools()); assertEquals(1, resolved.getTools().size()); Tool tool = (Tool) resolved.getTools().get(0); assertEquals("function", tool.getType()); assertEquals("responses_test_weather", tool.getFunction().getName()); } @Test public void shouldMergeManualToolsWithResolvedFunctions() { Map manualTool = new LinkedHashMap(); manualTool.put("type", "web_search_preview"); ResponseRequest request = ResponseRequest.builder() .model("test-model") .input("hello") .tools(Collections.singletonList(manualTool)) .functions("responses_test_weather") .build(); ResponseRequest resolved = ResponseRequestToolResolver.resolve(request); assertNotNull(resolved.getTools()); assertEquals(2, resolved.getTools().size()); assertTrue(resolved.getTools().get(0) instanceof Map); assertTrue(resolved.getTools().get(1) instanceof Tool); } @FunctionCall(name = "responses_test_weather", description = "test weather function for responses") public static class ResponsesTestWeatherFunction implements Function { @Override public String apply(Request request) { return request.getLocation(); } @FunctionRequest public static class Request { @FunctionParameter(description = "query location", required = true) private String location; public String getLocation() { return location; } public void setLocation(String location) { this.location = location; } } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/rag/Bm25RetrieverTest.java ================================================ package io.github.lnyocly.ai4j.rag; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.List; public class Bm25RetrieverTest { @Test public void shouldRankExactKeywordMatchFirst() throws Exception { Bm25Retriever retriever = new Bm25Retriever(Arrays.asList( RagHit.builder().id("1").content("vacation policy for employees").build(), RagHit.builder().id("2").content("insurance handbook and benefits").build() )); List hits = retriever.retrieve(RagQuery.builder() .query("vacation policy") .topK(2) .build()); Assert.assertEquals(1, hits.size()); Assert.assertEquals("1", hits.get(0).getId()); Assert.assertTrue(hits.get(0).getScore() > 0.0f); } @Test public void shouldReturnEmptyWhenNoTermMatches() throws Exception { Bm25Retriever retriever = new Bm25Retriever(Arrays.asList( RagHit.builder().id("1").content("vacation policy for employees").build(), RagHit.builder().id("2").content("insurance handbook and benefits").build() )); List hits = retriever.retrieve(RagQuery.builder() .query("quarterly revenue guidance") .topK(3) .build()); Assert.assertTrue(hits.isEmpty()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/rag/DefaultRagServiceTest.java ================================================ package io.github.lnyocly.ai4j.rag; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class DefaultRagServiceTest { @Test public void shouldAssembleContextAndTrimFinalHits() throws Exception { Retriever retriever = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder() .id("1") .content("policy one") .sourceName("handbook.pdf") .pageNumber(2) .sectionTitle("Leave") .build(), RagHit.builder() .id("2") .content("policy two") .sourceName("benefits.pdf") .pageNumber(5) .sectionTitle("Insurance") .build() ); } }; DefaultRagService ragService = new DefaultRagService(retriever); RagResult result = ragService.search(RagQuery.builder() .query("benefits") .finalTopK(1) .build()); Assert.assertEquals(1, result.getHits().size()); Assert.assertEquals(1, result.getCitations().size()); Assert.assertTrue(result.getContext().contains("[S1]")); Assert.assertTrue(result.getContext().contains("handbook.pdf")); Assert.assertTrue(result.getContext().contains("policy one")); Assert.assertNotNull(result.getTrace()); Assert.assertEquals(2, result.getTrace().getRetrievedHits().size()); Assert.assertEquals(2, result.getTrace().getRerankedHits().size()); Assert.assertEquals(Integer.valueOf(1), result.getHits().get(0).getRank()); Assert.assertEquals("retriever", result.getHits().get(0).getRetrieverSource()); } @Test public void shouldExposeRerankScoresAndTrace() throws Exception { Retriever retriever = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder().id("1").content("alpha").score(0.6f).build(), RagHit.builder().id("2").content("beta").score(0.5f).build() ); } }; Reranker reranker = new Reranker() { @Override public List rerank(String query, List hits) { List reranked = new ArrayList(hits); RagHit first = reranked.get(0); RagHit second = reranked.get(1); second.setScore(0.98f); first.setScore(0.42f); return Arrays.asList(second, first); } }; DefaultRagService ragService = new DefaultRagService(retriever, reranker, new DefaultRagContextAssembler()); RagResult result = ragService.search(RagQuery.builder() .query("beta") .finalTopK(2) .build()); Assert.assertEquals(2, result.getHits().size()); Assert.assertEquals("2", result.getHits().get(0).getId()); Assert.assertEquals(Float.valueOf(0.5f), result.getHits().get(0).getRetrievalScore()); Assert.assertEquals(Float.valueOf(0.98f), result.getHits().get(0).getRerankScore()); Assert.assertEquals(Float.valueOf(0.98f), result.getHits().get(0).getScore()); Assert.assertEquals(Integer.valueOf(1), result.getHits().get(0).getRank()); Assert.assertNotNull(result.getTrace()); Assert.assertEquals("1", result.getTrace().getRetrievedHits().get(0).getId()); Assert.assertEquals("2", result.getTrace().getRerankedHits().get(0).getId()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/rag/DenseRetrieverTest.java ================================================ package io.github.lnyocly.ai4j.rag; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class DenseRetrieverTest { @Test public void shouldConvertSearchResultsToRagHits() throws Exception { CapturingVectorStore vectorStore = new CapturingVectorStore(); DenseRetriever retriever = new DenseRetriever(new FakeEmbeddingService(), vectorStore); List hits = retriever.retrieve(RagQuery.builder() .query("employee handbook") .embeddingModel("text-embedding-3-small") .dataset("kb_docs") .topK(4) .filter(mapOf("tenant", "acme")) .build()); Assert.assertEquals("kb_docs", vectorStore.lastRequest.getDataset()); Assert.assertEquals(Integer.valueOf(4), vectorStore.lastRequest.getTopK()); Assert.assertEquals("acme", vectorStore.lastRequest.getFilter().get("tenant")); Assert.assertEquals(1, hits.size()); Assert.assertEquals("doc-1", hits.get(0).getId()); Assert.assertEquals("Employee Handbook", hits.get(0).getSourceName()); Assert.assertEquals("/docs/employee-handbook.pdf", hits.get(0).getSourcePath()); Assert.assertEquals(Integer.valueOf(3), hits.get(0).getPageNumber()); Assert.assertEquals("Vacation Policy", hits.get(0).getSectionTitle()); Assert.assertEquals("Paid leave policy", hits.get(0).getContent()); } private static class FakeEmbeddingService implements IEmbeddingService { @Override public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) { return embedding(embeddingReq); } @Override public EmbeddingResponse embedding(Embedding embeddingReq) { return EmbeddingResponse.builder() .data(Collections.singletonList(EmbeddingObject.builder() .index(0) .embedding(Arrays.asList(0.1f, 0.2f, 0.3f)) .object("embedding") .build())) .model(embeddingReq.getModel()) .object("list") .build(); } } private static class CapturingVectorStore implements VectorStore { private VectorSearchRequest lastRequest; @Override public int upsert(io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest request) { return 0; } @Override public List search(VectorSearchRequest request) { this.lastRequest = request; return Collections.singletonList(VectorSearchResult.builder() .id("doc-1") .score(0.98f) .content("Paid leave policy") .metadata(mapOf( RagMetadataKeys.SOURCE_NAME, "Employee Handbook", RagMetadataKeys.SOURCE_PATH, "/docs/employee-handbook.pdf", RagMetadataKeys.PAGE_NUMBER, "3", RagMetadataKeys.SECTION_TITLE, "Vacation Policy", RagMetadataKeys.CHUNK_INDEX, "2" )) .build()); } @Override public boolean delete(io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest request) { return false; } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder().dataset(true).metadataFilter(true).build(); } } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/rag/HybridRetrieverTest.java ================================================ package io.github.lnyocly.ai4j.rag; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.List; public class HybridRetrieverTest { @Test public void shouldMergeResultsFromMultipleRetrievers() throws Exception { Retriever dense = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder().id("a").content("dense-a").build(), RagHit.builder().id("b").content("dense-b").build() ); } }; Retriever bm25 = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder().id("b").content("bm25-b").build(), RagHit.builder().id("c").content("bm25-c").build() ); } }; HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25)); List hits = retriever.retrieve(RagQuery.builder().query("anything").topK(3).build()); Assert.assertEquals(3, hits.size()); Assert.assertEquals("b", hits.get(0).getId()); Assert.assertEquals("a", hits.get(1).getId()); Assert.assertEquals("c", hits.get(2).getId()); } @Test public void shouldDeduplicateSameChunkWithoutExplicitId() throws Exception { Retriever dense = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder() .documentId("doc-1") .chunkIndex(0) .content("same chunk") .score(0.91f) .build() ); } }; Retriever bm25 = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder() .documentId("doc-1") .chunkIndex(0) .content("same chunk") .score(3.2f) .build() ); } }; HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25)); List hits = retriever.retrieve(RagQuery.builder().query("same chunk").topK(5).build()); Assert.assertEquals(1, hits.size()); Assert.assertEquals("doc-1", hits.get(0).getDocumentId()); Assert.assertTrue(hits.get(0).getScore() > 0.0f); } @Test public void shouldSupportRelativeScoreFusion() throws Exception { Retriever dense = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder().id("a").content("dense-a").score(0.95f).build(), RagHit.builder().id("b").content("dense-b").score(0.90f).build(), RagHit.builder().id("c").content("dense-c").score(0.70f).build() ); } }; Retriever bm25 = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder().id("b").content("bm25-b").score(12.0f).build(), RagHit.builder().id("d").content("bm25-d").score(11.0f).build(), RagHit.builder().id("a").content("bm25-a").score(8.0f).build() ); } }; HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25), new RsfFusionStrategy()); List hits = retriever.retrieve(RagQuery.builder().query("anything").topK(4).build()); Assert.assertEquals(4, hits.size()); Assert.assertEquals("b", hits.get(0).getId()); Assert.assertEquals("a", hits.get(1).getId()); Assert.assertEquals("d", hits.get(2).getId()); Assert.assertEquals("c", hits.get(3).getId()); } @Test public void shouldSupportDistributionBasedScoreFusion() throws Exception { Retriever dense = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder().id("a").content("dense-a").score(0.95f).build(), RagHit.builder().id("b").content("dense-b").score(0.90f).build(), RagHit.builder().id("c").content("dense-c").score(0.70f).build() ); } }; Retriever bm25 = new Retriever() { @Override public List retrieve(RagQuery query) { return Arrays.asList( RagHit.builder().id("b").content("bm25-b").score(12.0f).build(), RagHit.builder().id("d").content("bm25-d").score(11.0f).build(), RagHit.builder().id("a").content("bm25-a").score(8.0f).build() ); } }; HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25), new DbsfFusionStrategy()); List hits = retriever.retrieve(RagQuery.builder().query("anything").topK(4).build()); Assert.assertEquals(4, hits.size()); Assert.assertEquals("b", hits.get(0).getId()); Assert.assertEquals("a", hits.get(1).getId()); Assert.assertEquals("d", hits.get(2).getId()); Assert.assertEquals("c", hits.get(3).getId()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/rag/IngestionPipelineTest.java ================================================ package io.github.lnyocly.ai4j.rag; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline; import io.github.lnyocly.ai4j.rag.ingestion.IngestionRequest; import io.github.lnyocly.ai4j.rag.ingestion.IngestionResult; import io.github.lnyocly.ai4j.rag.ingestion.IngestionSource; import io.github.lnyocly.ai4j.rag.ingestion.LoadedDocument; import io.github.lnyocly.ai4j.rag.ingestion.DocumentLoader; import io.github.lnyocly.ai4j.rag.ingestion.MetadataEnricher; import io.github.lnyocly.ai4j.rag.ingestion.OcrNoiseCleaningDocumentProcessor; import io.github.lnyocly.ai4j.rag.ingestion.OcrTextExtractingDocumentProcessor; import io.github.lnyocly.ai4j.rag.ingestion.OcrTextExtractor; import io.github.lnyocly.ai4j.rag.ingestion.RecursiveTextChunker; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import org.junit.Assert; import org.junit.Test; import java.io.File; import java.io.FileWriter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class IngestionPipelineTest { @Test public void shouldIngestInlineTextWithMetadataAndEmbeddings() throws Exception { CapturingVectorStore vectorStore = new CapturingVectorStore(); IngestionPipeline pipeline = new IngestionPipeline(new FakeEmbeddingService(), vectorStore); IngestionResult result = pipeline.ingest(IngestionRequest.builder() .dataset("kb_docs") .embeddingModel("text-embedding-3-small") .document(RagDocument.builder() .sourceName("员工手册") .sourcePath("/docs/handbook.md") .tenant("acme") .biz("hr") .version("2026.03") .build()) .source(IngestionSource.builder() .content("第一章 假期政策。\n第二章 报销政策。") .metadata(mapOf("department", "people-ops")) .build()) .chunker(new RecursiveTextChunker(8, 2)) .batchSize(2) .metadataEnrichers(Collections.singletonList( new MetadataEnricher() { @Override public void enrich(RagDocument document, RagChunk chunk, Map metadata) { metadata.put("customTag", "ingested"); } } )) .build()); Assert.assertNotNull(vectorStore.lastUpsertRequest); Assert.assertEquals("kb_docs", vectorStore.lastUpsertRequest.getDataset()); Assert.assertEquals(result.getChunks().size(), vectorStore.lastUpsertRequest.getRecords().size()); Assert.assertTrue(result.getChunks().size() >= 2); Assert.assertEquals(result.getChunks().size(), result.getUpsertedCount()); VectorRecord first = vectorStore.lastUpsertRequest.getRecords().get(0); Assert.assertEquals(first.getId(), result.getChunks().get(0).getChunkId()); Assert.assertEquals("ingested", first.getMetadata().get("customTag")); Assert.assertEquals("people-ops", first.getMetadata().get("department")); Assert.assertEquals("acme", first.getMetadata().get(RagMetadataKeys.TENANT)); Assert.assertEquals("hr", first.getMetadata().get(RagMetadataKeys.BIZ)); Assert.assertEquals("2026.03", first.getMetadata().get(RagMetadataKeys.VERSION)); Assert.assertEquals("员工手册", first.getMetadata().get(RagMetadataKeys.SOURCE_NAME)); Assert.assertEquals("/docs/handbook.md", first.getMetadata().get(RagMetadataKeys.SOURCE_PATH)); Assert.assertEquals(first.getContent(), first.getMetadata().get(RagMetadataKeys.CONTENT)); Assert.assertEquals(Arrays.asList(1.0f, (float) first.getContent().length()), first.getVector()); } @Test public void shouldLoadLocalFileThroughTikaLoader() throws Exception { File tempFile = File.createTempFile("ai4j-ingestion-", ".txt"); FileWriter writer = new FileWriter(tempFile); try { writer.write("alpha beta gamma"); } finally { writer.close(); } CapturingVectorStore vectorStore = new CapturingVectorStore(); IngestionPipeline pipeline = new IngestionPipeline(new FakeEmbeddingService(), vectorStore); IngestionResult result = pipeline.ingest(IngestionRequest.builder() .dataset("kb_files") .embeddingModel("text-embedding-3-small") .source(IngestionSource.file(tempFile)) .upsert(Boolean.FALSE) .build()); Assert.assertNotNull(result.getDocument()); Assert.assertEquals(tempFile.getName(), result.getDocument().getSourceName()); Assert.assertEquals(tempFile.getAbsolutePath(), result.getDocument().getSourcePath()); Assert.assertEquals(tempFile.toURI().toString(), result.getDocument().getSourceUri()); Assert.assertEquals(0, result.getUpsertedCount()); Assert.assertNull(vectorStore.lastUpsertRequest); Assert.assertFalse(result.getRecords().isEmpty()); Assert.assertEquals("text/plain", String.valueOf(result.getRecords().get(0).getMetadata().get("mimeType"))); tempFile.delete(); } @Test public void shouldSupportOcrFallbackProcessor() throws Exception { CapturingVectorStore vectorStore = new CapturingVectorStore(); IngestionPipeline pipeline = new IngestionPipeline( new FakeEmbeddingService(), vectorStore, Collections.singletonList(new BlankDocumentLoader()), new RecursiveTextChunker(1000, 0), Collections.singletonList(new OcrTextExtractingDocumentProcessor(new OcrTextExtractor() { @Override public boolean supports(IngestionSource source, LoadedDocument document) { return true; } @Override public String extractText(IngestionSource source, LoadedDocument document) { return "scanned contract text"; } })), Collections.singletonList(new MetadataEnricher() { @Override public void enrich(RagDocument document, RagChunk chunk, Map metadata) { metadata.put("testCase", "ocr"); } }) ); IngestionResult result = pipeline.ingest(IngestionRequest.builder() .dataset("kb_scan") .embeddingModel("text-embedding-3-small") .source(IngestionSource.builder() .name("scan.pdf") .build()) .upsert(Boolean.FALSE) .build()); Assert.assertEquals(1, result.getChunks().size()); Assert.assertEquals("scanned contract text", result.getChunks().get(0).getContent()); Assert.assertEquals(Boolean.TRUE, result.getRecords().get(0).getMetadata().get("ocrApplied")); Assert.assertEquals("ocr", result.getRecords().get(0).getMetadata().get("testCase")); } @Test public void shouldCleanOcrNoiseBeforeChunking() throws Exception { CapturingVectorStore vectorStore = new CapturingVectorStore(); IngestionPipeline pipeline = new IngestionPipeline(new FakeEmbeddingService(), vectorStore); IngestionResult result = pipeline.ingest(IngestionRequest.builder() .dataset("kb_clean") .embeddingModel("text-embedding-3-small") .source(IngestionSource.builder() .content("docu-\nment\n\n\nH e l l o") .build()) .documentProcessors(Collections.singletonList(new OcrNoiseCleaningDocumentProcessor())) .chunker(new RecursiveTextChunker(1000, 0)) .upsert(Boolean.FALSE) .build()); Assert.assertEquals(1, result.getChunks().size()); Assert.assertEquals("document\n\nHello", result.getChunks().get(0).getContent()); Assert.assertEquals(Boolean.TRUE, result.getRecords().get(0).getMetadata().get("whitespaceNormalized")); Assert.assertEquals(Boolean.TRUE, result.getRecords().get(0).getMetadata().get("ocrNoiseCleaned")); } private static class FakeEmbeddingService implements IEmbeddingService { @Override public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) { return embedding(embeddingReq); } @Override public EmbeddingResponse embedding(Embedding embeddingReq) { List inputs = extractInputs(embeddingReq.getInput()); List data = new ArrayList(inputs.size()); for (int i = 0; i < inputs.size(); i++) { data.add(EmbeddingObject.builder() .index(i) .object("embedding") .embedding(Arrays.asList((float) (i + 1), (float) inputs.get(i).length())) .build()); } return EmbeddingResponse.builder() .object("list") .model(embeddingReq.getModel()) .data(data) .build(); } @SuppressWarnings("unchecked") private List extractInputs(Object input) { if (input == null) { return Collections.emptyList(); } if (input instanceof List) { return (List) input; } return Collections.singletonList(String.valueOf(input)); } } private static class CapturingVectorStore implements VectorStore { private VectorUpsertRequest lastUpsertRequest; @Override public int upsert(VectorUpsertRequest request) { this.lastUpsertRequest = request; return request == null || request.getRecords() == null ? 0 : request.getRecords().size(); } @Override public List search(VectorSearchRequest request) { return Collections.emptyList(); } @Override public boolean delete(VectorDeleteRequest request) { return false; } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder() .dataset(true) .metadataFilter(true) .deleteByFilter(true) .returnStoredVector(true) .build(); } } private static class BlankDocumentLoader implements DocumentLoader { @Override public boolean supports(IngestionSource source) { return true; } @Override public LoadedDocument load(IngestionSource source) { return LoadedDocument.builder() .content("") .sourceName(source == null ? null : source.getName()) .sourcePath(source == null ? null : source.getPath()) .sourceUri(source == null ? null : source.getUri()) .metadata(source == null ? Collections.emptyMap() : source.getMetadata()) .build(); } } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/rag/ModelRerankerTest.java ================================================ package io.github.lnyocly.ai4j.rag; import io.github.lnyocly.ai4j.rerank.entity.RerankRequest; import io.github.lnyocly.ai4j.rerank.entity.RerankResponse; import io.github.lnyocly.ai4j.rerank.entity.RerankResult; import io.github.lnyocly.ai4j.service.IRerankService; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.List; public class ModelRerankerTest { @Test public void shouldReorderHitsByModelScoresAndKeepTail() throws Exception { ModelReranker reranker = new ModelReranker(new IRerankService() { @Override public RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) { return rerank(request); } @Override public RerankResponse rerank(RerankRequest request) { return RerankResponse.builder() .model(request.getModel()) .results(Arrays.asList( RerankResult.builder().index(1).relevanceScore(0.93f).build(), RerankResult.builder().index(0).relevanceScore(0.41f).build() )) .build(); } }, "jina-reranker-v2-base-multilingual", 2, null, false, true); List hits = reranker.rerank("vacation policy", Arrays.asList( RagHit.builder().id("a").content("doc-a").retrievalScore(0.55f).build(), RagHit.builder().id("b").content("doc-b").retrievalScore(0.52f).build(), RagHit.builder().id("c").content("doc-c").retrievalScore(0.40f).build() )); Assert.assertEquals(3, hits.size()); Assert.assertEquals("b", hits.get(0).getId()); Assert.assertEquals(Float.valueOf(0.93f), hits.get(0).getRerankScore()); Assert.assertEquals("a", hits.get(1).getId()); Assert.assertEquals(Float.valueOf(0.41f), hits.get(1).getRerankScore()); Assert.assertEquals("c", hits.get(2).getId()); Assert.assertNull(hits.get(2).getRerankScore()); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/rag/RagEvaluatorTest.java ================================================ package io.github.lnyocly.ai4j.rag; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.List; public class RagEvaluatorTest { @Test public void shouldComputeStandardRetrievalMetrics() { List hits = Arrays.asList( RagHit.builder().id("a").content("A").build(), RagHit.builder().id("b").content("B").build(), RagHit.builder().id("c").content("C").build(), RagHit.builder().id("d").content("D").build() ); RagEvaluation evaluation = new RagEvaluator().evaluate(hits, Arrays.asList("b", "d"), 4); Assert.assertEquals(Integer.valueOf(4), evaluation.getEvaluatedAtK()); Assert.assertEquals(Integer.valueOf(4), evaluation.getRetrievedCount()); Assert.assertEquals(Integer.valueOf(2), evaluation.getRelevantCount()); Assert.assertEquals(Integer.valueOf(2), evaluation.getTruePositiveCount()); Assert.assertEquals(0.5d, evaluation.getPrecisionAtK(), 0.0001d); Assert.assertEquals(1.0d, evaluation.getRecallAtK(), 0.0001d); Assert.assertEquals(0.6667d, evaluation.getF1AtK(), 0.001d); Assert.assertEquals(0.5d, evaluation.getMrr(), 0.0001d); Assert.assertEquals(0.6509d, evaluation.getNdcg(), 0.001d); } @Test public void shouldHandleNoRelevantDocuments() { List hits = Arrays.asList( RagHit.builder().id("a").content("A").build(), RagHit.builder().id("b").content("B").build() ); RagEvaluation evaluation = new RagEvaluator().evaluate(hits, Arrays.asList("z"), 2); Assert.assertEquals(0.0d, evaluation.getPrecisionAtK(), 0.0001d); Assert.assertEquals(0.0d, evaluation.getRecallAtK(), 0.0001d); Assert.assertEquals(0.0d, evaluation.getF1AtK(), 0.0001d); Assert.assertEquals(0.0d, evaluation.getMrr(), 0.0001d); Assert.assertEquals(0.0d, evaluation.getNdcg(), 0.0001d); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/skill/SkillsIChatServiceTest.java ================================================ package io.github.lnyocly.ai4j.skill; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.platform.openai.chat.OpenAiChatService; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tool.BuiltInTools; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Protocol; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; import okio.Buffer; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; public class SkillsIChatServiceTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldDiscoverSkillsAndAssemblePromptForBasicChatUsage() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("skills-basic-chat").toPath(); Path skillFile = writeSkill( workspaceRoot.resolve(".ai4j").resolve("skills").resolve("planner").resolve("SKILL.md"), "---\nname: planner\ndescription: Plan implementation steps.\n---\n" ); Skills.DiscoveryResult discovery = Skills.discoverDefault(workspaceRoot); Assert.assertEquals(1, discovery.getSkills().size()); Assert.assertEquals("planner", discovery.getSkills().get(0).getName()); Assert.assertEquals("workspace", discovery.getSkills().get(0).getSource()); Assert.assertTrue(discovery.getAllowedReadRoots().contains(skillFile.getParent().getParent().toString())); String systemPrompt = Skills.appendAvailableSkillsPrompt("Base prompt.", discovery.getSkills()); Assert.assertTrue(systemPrompt.contains("")); Assert.assertTrue(systemPrompt.contains(skillFile.toAbsolutePath().normalize().toString())); Assert.assertEquals(4, BuiltInTools.codingTools().size()); } @Test public void shouldAllowBasicIChatServiceToMountSkillsAndBuiltInTools() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("skills-chat-request").toPath(); Path skillFile = writeSkill( workspaceRoot.resolve(".ai4j").resolve("skills").resolve("reviewer").resolve("SKILL.md"), "# reviewer\nReview code changes for risks.\n" ); Skills.DiscoveryResult discovery = Skills.discoverDefault(workspaceRoot); String systemPrompt = Skills.appendAvailableSkillsPrompt("You are a helpful assistant.", discovery.getSkills()); AtomicReference requestJson = new AtomicReference(); IChatService chatService = new OpenAiChatService(configurationWithJsonResponse( "{" + "\"id\":\"resp_skill_1\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000000," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"\"," + "\"tool_calls\":[{" + "\"id\":\"call_skill_1\"," + "\"type\":\"function\"," + "\"function\":{" + "\"name\":\"read_file\"," + "\"arguments\":\"{\\\"path\\\":\\\"" + escapeJson(skillFile.toAbsolutePath().normalize().toString()) + "\\\"}\"" + "}" + "}]" + "}," + "\"finish_reason\":\"tool_calls\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":10," + "\"completion_tokens\":5," + "\"total_tokens\":15" + "}" + "}", requestJson )); ChatCompletion completion = ChatCompletion.builder() .model("gpt-test") .messages(Arrays.asList( ChatMessage.withSystem(systemPrompt), ChatMessage.withUser("Use the most relevant installed skill.") )) .tools(BuiltInTools.codingTools()) .build(); completion.setPassThroughToolCalls(Boolean.TRUE); ChatCompletionResponse response = chatService.chatCompletion(completion); Assert.assertNotNull(response); Assert.assertEquals("tool_calls", response.getChoices().get(0).getFinishReason()); Assert.assertEquals("read_file", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getName()); Assert.assertTrue(requestJson.get().contains("")); Assert.assertTrue(requestJson.get().contains("reviewer")); Assert.assertTrue(requestJson.get().contains(escapeJson(skillFile.toAbsolutePath().normalize().toString()))); Assert.assertTrue(requestJson.get().contains("\"name\":\"read_file\"")); } @Test public void shouldAutoInvokeBuiltInReadFileToolWhenContextConfigured() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("skills-auto-loop").toPath(); Path skillFile = writeSkill( workspaceRoot.resolve(".ai4j").resolve("skills").resolve("reviewer").resolve("SKILL.md"), "# reviewer\nReview code changes for risks.\n" ); Skills.DiscoveryResult discovery = Skills.discoverDefault(workspaceRoot); String systemPrompt = Skills.appendAvailableSkillsPrompt("You are a helpful assistant.", discovery.getSkills()); AtomicInteger requestCount = new AtomicInteger(); AtomicReference secondRequestBody = new AtomicReference(); String skillRelativePath = ".ai4j/skills/reviewer/SKILL.md"; IChatService chatService = new OpenAiChatService(configurationWithJsonResponses( requestCount, secondRequestBody, "{" + "\"id\":\"resp_skill_auto_1\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000000," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"\"," + "\"tool_calls\":[{" + "\"id\":\"call_skill_auto_1\"," + "\"type\":\"function\"," + "\"function\":{" + "\"name\":\"read_file\"," + "\"arguments\":\"{\\\"path\\\":\\\"" + skillRelativePath + "\\\"}\"" + "}" + "}]" + "}," + "\"finish_reason\":\"tool_calls\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":10," + "\"completion_tokens\":5," + "\"total_tokens\":15" + "}" + "}", "{" + "\"id\":\"resp_skill_auto_2\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000001," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"Skill loaded successfully.\"" + "}," + "\"finish_reason\":\"stop\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":12," + "\"completion_tokens\":7," + "\"total_tokens\":19" + "}" + "}" )); ChatCompletion completion = ChatCompletion.builder() .model("gpt-test") .messages(Arrays.asList( ChatMessage.withSystem(systemPrompt), ChatMessage.withUser("Use the most relevant installed skill.") )) .tools(BuiltInTools.codingTools()) .builtInToolContext(Skills.createToolContext(workspaceRoot, discovery)) .build(); ChatCompletionResponse response = chatService.chatCompletion(completion); Assert.assertNotNull(response); Assert.assertEquals(2, requestCount.get()); Assert.assertEquals("stop", response.getChoices().get(0).getFinishReason()); Assert.assertEquals("Skill loaded successfully.", response.getChoices().get(0).getMessage().getContent().getText()); Assert.assertTrue(secondRequestBody.get().contains("\"role\":\"tool\"")); Assert.assertTrue(secondRequestBody.get().contains("Review code changes for risks.")); } @Test public void shouldAllowLegacyFunctionsApiToUseBuiltInReadFile() throws Exception { Path repoRoot = Paths.get(".").toAbsolutePath().normalize(); Path workspaceRoot = repoRoot.resolve("target").resolve("skills-functions-chat"); Path skillFile = writeSkill( workspaceRoot.resolve(".ai4j").resolve("skills").resolve("reviewer").resolve("SKILL.md"), "# reviewer\nReview code changes for risks.\n" ); String skillRelativePath = repoRoot.relativize(skillFile.toAbsolutePath().normalize()).toString().replace('\\', '/'); Skills.DiscoveryResult discovery = Skills.discoverDefault(repoRoot, Arrays.asList(repoRoot.relativize(workspaceRoot.resolve(".ai4j").resolve("skills")).toString())); String systemPrompt = Skills.appendAvailableSkillsPrompt("You are a helpful assistant.", discovery.getSkills()); AtomicInteger requestCount = new AtomicInteger(); AtomicReference secondRequestBody = new AtomicReference(); IChatService chatService = new OpenAiChatService(configurationWithJsonResponses( requestCount, secondRequestBody, "{" + "\"id\":\"resp_skill_fn_1\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000000," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"\"," + "\"tool_calls\":[{" + "\"id\":\"call_skill_fn_1\"," + "\"type\":\"function\"," + "\"function\":{" + "\"name\":\"read_file\"," + "\"arguments\":\"{\\\"path\\\":\\\"" + skillRelativePath + "\\\"}\"" + "}" + "}]" + "}," + "\"finish_reason\":\"tool_calls\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":10," + "\"completion_tokens\":5," + "\"total_tokens\":15" + "}" + "}", "{" + "\"id\":\"resp_skill_fn_2\"," + "\"object\":\"chat.completion\"," + "\"created\":1710000001," + "\"model\":\"gpt-test\"," + "\"choices\":[{" + "\"index\":0," + "\"message\":{" + "\"role\":\"assistant\"," + "\"content\":\"Legacy functions API still works.\"" + "}," + "\"finish_reason\":\"stop\"" + "}]," + "\"usage\":{" + "\"prompt_tokens\":12," + "\"completion_tokens\":7," + "\"total_tokens\":19" + "}" + "}" )); ChatCompletion completion = ChatCompletion.builder() .model("gpt-test") .messages(Arrays.asList( ChatMessage.withSystem(systemPrompt), ChatMessage.withUser("Use the most relevant installed skill.") )) .functions("read_file") .build(); ChatCompletionResponse response = chatService.chatCompletion(completion); Assert.assertNotNull(response); Assert.assertEquals(2, requestCount.get()); Assert.assertEquals("stop", response.getChoices().get(0).getFinishReason()); Assert.assertEquals("Legacy functions API still works.", response.getChoices().get(0).getMessage().getContent().getText()); Assert.assertTrue(secondRequestBody.get().contains("\"tool_call_id\":\"call_skill_fn_1\"")); Assert.assertTrue(secondRequestBody.get().contains("Review code changes for risks.")); } private static Path writeSkill(Path skillFile, String content) throws Exception { Files.createDirectories(skillFile.getParent()); Files.write(skillFile, content.getBytes(StandardCharsets.UTF_8)); return skillFile; } private static String escapeJson(String value) { return value.replace("\\", "\\\\"); } private static Configuration configurationWithJsonResponse(String responseJson, AtomicReference requestJson) { OpenAiConfig openAiConfig = new OpenAiConfig(); openAiConfig.setApiHost("https://unit.test/"); openAiConfig.setApiKey("config-api-key"); OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(chain -> { if (requestJson != null) { requestJson.set(readRequestBody(chain.request().body())); } return new Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .body(ResponseBody.create(responseJson, MediaType.get("application/json"))) .build(); }) .build(); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); configuration.setOkHttpClient(okHttpClient); return configuration; } private static Configuration configurationWithJsonResponses(AtomicInteger requestCount, AtomicReference secondRequestBody, String firstResponseJson, String secondResponseJson) { OpenAiConfig openAiConfig = new OpenAiConfig(); openAiConfig.setApiHost("https://unit.test/"); openAiConfig.setApiKey("config-api-key"); OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(chain -> { int current = requestCount.incrementAndGet(); if (current == 2 && secondRequestBody != null) { secondRequestBody.set(readRequestBody(chain.request().body())); } String responseJson = current == 1 ? firstResponseJson : secondResponseJson; return new Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .body(ResponseBody.create(responseJson, MediaType.get("application/json"))) .build(); }) .build(); Configuration configuration = new Configuration(); configuration.setOpenAiConfig(openAiConfig); configuration.setOkHttpClient(okHttpClient); return configuration; } private static String readRequestBody(RequestBody body) { if (body == null) { return ""; } try { Buffer buffer = new Buffer(); body.writeTo(buffer); return buffer.readUtf8(); } catch (Exception ex) { throw new IllegalStateException("Failed to read request body", ex); } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutorTest.java ================================================ package io.github.lnyocly.ai4j.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; public class BuiltInToolExecutorTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldExecuteReadWriteAndApplyPatchTools() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("built-in-tools").toPath(); Files.write(workspaceRoot.resolve("notes.txt"), "hello\nworld\n".getBytes(StandardCharsets.UTF_8)); BuiltInToolContext context = BuiltInToolContext.builder() .workspaceRoot(workspaceRoot.toString()) .build(); JSONObject readResult = JSON.parseObject(ToolUtil.invoke( BuiltInTools.READ_FILE, "{\"path\":\"notes.txt\"}", context )); Assert.assertEquals("notes.txt", readResult.getString("path")); Assert.assertTrue(readResult.getString("content").contains("hello")); JSONObject writeResult = JSON.parseObject(ToolUtil.invoke( BuiltInTools.WRITE_FILE, "{\"path\":\"nested/output.txt\",\"content\":\"abc\",\"mode\":\"create\"}", context )); Assert.assertTrue(writeResult.getBooleanValue("created")); Assert.assertEquals("abc", new String(Files.readAllBytes(workspaceRoot.resolve("nested").resolve("output.txt")), StandardCharsets.UTF_8)); String patch = "*** Begin Patch\n" + "*** Update File: nested/output.txt\n" + "@@\n" + "-abc\n" + "+updated\n" + "*** End Patch\n"; JSONObject patchResult = JSON.parseObject(ToolUtil.invoke( BuiltInTools.APPLY_PATCH, JSON.toJSONString(new JSONObject() {{ put("patch", patch); }}), context )); Assert.assertEquals(1, patchResult.getIntValue("filesChanged")); Assert.assertEquals("updated", new String(Files.readAllBytes(workspaceRoot.resolve("nested").resolve("output.txt")), StandardCharsets.UTF_8)); Assert.assertEquals(1, BuiltInTools.tools(BuiltInTools.READ_FILE).size()); } @Test public void shouldExecuteBashToolsWithinContext() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("built-in-bash").toPath(); BuiltInToolContext context = BuiltInToolContext.builder() .workspaceRoot(workspaceRoot.toString()) .build(); JSONObject execResult = JSON.parseObject(ToolUtil.invoke( BuiltInTools.BASH, "{\"action\":\"exec\",\"command\":\"echo hello\"}", context )); Assert.assertEquals(0, execResult.getIntValue("exitCode")); Assert.assertTrue(execResult.getString("stdout").toLowerCase().contains("hello")); String command = isWindows() ? "ping 127.0.0.1 -n 6 >nul" : "sleep 5"; JSONObject startResult = JSON.parseObject(ToolUtil.invoke( BuiltInTools.BASH, JSON.toJSONString(new JSONObject() {{ put("action", "start"); put("command", command); }}), context )); String processId = startResult.getString("processId"); Assert.assertNotNull(processId); JSONObject listResult = JSON.parseObject(ToolUtil.invoke( BuiltInTools.BASH, "{\"action\":\"list\"}", context )); Assert.assertFalse(listResult.getJSONArray("processes").isEmpty()); JSONObject stopResult = JSON.parseObject(ToolUtil.invoke( BuiltInTools.BASH, JSON.toJSONString(new JSONObject() {{ put("action", "stop"); put("processId", processId); }}), context )); Assert.assertEquals(processId, stopResult.getString("processId")); Assert.assertEquals("STOPPED", stopResult.getString("status")); } private static boolean isWindows() { return System.getProperty("os.name", "").toLowerCase().contains("win"); } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/vector/store/milvus/MilvusVectorStoreTest.java ================================================ package io.github.lnyocly.ai4j.vector.store.milvus; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import io.github.lnyocly.ai4j.config.MilvusConfig; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import okhttp3.OkHttpClient; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; public class MilvusVectorStoreTest { @Test public void shouldUpsertAndSearchAgainstMilvusHttpApi() throws Exception { AtomicReference upsertBody = new AtomicReference(); AtomicReference searchBody = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/v2/vectordb/entities/upsert", jsonHandler("{\"code\":0}", upsertBody)); server.createContext("/v2/vectordb/entities/search", jsonHandler( "{\"data\":[{\"id\":\"1\",\"distance\":0.08,\"entity\":{\"content\":\"snippet\",\"sourceName\":\"handbook.pdf\",\"pageNumber\":3}}]}", searchBody)); server.start(); try { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); MilvusConfig milvusConfig = new MilvusConfig(); milvusConfig.setHost("http://127.0.0.1:" + server.getAddress().getPort()); configuration.setMilvusConfig(milvusConfig); MilvusVectorStore store = new MilvusVectorStore(configuration); int inserted = store.upsert(VectorUpsertRequest.builder() .dataset("demo_collection") .records(Collections.singletonList(VectorRecord.builder() .id("doc-1") .vector(Arrays.asList(0.1f, 0.2f)) .content("hello milvus") .metadata(mapOf("sourceName", "handbook.pdf")) .build())) .build()); List results = store.search(VectorSearchRequest.builder() .dataset("demo_collection") .vector(Arrays.asList(0.2f, 0.3f)) .topK(2) .filter(mapOf("tenant", "acme")) .build()); Assert.assertEquals(1, inserted); Assert.assertTrue(upsertBody.get().contains("\"collectionName\":\"demo_collection\"")); Assert.assertTrue(upsertBody.get().contains("\"sourceName\":\"handbook.pdf\"")); Assert.assertTrue(searchBody.get().contains("\"annsField\":\"vector\"")); Assert.assertTrue(searchBody.get().contains("\"tenant == \\\"acme\\\"\"")); Assert.assertEquals(1, results.size()); Assert.assertEquals("1", results.get(0).getId()); Assert.assertEquals("snippet", results.get(0).getContent()); Assert.assertTrue(results.get(0).getScore() > 0.9f); } finally { server.stop(0); } } private HttpHandler jsonHandler(final String responseBody, final AtomicReference requestBody) { return new HttpHandler() { @Override public void handle(HttpExchange exchange) { try { requestBody.set(read(exchange.getRequestBody())); byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, response.length); OutputStream outputStream = exchange.getResponseBody(); try { outputStream.write(response); } finally { outputStream.close(); } } catch (Exception ex) { throw new RuntimeException(ex); } } }; } private String read(InputStream inputStream) throws Exception { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[256]; int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/vector/store/pinecone/PineconeVectorStoreTest.java ================================================ package io.github.lnyocly.ai4j.vector.store.pinecone; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete; import io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert; import io.github.lnyocly.ai4j.vector.pinecone.PineconeInsertResponse; import io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery; import io.github.lnyocly.ai4j.vector.pinecone.PineconeQueryResponse; import io.github.lnyocly.ai4j.vector.service.PineconeService; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class PineconeVectorStoreTest { @Test public void shouldConvertUpsertRecordsToPineconeRequest() throws Exception { CapturingPineconeService pineconeService = new CapturingPineconeService(); PineconeVectorStore store = new PineconeVectorStore(pineconeService); int inserted = store.upsert(VectorUpsertRequest.builder() .dataset("tenant_a") .records(Collections.singletonList(VectorRecord.builder() .id("doc-1") .vector(Arrays.asList(0.1f, 0.2f)) .content("hello rag") .metadata(mapOf("sourceName", "manual.pdf")) .build())) .build()); Assert.assertEquals(1, inserted); Assert.assertNotNull(pineconeService.lastInsert); Assert.assertEquals("tenant_a", pineconeService.lastInsert.getNamespace()); Assert.assertEquals("doc-1", pineconeService.lastInsert.getVectors().get(0).getId()); Assert.assertEquals("hello rag", pineconeService.lastInsert.getVectors().get(0).getMetadata().get("content")); Assert.assertEquals("manual.pdf", pineconeService.lastInsert.getVectors().get(0).getMetadata().get("sourceName")); } @Test public void shouldConvertPineconeMatchesToVectorSearchResults() throws Exception { CapturingPineconeService pineconeService = new CapturingPineconeService(); PineconeVectorStore store = new PineconeVectorStore(pineconeService); List results = store.search(VectorSearchRequest.builder() .dataset("tenant_b") .vector(Arrays.asList(0.3f, 0.4f)) .topK(3) .filter(mapOf("tenant", "acme")) .build()); Assert.assertNotNull(pineconeService.lastQuery); Assert.assertEquals("tenant_b", pineconeService.lastQuery.getNamespace()); Assert.assertEquals(Integer.valueOf(3), pineconeService.lastQuery.getTopK()); Assert.assertEquals("acme", pineconeService.lastQuery.getFilter().get("tenant")); Assert.assertEquals(1, results.size()); Assert.assertEquals("match-1", results.get(0).getId()); Assert.assertEquals("retrieved snippet", results.get(0).getContent()); } @Test public void shouldConvertDeleteRequestToPineconeDelete() throws Exception { CapturingPineconeService pineconeService = new CapturingPineconeService(); PineconeVectorStore store = new PineconeVectorStore(pineconeService); boolean deleted = store.delete(VectorDeleteRequest.builder() .dataset("tenant_c") .ids(Collections.singletonList("doc-1")) .build()); Assert.assertTrue(deleted); Assert.assertNotNull(pineconeService.lastDelete); Assert.assertEquals("tenant_c", pineconeService.lastDelete.getNamespace()); Assert.assertEquals("doc-1", pineconeService.lastDelete.getIds().get(0)); } private static class CapturingPineconeService extends PineconeService { private PineconeInsert lastInsert; private PineconeQuery lastQuery; private PineconeDelete lastDelete; private CapturingPineconeService() { super(new Configuration()); } @Override public Integer insert(PineconeInsert pineconeInsertReq) { this.lastInsert = pineconeInsertReq; return 1; } @Override public PineconeQueryResponse query(PineconeQuery pineconeQueryReq) { this.lastQuery = pineconeQueryReq; PineconeQueryResponse.Match match = new PineconeQueryResponse.Match(); match.setId("match-1"); match.setScore(0.92f); match.setMetadata(new LinkedHashMap()); match.getMetadata().put("content", "retrieved snippet"); match.getMetadata().put("sourceName", "manual.pdf"); PineconeQueryResponse response = new PineconeQueryResponse(); response.setMatches(Collections.singletonList(match)); return response; } @Override public Boolean delete(PineconeDelete pineconeDeleteReq) { this.lastDelete = pineconeDeleteReq; return true; } } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/ai4j/vector/store/qdrant/QdrantVectorStoreTest.java ================================================ package io.github.lnyocly.ai4j.vector.store.qdrant; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import io.github.lnyocly.ai4j.config.QdrantConfig; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.vector.store.VectorRecord; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import okhttp3.OkHttpClient; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; public class QdrantVectorStoreTest { @Test public void shouldUpsertAndSearchAgainstQdrantHttpApi() throws Exception { AtomicReference upsertBody = new AtomicReference(); AtomicReference queryBody = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/collections/demo/points", jsonHandler("{\"status\":\"ok\"}", upsertBody)); server.createContext("/collections/demo/points/query", jsonHandler( "{\"result\":{\"points\":[{\"id\":\"pt-1\",\"score\":0.87,\"payload\":{\"content\":\"snippet\",\"sourceName\":\"manual.pdf\"}}]}}", queryBody)); server.start(); try { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); QdrantConfig qdrantConfig = new QdrantConfig(); qdrantConfig.setHost("http://127.0.0.1:" + server.getAddress().getPort()); configuration.setQdrantConfig(qdrantConfig); QdrantVectorStore store = new QdrantVectorStore(configuration); int inserted = store.upsert(VectorUpsertRequest.builder() .dataset("demo") .records(Collections.singletonList(VectorRecord.builder() .id("doc-1") .vector(Arrays.asList(0.1f, 0.2f)) .content("hello") .metadata(mapOf("sourceName", "manual.pdf")) .build())) .build()); List results = store.search(VectorSearchRequest.builder() .dataset("demo") .vector(Arrays.asList(0.2f, 0.3f)) .topK(3) .filter(mapOf("tenant", "acme")) .build()); Assert.assertEquals(1, inserted); Assert.assertTrue(upsertBody.get().contains("\"points\"")); Assert.assertTrue(upsertBody.get().contains("\"sourceName\":\"manual.pdf\"")); Assert.assertTrue(queryBody.get().contains("\"limit\":3")); Assert.assertTrue(queryBody.get().contains("\"tenant\"")); Assert.assertEquals(1, results.size()); Assert.assertEquals("pt-1", results.get(0).getId()); Assert.assertEquals("snippet", results.get(0).getContent()); } finally { server.stop(0); } } private HttpHandler jsonHandler(final String responseBody, final AtomicReference requestBody) { return new HttpHandler() { @Override public void handle(HttpExchange exchange) { try { requestBody.set(read(exchange.getRequestBody())); byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, response.length); OutputStream outputStream = exchange.getResponseBody(); try { outputStream.write(response); } finally { outputStream.close(); } } catch (Exception ex) { throw new RuntimeException(ex); } } }; } private String read(InputStream inputStream) throws Exception { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[256]; int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/interceptor/ErrorInterceptorTest.java ================================================ package io.github.lnyocly.interceptor; import io.github.lnyocly.ai4j.exception.CommonException; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import okhttp3.Call; import okhttp3.Connection; import okhttp3.Interceptor; import okhttp3.MediaType; import okhttp3.Protocol; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; import org.junit.Assert; import org.junit.Test; import java.io.IOException; import java.util.concurrent.TimeUnit; public class ErrorInterceptorTest { @Test public void test_completed_response_with_text_error_keyword_should_not_throw() throws Exception { ErrorInterceptor interceptor = new ErrorInterceptor(); Request request = new Request.Builder().url("https://example.com/test").build(); String payload = "{\"id\":\"resp_x\",\"status\":\"completed\",\"output\":[{\"content\":[{\"text\":\"Error Responses section\"}]}]}"; Response response = buildResponse(request, 200, "OK", payload, "application/json"); Response intercepted = interceptor.intercept(new FixedResponseChain(request, response)); Assert.assertEquals(200, intercepted.code()); Assert.assertNotNull(intercepted.body()); Assert.assertTrue(intercepted.body().string().contains("Error Responses section")); } @Test(expected = CommonException.class) public void test_hunyuan_error_shape_in_success_response_should_throw() throws Exception { ErrorInterceptor interceptor = new ErrorInterceptor(); Request request = new Request.Builder().url("https://example.com/test").build(); String payload = "{\"Response\":{\"Error\":{\"Code\":\"InvalidParameter\",\"Message\":\"bad request\"}}}"; Response response = buildResponse(request, 200, "OK", payload, "application/json"); interceptor.intercept(new FixedResponseChain(request, response)); } private Response buildResponse(Request request, int code, String message, String body, String contentType) { return new Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(code) .message(message) .body(ResponseBody.create(MediaType.get(contentType), body)) .build(); } private static class FixedResponseChain implements Interceptor.Chain { private final Request request; private final Response response; private FixedResponseChain(Request request, Response response) { this.request = request; this.response = response; } @Override public Request request() { return request; } @Override public Response proceed(Request request) throws IOException { return response.newBuilder().request(request).build(); } @Override public Connection connection() { return null; } @Override public Call call() { return null; } @Override public int connectTimeoutMillis() { return 0; } @Override public Interceptor.Chain withConnectTimeout(int timeout, TimeUnit unit) { return this; } @Override public int readTimeoutMillis() { return 0; } @Override public Interceptor.Chain withReadTimeout(int timeout, TimeUnit unit) { return this; } @Override public int writeTimeoutMillis() { return 0; } @Override public Interceptor.Chain withWriteTimeout(int timeout, TimeUnit unit) { return this; } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/listener/SseListenerTest.java ================================================ package io.github.lnyocly.listener; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; public class SseListenerTest { @Test public void shouldIgnoreEmptyToolCallDeltaWhenFinishReasonIsToolCalls() { RecordingSseListener listener = new RecordingSseListener(); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}]}"); } @Test public void shouldIgnoreToolCallDeltaWithoutFunctionPayload() { RecordingSseListener listener = new RecordingSseListener(); listener.setShowToolArgs(true); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{}]},\"finish_reason\":null}]}"); } @Test public void shouldFinalizeFragmentedToolCallArgumentsWhenFinishReasonArrivesWithoutDelta() { RecordingSseListener listener = new RecordingSseListener(); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"bash\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}"); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"action\\\":\\\"exec\\\",\"}}]},\"finish_reason\":null}]}"); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"command\\\":\\\"date\\\"}\"}}]},\"finish_reason\":null}]}"); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}"); Assert.assertEquals(1, listener.getToolCalls().size()); ToolCall toolCall = listener.getToolCalls().get(0); Assert.assertEquals("bash", toolCall.getFunction().getName()); Assert.assertEquals("{\"action\":\"exec\",\"command\":\"date\"}", toolCall.getFunction().getArguments()); } @Test public void shouldAggregateMiniMaxStyleToolCallFragmentsIntoSingleCall() { RecordingSseListener listener = new RecordingSseListener(); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"delegate_plan\",\"arguments\":\"{\\\"task\\\": \\\"Create a short implementation plan\"}}]},\"finish_reason\":null}]}"); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"function\":{\"arguments\":\" for adding a hello endpoint demo app in this empty workspace.\"}}]},\"finish_reason\":null}]}"); listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}"); Assert.assertEquals(1, listener.getToolCalls().size()); ToolCall toolCall = listener.getToolCalls().get(0); Assert.assertEquals("delegate_plan", toolCall.getFunction().getName()); Assert.assertEquals("{\"task\": \"Create a short implementation plan for adding a hello endpoint demo app in this empty workspace.\"}", toolCall.getFunction().getArguments()); } @Test public void shouldNotWriteBlankLinesToStdoutForEscapedNewlinePayloads() throws Exception { RecordingSseListener listener = new RecordingSseListener(); ByteArrayOutputStream stdout = new ByteArrayOutputStream(); PrintStream originalOut = System.out; System.setOut(new PrintStream(stdout, true, "UTF-8")); try { listener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"line1\\\\nline2\"},\"finish_reason\":\"stop\"}]}"); } finally { System.setOut(originalOut); } Assert.assertEquals("", stdout.toString(StandardCharsets.UTF_8.name())); } private static final class RecordingSseListener extends SseListener { @Override protected void send() { } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/listener/StreamExecutionSupportTest.java ================================================ package io.github.lnyocly.listener; import io.github.lnyocly.ai4j.listener.ManagedStreamListener; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.listener.StreamExecutionSupport; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; public class StreamExecutionSupportTest { @Test public void shouldRetryBeforeFirstEventAndStopAfterSuccess() throws Exception { RecordingManagedStreamListener listener = new RecordingManagedStreamListener(); AtomicInteger attempts = new AtomicInteger(); StreamExecutionSupport.execute( listener, StreamExecutionOptions.builder().maxRetries(2).retryBackoffMs(0L).build(), () -> { if (attempts.incrementAndGet() == 1) { listener.recordFailure(new RuntimeException("connect failed")); } else { listener.clearFailure(); } } ); Assert.assertEquals(2, attempts.get()); Assert.assertEquals(1, listener.retries.size()); Assert.assertEquals("connect failed|2/3", listener.retries.get(0)); Assert.assertEquals(1, listener.prepareForRetryCalls); Assert.assertNull(listener.getFailure()); } @Test public void shouldNotRetryAfterFirstEventArrives() throws Exception { RecordingManagedStreamListener listener = new RecordingManagedStreamListener(); listener.receivedEvent = true; AtomicInteger attempts = new AtomicInteger(); StreamExecutionSupport.execute( listener, StreamExecutionOptions.builder().maxRetries(3).retryBackoffMs(0L).build(), () -> { attempts.incrementAndGet(); listener.recordFailure(new RuntimeException("stream stalled")); } ); Assert.assertEquals(1, attempts.get()); Assert.assertEquals(0, listener.retries.size()); Assert.assertEquals(0, listener.prepareForRetryCalls); Assert.assertNotNull(listener.getFailure()); } @Test public void shouldApplySafeDefaultTimeoutsWhenOptionsAreMissing() throws Exception { RecordingManagedStreamListener listener = new RecordingManagedStreamListener(); StreamExecutionSupport.execute(listener, null, new StreamExecutionSupport.StreamStarter() { @Override public void start() { } }); Assert.assertNotNull(listener.awaitedOptions); Assert.assertEquals(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_MS, listener.awaitedOptions.getFirstTokenTimeoutMs()); Assert.assertEquals(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_MS, listener.awaitedOptions.getIdleTimeoutMs()); Assert.assertEquals(StreamExecutionSupport.DEFAULT_MAX_RETRIES, listener.awaitedOptions.getMaxRetries()); Assert.assertEquals(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_MS, listener.awaitedOptions.getRetryBackoffMs()); } @Test public void shouldHonorConfiguredDefaultTimeoutOverrides() throws Exception { RecordingManagedStreamListener listener = new RecordingManagedStreamListener(); String firstTokenPrevious = System.getProperty(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY); String idlePrevious = System.getProperty(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_PROPERTY); String retriesPrevious = System.getProperty(StreamExecutionSupport.DEFAULT_MAX_RETRIES_PROPERTY); String backoffPrevious = System.getProperty(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_PROPERTY); try { System.setProperty(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY, "1234"); System.setProperty(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_PROPERTY, "5678"); System.setProperty(StreamExecutionSupport.DEFAULT_MAX_RETRIES_PROPERTY, "2"); System.setProperty(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_PROPERTY, "250"); StreamExecutionSupport.execute(listener, null, new StreamExecutionSupport.StreamStarter() { @Override public void start() { } }); Assert.assertNotNull(listener.awaitedOptions); Assert.assertEquals(1234L, listener.awaitedOptions.getFirstTokenTimeoutMs()); Assert.assertEquals(5678L, listener.awaitedOptions.getIdleTimeoutMs()); Assert.assertEquals(2, listener.awaitedOptions.getMaxRetries()); Assert.assertEquals(250L, listener.awaitedOptions.getRetryBackoffMs()); } finally { restoreProperty(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY, firstTokenPrevious); restoreProperty(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_PROPERTY, idlePrevious); restoreProperty(StreamExecutionSupport.DEFAULT_MAX_RETRIES_PROPERTY, retriesPrevious); restoreProperty(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_PROPERTY, backoffPrevious); } } private static final class RecordingManagedStreamListener implements ManagedStreamListener { private Throwable failure; private boolean receivedEvent; private boolean cancelRequested; private int prepareForRetryCalls; private final List retries = new ArrayList(); private StreamExecutionOptions awaitedOptions; @Override public void awaitCompletion(StreamExecutionOptions options) { this.awaitedOptions = options; } @Override public Throwable getFailure() { return failure; } @Override public void recordFailure(Throwable failure) { this.failure = failure; } @Override public void clearFailure() { this.failure = null; } @Override public void prepareForRetry() { prepareForRetryCalls += 1; clearFailure(); receivedEvent = false; } @Override public boolean hasReceivedEvent() { return receivedEvent; } @Override public boolean isCancelRequested() { return cancelRequested; } @Override public void onRetrying(Throwable failure, int attempt, int maxAttempts) { String message = failure == null ? "unknown" : failure.getMessage(); retries.add(message + "|" + attempt + "/" + maxAttempts); } } private static void restoreProperty(String key, String value) { if (value == null) { System.clearProperty(key); } else { System.setProperty(key, value); } } } ================================================ FILE: ai4j/src/test/java/io/github/lnyocly/service/AiServiceRegistryTest.java ================================================ package io.github.lnyocly.service; import io.github.lnyocly.ai4j.config.AiPlatform; import io.github.lnyocly.ai4j.config.JinaConfig; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.platform.jina.rerank.JinaRerankService; import io.github.lnyocly.ai4j.platform.openai.chat.OpenAiChatService; import io.github.lnyocly.ai4j.rag.Reranker; import io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline; import io.github.lnyocly.ai4j.service.AiConfig; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.service.factory.AiServiceRegistration; import io.github.lnyocly.ai4j.service.factory.AiServiceRegistry; import io.github.lnyocly.ai4j.service.factory.DefaultAiServiceRegistry; import io.github.lnyocly.ai4j.service.factory.FreeAiService; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import okhttp3.OkHttpClient; import org.junit.Assert; import org.junit.Test; import java.util.Collections; public class AiServiceRegistryTest { @Test public void shouldBuildRegistryFromConfiguredPlatforms() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); AiPlatform aiPlatform = new AiPlatform(); aiPlatform.setId("tenant-a-openai"); aiPlatform.setPlatform("openai"); aiPlatform.setApiHost("https://example-openai.local/"); aiPlatform.setApiKey("sk-test"); AiConfig aiConfig = new AiConfig(); aiConfig.setPlatforms(Collections.singletonList(aiPlatform)); AiServiceRegistry registry = DefaultAiServiceRegistry.from(configuration, aiConfig); AiServiceRegistration registration = registry.get("tenant-a-openai"); AiService aiService = registration.getAiService(); Assert.assertTrue(registry.contains("tenant-a-openai")); Assert.assertEquals(PlatformType.OPENAI, registration.getPlatformType()); Assert.assertTrue(registry.getChatService("tenant-a-openai") instanceof OpenAiChatService); Assert.assertNotNull(aiService); Assert.assertNotNull(aiService.getConfiguration()); Assert.assertNotSame(configuration, aiService.getConfiguration()); OpenAiConfig scopedOpenAiConfig = aiService.getConfiguration().getOpenAiConfig(); Assert.assertEquals("https://example-openai.local/", scopedOpenAiConfig.getApiHost()); Assert.assertEquals("sk-test", scopedOpenAiConfig.getApiKey()); } @Test @SuppressWarnings("deprecation") public void shouldKeepFreeAiServiceAsCompatibilityShell() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); AiPlatform aiPlatform = new AiPlatform(); aiPlatform.setId("tenant-a-openai"); aiPlatform.setPlatform("openai"); aiPlatform.setApiHost("https://example-openai.local/"); aiPlatform.setApiKey("sk-test"); AiConfig aiConfig = new AiConfig(); aiConfig.setPlatforms(Collections.singletonList(aiPlatform)); new FreeAiService(configuration, aiConfig); Assert.assertTrue(FreeAiService.contains("tenant-a-openai")); Assert.assertTrue(FreeAiService.getChatService("tenant-a-openai") instanceof OpenAiChatService); Assert.assertNull(FreeAiService.getChatService("missing")); } @Test public void shouldFailFastWhenPlatformIsUnsupported() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); AiPlatform aiPlatform = new AiPlatform(); aiPlatform.setId("tenant-a-unknown"); aiPlatform.setPlatform("unknown-provider"); AiConfig aiConfig = new AiConfig(); aiConfig.setPlatforms(Collections.singletonList(aiPlatform)); try { DefaultAiServiceRegistry.from(configuration, aiConfig); Assert.fail("Expected unsupported platform to fail fast"); } catch (IllegalArgumentException e) { Assert.assertEquals("Unsupported ai platform 'unknown-provider' for id 'tenant-a-unknown'", e.getMessage()); } } @Test public void shouldExposeJinaCompatibleRerankServiceFromRegistry() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); AiPlatform aiPlatform = new AiPlatform(); aiPlatform.setId("tenant-a-rerank"); aiPlatform.setPlatform("jina"); aiPlatform.setApiHost("https://api.jina.ai/"); aiPlatform.setApiKey("jina-key"); aiPlatform.setRerankUrl("v1/rerank"); AiConfig aiConfig = new AiConfig(); aiConfig.setPlatforms(Collections.singletonList(aiPlatform)); AiServiceRegistry registry = DefaultAiServiceRegistry.from(configuration, aiConfig); AiServiceRegistration registration = registry.get("tenant-a-rerank"); Assert.assertEquals(PlatformType.JINA, registration.getPlatformType()); Assert.assertTrue(registry.getRerankService("tenant-a-rerank") instanceof JinaRerankService); JinaConfig scopedJinaConfig = registration.getAiService().getConfiguration().getJinaConfig(); Assert.assertEquals("https://api.jina.ai/", scopedJinaConfig.getApiHost()); Assert.assertEquals("jina-key", scopedJinaConfig.getApiKey()); Assert.assertEquals("v1/rerank", scopedJinaConfig.getRerankUrl()); Reranker reranker = registry.getModelReranker( "tenant-a-rerank", "jina-reranker-v2-base-multilingual", 5, "优先制度原文" ); Assert.assertNotNull(reranker); } @Test public void shouldExposeIngestionPipelineFromRegistryAndCompatibilityShell() { Configuration configuration = new Configuration(); configuration.setOkHttpClient(new OkHttpClient()); AiPlatform aiPlatform = new AiPlatform(); aiPlatform.setId("tenant-a-openai"); aiPlatform.setPlatform("openai"); aiPlatform.setApiHost("https://example-openai.local/"); aiPlatform.setApiKey("sk-test"); AiConfig aiConfig = new AiConfig(); aiConfig.setPlatforms(Collections.singletonList(aiPlatform)); AiServiceRegistry registry = DefaultAiServiceRegistry.from(configuration, aiConfig); VectorStore vectorStore = new NoopVectorStore(); IngestionPipeline pipeline = registry.getIngestionPipeline("tenant-a-openai", vectorStore); Assert.assertNotNull(pipeline); new FreeAiService(registry); Assert.assertNotNull(FreeAiService.getIngestionPipeline("tenant-a-openai", vectorStore)); } private static class NoopVectorStore implements VectorStore { @Override public int upsert(io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest request) { return 0; } @Override public java.util.List search(VectorSearchRequest request) { return Collections.emptyList(); } @Override public boolean delete(VectorDeleteRequest request) { return false; } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder().dataset(true).metadataFilter(true).build(); } } } ================================================ FILE: ai4j-agent/pom.xml ================================================ 4.0.0 io.github.lnyo-cly ai4j-sdk 2.3.0 ai4j-agent jar ai4j-agent ai4j 通用 Agent 运行时模块,支持 ReAct、subagent、team、memory 与 trace。 Agent runtime module for ReAct, subagents, teams, memory, and tracing. The Apache License, Version 2.0 https://www.apache.org/licenses/LICENSE-2.0.txt GitHub https://github.com/LnYo-Cly/ai4j/issues https://github.com/LnYo-Cly/ai4j LnYo-Cly LnYo-Cly lnyocly@gmail.com https://github.com/LnYo-Cly/ai4j +8 https://github.com/LnYo-Cly/ai4j scm:git:https://github.com/LnYo-Cly/ai4j.git scm:git:https://github.com/LnYo-Cly/ai4j.git io.github.lnyo-cly ai4j ${project.version} com.alibaba.fastjson2 fastjson2 2.0.43 org.graalvm.sdk graal-sdk 20.3.4 org.projectlombok lombok 1.18.30 org.slf4j slf4j-api 1.7.30 io.opentelemetry opentelemetry-api 1.39.0 junit junit 4.13.2 test com.h2database h2 2.2.224 test release org.codehaus.mojo flatten-maven-plugin ${flatten-maven-plugin.version} ossrh flatten process-resources flatten flatten-clean clean clean org.apache.maven.plugins maven-source-plugin 3.3.1 attach-sources jar-no-fork org.apache.maven.plugins maven-javadoc-plugin 3.6.3 attach-javadocs jar none false Author a Author: Description a Description: Date a Date: org.apache.maven.plugins maven-gpg-plugin 1.6 D:\Develop\DevelopEnv\GnuPG\bin\gpg.exe cly sign-artifacts verify sign org.sonatype.central central-publishing-maven-plugin 0.4.0 true LnYo-Cly true ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agent.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.memory.AgentMemory; import java.util.function.Supplier; public class Agent { private final AgentRuntime runtime; private final AgentContext baseContext; private final Supplier memorySupplier; public Agent(AgentRuntime runtime, AgentContext baseContext, Supplier memorySupplier) { this.runtime = runtime; this.baseContext = baseContext; this.memorySupplier = memorySupplier; } public AgentResult run(AgentRequest request) throws Exception { return runtime.run(baseContext, request); } public void runStream(AgentRequest request, AgentListener listener) throws Exception { runtime.runStream(baseContext, request, listener); } public AgentResult runStreamResult(AgentRequest request, AgentListener listener) throws Exception { return runtime.runStreamResult(baseContext, request, listener); } public AgentSession newSession() { AgentMemory memory = memorySupplier == null ? baseContext.getMemory() : memorySupplier.get(); AgentContext sessionContext = baseContext.toBuilder().memory(memory).build(); return new AgentSession(runtime, sessionContext); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentBuilder.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.agent.codeact.CodeActOptions; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutor; import io.github.lnyocly.ai4j.agent.codeact.GraalVmCodeExecutor; import io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor; import io.github.lnyocly.ai4j.agent.event.AgentEventPublisher; import io.github.lnyocly.ai4j.agent.memory.AgentMemory; import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.runtime.ReActRuntime; import io.github.lnyocly.ai4j.agent.subagent.StaticSubAgentRegistry; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry; import io.github.lnyocly.ai4j.agent.subagent.SubAgentToolExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.agent.trace.AgentTraceListener; import io.github.lnyocly.ai4j.agent.trace.TraceConfig; import io.github.lnyocly.ai4j.agent.trace.TraceExporter; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Supplier; public class AgentBuilder { private AgentRuntime runtime; private AgentModelClient modelClient; private AgentToolRegistry toolRegistry; private SubAgentRegistry subAgentRegistry; private HandoffPolicy handoffPolicy; private final List subAgentDefinitions = new ArrayList<>(); private ToolExecutor toolExecutor; private CodeExecutor codeExecutor; private Supplier memorySupplier; private AgentOptions options; private CodeActOptions codeActOptions; private TraceExporter traceExporter; private TraceConfig traceConfig; private AgentEventPublisher eventPublisher; private String model; private String instructions; private String systemPrompt; private Double temperature; private Double topP; private Integer maxOutputTokens; private Object reasoning; private Object toolChoice; private Boolean parallelToolCalls; private Boolean store; private String user; private Map extraBody; public AgentBuilder runtime(AgentRuntime runtime) { this.runtime = runtime; return this; } public AgentBuilder modelClient(AgentModelClient modelClient) { this.modelClient = modelClient; return this; } public AgentBuilder toolRegistry(AgentToolRegistry toolRegistry) { this.toolRegistry = toolRegistry; return this; } public AgentBuilder toolRegistry(List functions, List mcpServices) { this.toolRegistry = createToolUtilRegistry(functions, mcpServices); return this; } public AgentBuilder subAgentRegistry(SubAgentRegistry subAgentRegistry) { this.subAgentRegistry = subAgentRegistry; return this; } public AgentBuilder handoffPolicy(HandoffPolicy handoffPolicy) { this.handoffPolicy = handoffPolicy; return this; } public AgentBuilder subAgent(SubAgentDefinition definition) { if (definition != null) { this.subAgentDefinitions.add(definition); } return this; } public AgentBuilder subAgents(List definitions) { if (definitions != null && !definitions.isEmpty()) { this.subAgentDefinitions.addAll(definitions); } return this; } public AgentBuilder toolExecutor(ToolExecutor toolExecutor) { this.toolExecutor = toolExecutor; return this; } public AgentBuilder codeExecutor(CodeExecutor codeExecutor) { this.codeExecutor = codeExecutor; return this; } public AgentBuilder memorySupplier(Supplier memorySupplier) { this.memorySupplier = memorySupplier; return this; } public AgentBuilder options(AgentOptions options) { this.options = options; return this; } public AgentBuilder codeActOptions(CodeActOptions codeActOptions) { this.codeActOptions = codeActOptions; return this; } public AgentBuilder traceExporter(TraceExporter traceExporter) { this.traceExporter = traceExporter; return this; } public AgentBuilder traceConfig(TraceConfig traceConfig) { this.traceConfig = traceConfig; return this; } public AgentBuilder eventPublisher(AgentEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; return this; } public AgentBuilder model(String model) { this.model = model; return this; } public AgentBuilder instructions(String instructions) { this.instructions = instructions; return this; } public AgentBuilder systemPrompt(String systemPrompt) { this.systemPrompt = systemPrompt; return this; } public AgentBuilder temperature(Double temperature) { this.temperature = temperature; return this; } public AgentBuilder topP(Double topP) { this.topP = topP; return this; } public AgentBuilder maxOutputTokens(Integer maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; return this; } public AgentBuilder reasoning(Object reasoning) { this.reasoning = reasoning; return this; } public AgentBuilder toolChoice(Object toolChoice) { this.toolChoice = toolChoice; return this; } public AgentBuilder parallelToolCalls(Boolean parallelToolCalls) { this.parallelToolCalls = parallelToolCalls; return this; } public AgentBuilder store(Boolean store) { this.store = store; return this; } public AgentBuilder user(String user) { this.user = user; return this; } public AgentBuilder extraBody(Map extraBody) { this.extraBody = extraBody; return this; } public Agent build() { AgentRuntime resolvedRuntime = runtime == null ? new ReActRuntime() : runtime; Supplier resolvedMemorySupplier = memorySupplier == null ? InMemoryAgentMemory::new : memorySupplier; AgentMemory memory = resolvedMemorySupplier.get(); AgentToolRegistry baseToolRegistry = toolRegistry == null ? StaticToolRegistry.empty() : toolRegistry; SubAgentRegistry resolvedSubAgentRegistry = resolveSubAgentRegistry(); AgentToolRegistry resolvedToolRegistry = resolveToolRegistry(baseToolRegistry, resolvedSubAgentRegistry); ToolExecutor resolvedToolExecutor = toolExecutor; if (resolvedToolExecutor == null) { Set allowedToolNames = resolveToolNames(baseToolRegistry); resolvedToolExecutor = createToolUtilExecutor(allowedToolNames); } if (resolvedSubAgentRegistry != null) { HandoffPolicy resolvedHandoffPolicy = handoffPolicy == null ? HandoffPolicy.builder().build() : handoffPolicy; resolvedToolExecutor = new SubAgentToolExecutor(resolvedSubAgentRegistry, resolvedToolExecutor, resolvedHandoffPolicy); } CodeExecutor resolvedCodeExecutor = codeExecutor == null ? createDefaultCodeExecutor() : codeExecutor; AgentOptions resolvedOptions = options == null ? AgentOptions.builder().build() : options; CodeActOptions resolvedCodeActOptions = codeActOptions == null ? CodeActOptions.builder().build() : codeActOptions; AgentEventPublisher resolvedEventPublisher = eventPublisher == null ? new AgentEventPublisher() : eventPublisher; if (traceExporter != null) { resolvedEventPublisher.addListener(new AgentTraceListener(traceExporter, traceConfig)); } if (modelClient == null) { throw new IllegalStateException("modelClient is required"); } AgentContext context = AgentContext.builder() .modelClient(modelClient) .toolRegistry(resolvedToolRegistry) .toolExecutor(resolvedToolExecutor) .codeExecutor(resolvedCodeExecutor) .memory(memory) .options(resolvedOptions) .codeActOptions(resolvedCodeActOptions) .eventPublisher(resolvedEventPublisher) .model(model) .instructions(instructions) .systemPrompt(systemPrompt) .temperature(temperature) .topP(topP) .maxOutputTokens(maxOutputTokens) .reasoning(reasoning) .toolChoice(toolChoice) .parallelToolCalls(parallelToolCalls) .store(store) .user(user) .extraBody(extraBody) .build(); return new Agent(resolvedRuntime, context, resolvedMemorySupplier); } private CodeExecutor createDefaultCodeExecutor() { String javaSpecVersion = System.getProperty("java.specification.version", ""); if ("1.8".equals(javaSpecVersion) || "8".equals(javaSpecVersion)) { return new NashornCodeExecutor(); } return new GraalVmCodeExecutor(); } private SubAgentRegistry resolveSubAgentRegistry() { if (subAgentRegistry != null) { return subAgentRegistry; } if (!subAgentDefinitions.isEmpty()) { return new StaticSubAgentRegistry(subAgentDefinitions); } return null; } private AgentToolRegistry resolveToolRegistry(AgentToolRegistry baseToolRegistry, SubAgentRegistry subRegistry) { if (subRegistry == null) { return baseToolRegistry; } return new CompositeToolRegistry(baseToolRegistry, new StaticToolRegistry(subRegistry.getTools())); } private Set resolveToolNames(AgentToolRegistry registry) { Set names = new HashSet<>(); if (registry == null) { return names; } List tools = registry.getTools(); if (tools == null) { return names; } for (Object tool : tools) { if (tool instanceof Tool) { Tool.Function fn = ((Tool) tool).getFunction(); if (fn != null && fn.getName() != null) { names.add(fn.getName()); } } } return names; } private AgentToolRegistry createToolUtilRegistry(List functions, List mcpServices) { Object registry = instantiateClass( "io.github.lnyocly.ai4j.agent.tool.ToolUtilRegistry", new Class[]{List.class, List.class}, new Object[]{functions, mcpServices} ); if (!(registry instanceof AgentToolRegistry)) { throw new IllegalStateException("ToolUtilRegistry is unavailable. Add the ai4j integration module or provide AgentToolRegistry manually."); } return (AgentToolRegistry) registry; } private ToolExecutor createToolUtilExecutor(Set allowedToolNames) { Object executor = instantiateClass( "io.github.lnyocly.ai4j.agent.tool.ToolUtilExecutor", new Class[]{Set.class}, new Object[]{allowedToolNames} ); if (executor == null) { if (allowedToolNames == null || allowedToolNames.isEmpty()) { return null; } throw new IllegalStateException("ToolUtilExecutor is unavailable. Add the ai4j integration module or provide ToolExecutor manually."); } if (!(executor instanceof ToolExecutor)) { throw new IllegalStateException("ToolUtilExecutor does not implement ToolExecutor."); } return (ToolExecutor) executor; } private Object instantiateClass(String className, Class[] parameterTypes, Object[] args) { try { Class clazz = Class.forName(className); Constructor constructor = clazz.getConstructor(parameterTypes); return constructor.newInstance(args); } catch (ClassNotFoundException e) { return null; } catch (Exception e) { throw new IllegalStateException("Failed to initialize " + className, e); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentContext.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.agent.event.AgentEventPublisher; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutor; import io.github.lnyocly.ai4j.agent.codeact.CodeActOptions; import io.github.lnyocly.ai4j.agent.memory.AgentMemory; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import lombok.Builder; import lombok.Data; import java.util.Map; @Data @Builder(toBuilder = true) public class AgentContext { private AgentModelClient modelClient; private AgentToolRegistry toolRegistry; private ToolExecutor toolExecutor; private CodeExecutor codeExecutor; private AgentMemory memory; private AgentOptions options; private CodeActOptions codeActOptions; private AgentEventPublisher eventPublisher; private String model; private String instructions; private String systemPrompt; private Double temperature; private Double topP; private Integer maxOutputTokens; private Object reasoning; private Object toolChoice; private Boolean parallelToolCalls; private Boolean store; private String user; private Map extraBody; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentOptions.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class AgentOptions { @Builder.Default private int maxSteps = 0; @Builder.Default private boolean stream = false; @Builder.Default private StreamExecutionOptions streamExecution = StreamExecutionOptions.builder().build(); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRequest.java ================================================ package io.github.lnyocly.ai4j.agent; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentRequest { private Object input; private Map metadata; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentResult.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentResult { private String outputText; private Object rawResponse; private List toolCalls; private List toolResults; private Integer steps; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRuntime.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.agent.event.AgentListener; public interface AgentRuntime { AgentResult run(AgentContext context, AgentRequest request) throws Exception; void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception; default AgentResult runStreamResult(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { runStream(context, request, listener); return null; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentSession.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.agent.event.AgentListener; public class AgentSession { private final AgentRuntime runtime; private final AgentContext context; public AgentSession(AgentRuntime runtime, AgentContext context) { this.runtime = runtime; this.context = context; } public AgentResult run(String input) throws Exception { return runtime.run(context, AgentRequest.builder().input(input).build()); } public AgentResult run(AgentRequest request) throws Exception { return runtime.run(context, request); } public void runStream(AgentRequest request, AgentListener listener) throws Exception { runtime.runStream(context, request, listener); } public AgentResult runStreamResult(AgentRequest request, AgentListener listener) throws Exception { return runtime.runStreamResult(context, request, listener); } public AgentContext getContext() { return context; } public AgentRuntime getRuntime() { return runtime; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agents.java ================================================ package io.github.lnyocly.ai4j.agent; import io.github.lnyocly.ai4j.agent.runtime.CodeActRuntime; import io.github.lnyocly.ai4j.agent.runtime.DeepResearchRuntime; import io.github.lnyocly.ai4j.agent.runtime.ReActRuntime; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamBuilder; public final class Agents { private Agents() { } public static AgentBuilder builder() { return new AgentBuilder(); } public static AgentBuilder react() { return new AgentBuilder().runtime(new ReActRuntime()); } public static AgentBuilder codeAct() { return new AgentBuilder().runtime(new CodeActRuntime()); } public static AgentBuilder deepResearch() { return new AgentBuilder().runtime(new DeepResearchRuntime()); } public static AgentTeamBuilder team() { return AgentTeamBuilder.builder(); } public static Agent teamAgent(AgentTeamBuilder builder) { if (builder == null) { throw new IllegalArgumentException("builder is required"); } return builder.buildAgent(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeActOptions.java ================================================ package io.github.lnyocly.ai4j.agent.codeact; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodeActOptions { @Builder.Default private boolean reAct = false; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionRequest.java ================================================ package io.github.lnyocly.ai4j.agent.codeact; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import lombok.Builder; import lombok.Data; import java.util.List; @Data @Builder public class CodeExecutionRequest { private String language; private String code; private List toolNames; private ToolExecutor toolExecutor; private String user; private Long timeoutMs; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionResult.java ================================================ package io.github.lnyocly.ai4j.agent.codeact; import lombok.Builder; import lombok.Data; @Data @Builder public class CodeExecutionResult { private String stdout; private String result; private String error; public boolean isSuccess() { return error == null || error.isEmpty(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.codeact; public interface CodeExecutor { CodeExecutionResult execute(CodeExecutionRequest request) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/GraalVmCodeExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.codeact; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.Value; import org.graalvm.polyglot.proxy.ProxyExecutable; import org.graalvm.polyglot.proxy.ProxyObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; public class GraalVmCodeExecutor implements CodeExecutor { private static final Logger log = LoggerFactory.getLogger(GraalVmCodeExecutor.class); private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*"); private static final long DEFAULT_TIMEOUT_MS = 8000L; public GraalVmCodeExecutor() { } // Keep this constructor for compatibility with existing caller code. public GraalVmCodeExecutor(String ignored) { } @Override public CodeExecutionResult execute(CodeExecutionRequest request) { if (request == null || request.getCode() == null) { return CodeExecutionResult.builder().error("code is required").build(); } String language = normalizeLanguage(request.getLanguage()); if (!"python".equals(language)) { return CodeExecutionResult.builder() .error("unsupported language: " + request.getLanguage() + ", only python is enabled") .build(); } CodeExecutionResult pyResult = executePythonWithGraalPy(request); if (pyResult == null) { return CodeExecutionResult.builder() .error("Python engine not found (GraalPy). Ensure GraalPy runtime is available.") .build(); } return pyResult; } private String normalizeLanguage(String language) { if (language == null || language.trim().isEmpty()) { return "python"; } String value = language.trim().toLowerCase(); if ("py".equals(value) || "python3".equals(value)) { return "python"; } return value; } private String buildPythonPrelude(List toolNames) { StringBuilder builder = new StringBuilder(); builder.append("__codeact_result = None\n"); builder.append("def callTool(name, args=None, **kwargs):\n"); builder.append(" if args is None and kwargs:\n"); builder.append(" return tools.call(name, kwargs)\n"); builder.append(" if kwargs and isinstance(args, dict):\n"); builder.append(" merged = dict(args)\n"); builder.append(" merged.update(kwargs)\n"); builder.append(" return tools.call(name, merged)\n"); builder.append(" return tools.call(name, args)\n"); if (toolNames != null) { for (String name : toolNames) { if (name != null && IDENTIFIER.matcher(name).matches()) { builder.append("def ").append(name).append("(args=None, **kwargs):\n") .append(" if args is None and kwargs:\n") .append(" return tools.call(\"") .append(escapePython(name)).append("\", kwargs)\n") .append(" if kwargs and isinstance(args, dict):\n") .append(" merged = dict(args)\n") .append(" merged.update(kwargs)\n") .append(" return tools.call(\"") .append(escapePython(name)).append("\", merged)\n") .append(" return tools.call(\"") .append(escapePython(name)).append("\", args)\n"); } } } return builder.toString(); } private String wrapPythonCode(String code) { String[] lines = code.split("\r?\n"); StringBuilder builder = new StringBuilder(); builder.append("def __codeact_main():\n"); builder.append(" global __codeact_result\n"); if (lines.length == 0) { builder.append(" pass\n"); } else { for (String line : lines) { builder.append(" ").append(line).append("\n"); } } builder.append("__codeact_tmp = __codeact_main()\n"); builder.append("if __codeact_tmp is not None:\n"); builder.append(" __codeact_result = __codeact_tmp\n"); return builder.toString(); } private String escapePython(String text) { return text.replace("\\", "\\\\").replace("\"", "\\\""); } private String trimError(String error) { if (error == null || error.trim().isEmpty()) { return null; } return error.trim(); } private CodeExecutionResult executePythonWithGraalPy(CodeExecutionRequest request) { ByteArrayOutputStream stdoutBytes = new ByteArrayOutputStream(); ByteArrayOutputStream stderrBytes = new ByteArrayOutputStream(); ToolExecutor toolExecutor = request.getToolExecutor(); ToolBridge toolBridge = new ToolBridge(toolExecutor, request.getUser()); ProxyExecutable callTool = new ProxyExecutable() { @Override public Object execute(Value... args) { try { String name = args != null && args.length > 0 && args[0] != null ? args[0].asString() : null; Object payload = null; if (args != null && args.length > 1 && args[1] != null) { Value value = args[1]; if (!value.isNull() && !isUndefined(value)) { if (value.isString()) { payload = value.asString(); } else if (value.hasArrayElements()) { payload = value.as(List.class); } else if (value.hasMembers()) { payload = value.as(Map.class); } else if (value.isNumber()) { payload = value.as(Double.class); } else if (value.isBoolean()) { payload = value.asBoolean(); } else { payload = value.toString(); } } } return toolBridge.call(name, payload); } catch (Exception e) { throw new RuntimeException(e); } } }; Map toolMap = new HashMap(); toolMap.put("call", callTool); ProxyObject tools = ProxyObject.fromMap(toolMap); Context context = null; ExecutorService executor = Executors.newSingleThreadExecutor(); try { HostAccess hostAccess = HostAccess.newBuilder() .allowAccessAnnotatedBy(HostAccess.Export.class) .build(); context = Context.newBuilder("python") .allowHostAccess(hostAccess) .option("engine.WarnInterpreterOnly", "false") .out(new PrintStream(stdoutBytes, true)) .err(new PrintStream(stderrBytes, true)) .build(); context.getBindings("python").putMember("tools", tools); String prelude = buildPythonPrelude(request.getToolNames()); String wrapped = wrapPythonCode(request.getCode()); String script = prelude + "\n" + wrapped; Long timeoutMs = request.getTimeoutMs(); long timeout = timeoutMs == null ? DEFAULT_TIMEOUT_MS : timeoutMs; final Context runContext = context; final String runScript = script; Future future = executor.submit(new Callable() { @Override public Value call() { return runContext.eval("python", runScript); } }); Value value = future.get(timeout, TimeUnit.MILLISECONDS); Value fallback = context.getBindings("python").getMember("__codeact_result"); String resolved = resolveValue(fallback); if (resolved == null) { resolved = resolveValue(value); } String stderrText = new String(stderrBytes.toByteArray(), StandardCharsets.UTF_8); String filteredError = filterPolyglotWarnings(stderrText); return CodeExecutionResult.builder() .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)) .result(resolved) .error(trimError(filteredError)) .build(); } catch (IllegalArgumentException e) { return null; } catch (TimeoutException e) { return CodeExecutionResult.builder() .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)) .error("code execution timeout") .build(); } catch (ExecutionException e) { Throwable cause = e.getCause() == null ? e : e.getCause(); log.warn("GraalPy execution failed", cause); return CodeExecutionResult.builder() .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)) .error(String.valueOf(cause.getMessage())) .build(); } catch (Throwable t) { log.warn("GraalPy execution failed", t); return CodeExecutionResult.builder() .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)) .error(String.valueOf(t.getMessage())) .build(); } finally { if (context != null) { try { context.close(true); } catch (Exception ignored) { } } executor.shutdownNow(); } } private String resolveValue(Value value) { if (value == null) { return null; } if (value.isNull() || isUndefined(value)) { return null; } if (value.isString()) { return value.asString(); } return value.toString(); } private boolean isUndefined(Value value) { if (value == null) { return true; } try { String text = value.toString(); return "undefined".equalsIgnoreCase(text) || "null".equalsIgnoreCase(text); } catch (Exception e) { return false; } } private String filterPolyglotWarnings(String stderr) { if (stderr == null || stderr.trim().isEmpty()) { return stderr; } String[] lines = stderr.split("\r?\n"); StringBuilder filtered = new StringBuilder(); for (String line : lines) { if (line == null || line.trim().isEmpty()) { continue; } String text = line.trim(); if (text.startsWith("[engine] WARNING:") || text.contains("polyglot engine uses a fallback runtime") || text.contains("JVMCI is not enabled") || text.contains("WarnInterpreterOnly") || text.startsWith("[To redirect Truffle log output") || text.startsWith("* '--log.file=") || text.startsWith("* '-Dpolyglot.log.file=") || text.startsWith("* Configure logging using the polyglot embedding API") || text.startsWith("Execution without runtime compilation will negatively impact") || text.startsWith("For more information see:")) { continue; } if (filtered.length() > 0) { filtered.append("\n"); } filtered.append(text); } return filtered.length() == 0 ? null : filtered.toString(); } private static class ToolBridge { private final ToolExecutor toolExecutor; private final String user; private ToolBridge(ToolExecutor toolExecutor, String user) { this.toolExecutor = toolExecutor; this.user = user; } @HostAccess.Export public String call(String name, Object args) throws Exception { if (toolExecutor == null) { throw new IllegalStateException("toolExecutor is required"); } String arguments; if (args == null) { arguments = "{}"; } else if (args instanceof String) { arguments = (String) args; } else { arguments = JSON.toJSONString(args); } AgentToolCall call = AgentToolCall.builder() .name(resolveName(name)) .arguments(arguments) .build(); return toolExecutor.execute(call); } private String resolveName(String name) { if (user == null || user.trim().isEmpty()) { return name; } return "user_" + user + "_tool_" + name; } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/NashornCodeExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.codeact; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.script.Bindings; import javax.script.ScriptContext; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; import javax.script.SimpleScriptContext; import java.io.StringWriter; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; public class NashornCodeExecutor implements CodeExecutor { private static final Logger log = LoggerFactory.getLogger(NashornCodeExecutor.class); private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*"); private static final long DEFAULT_TIMEOUT_MS = 8000L; @Override public CodeExecutionResult execute(CodeExecutionRequest request) { if (request == null || request.getCode() == null) { return CodeExecutionResult.builder().error("code is required").build(); } String language = normalizeLanguage(request.getLanguage()); if (!"javascript".equals(language)) { return CodeExecutionResult.builder() .error("unsupported language: " + request.getLanguage() + ", only javascript is enabled") .build(); } return executeJavaScript(request); } private String normalizeLanguage(String language) { if (language == null || language.trim().isEmpty()) { return "javascript"; } String value = language.trim().toLowerCase(); if ("js".equals(value) || "ecmascript".equals(value)) { return "javascript"; } return value; } private CodeExecutionResult executeJavaScript(CodeExecutionRequest request) { ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn"); if (engine == null) { return CodeExecutionResult.builder() .error("Nashorn engine not found. Use JDK 8 or add nashorn engine dependency.") .build(); } StringWriter stdout = new StringWriter(); StringWriter stderr = new StringWriter(); ScriptContext context = new SimpleScriptContext(); context.setWriter(stdout); context.setErrorWriter(stderr); Bindings bindings = engine.createBindings(); bindings.put("__toolBridge", new ToolBridge(request.getToolExecutor(), request.getUser())); context.setBindings(bindings, ScriptContext.ENGINE_SCOPE); String script = buildPrelude(request.getToolNames()) + "\n" + wrapCode(request.getCode()); Long timeoutMs = request.getTimeoutMs(); long timeout = timeoutMs == null ? DEFAULT_TIMEOUT_MS : timeoutMs; ExecutorService executor = Executors.newSingleThreadExecutor(); try { Future future = executor.submit(new Callable() { @Override public Object call() throws Exception { return engine.eval(script, context); } }); Object value = future.get(timeout, TimeUnit.MILLISECONDS); Object resultValue = bindings.get("__codeact_result"); if (resultValue == null) { resultValue = value; } String error = trimError(stderr.toString()); return CodeExecutionResult.builder() .stdout(stdout.toString()) .result(resultValue == null ? null : String.valueOf(resultValue)) .error(error) .build(); } catch (TimeoutException e) { return CodeExecutionResult.builder() .stdout(stdout.toString()) .error("code execution timeout") .build(); } catch (ExecutionException e) { Throwable cause = e.getCause() == null ? e : e.getCause(); log.warn("Nashorn execution failed", cause); return CodeExecutionResult.builder() .stdout(stdout.toString()) .error(String.valueOf(cause.getMessage())) .build(); } catch (Throwable t) { log.warn("Nashorn execution failed", t); return CodeExecutionResult.builder() .stdout(stdout.toString()) .error(String.valueOf(t.getMessage())) .build(); } finally { executor.shutdownNow(); } } private String buildPrelude(List toolNames) { StringBuilder builder = new StringBuilder(); builder.append("var __codeact_result = null;\n"); builder.append("function __parseIfJson(value) {\n"); builder.append(" if (value == null) { return value; }\n"); builder.append(" var text = null;\n"); builder.append(" if (typeof value === 'string') { text = value; }\n"); builder.append(" else { try { text = String(value); } catch (e) { return value; } }\n"); builder.append(" text = text == null ? '' : text.trim();\n"); builder.append(" if (text.length < 2) { return value; }\n"); builder.append(" var quotedJson = text.charAt(0) === '\"' && text.charAt(text.length - 1) === '\"';\n"); builder.append(" var objJson = text.charAt(0) === '{' && text.charAt(text.length - 1) === '}';\n"); builder.append(" var arrJson = text.charAt(0) === '[' && text.charAt(text.length - 1) === ']';\n"); builder.append(" if (!quotedJson && !objJson && !arrJson) { return value; }\n"); builder.append(" try { return JSON.parse(text); } catch (e) { return value; }\n"); builder.append("}\n"); builder.append("function __normalizeToolResult(value) {\n"); builder.append(" var current = value;\n"); builder.append(" for (var i = 0; i < 3; i++) {\n"); builder.append(" var next = __parseIfJson(current);\n"); builder.append(" if (next === current) { break; }\n"); builder.append(" current = next;\n"); builder.append(" }\n"); builder.append(" return current;\n"); builder.append("}\n"); builder.append("function callTool(name, args) {\n"); builder.append(" var payload = args == null ? '{}': (typeof args === 'string' ? args : JSON.stringify(args));\n"); builder.append(" var raw = __toolBridge.call(String(name), payload);\n"); builder.append(" return __normalizeToolResult(raw);\n"); builder.append("}\n"); if (toolNames != null) { for (String name : toolNames) { if (name != null && IDENTIFIER.matcher(name).matches()) { builder.append("function ").append(name).append("(args) {\n") .append(" return callTool(\"") .append(escapeJs(name)).append("\", args);\n") .append("}\n"); } } } return builder.toString(); } private String wrapCode(String code) { StringBuilder builder = new StringBuilder(); builder.append("var __codeact_return = (function __codeact_main__() {\n"); builder.append(code).append("\n"); builder.append("})();\n"); builder.append("if (__codeact_result == null && typeof __codeact_return !== 'undefined') {\n"); builder.append(" __codeact_result = __codeact_return;\n"); builder.append("}\n"); builder.append("__codeact_result;\n"); return builder.toString(); } private String escapeJs(String text) { return text.replace("\\", "\\\\").replace("\"", "\\\""); } private String trimError(String error) { if (error == null || error.trim().isEmpty()) { return null; } return error.trim(); } public static class ToolBridge { private final ToolExecutor toolExecutor; private final String user; private ToolBridge(ToolExecutor toolExecutor, String user) { this.toolExecutor = toolExecutor; this.user = user; } public String call(String name, String arguments) throws Exception { if (toolExecutor == null) { throw new IllegalStateException("toolExecutor is required"); } String payload = arguments == null || arguments.trim().isEmpty() ? "{}" : arguments; AgentToolCall call = AgentToolCall.builder() .name(resolveName(name)) .arguments(payload) .build(); return toolExecutor.execute(call); } private String resolveName(String name) { if (user == null || user.trim().isEmpty()) { return name; } return "user_" + user + "_tool_" + name; } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEvent.java ================================================ package io.github.lnyocly.ai4j.agent.event; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentEvent { private AgentEventType type; private Integer step; private String message; private Object payload; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEventPublisher.java ================================================ package io.github.lnyocly.ai4j.agent.event; import java.util.ArrayList; import java.util.List; public class AgentEventPublisher { private final List listeners = new ArrayList<>(); public AgentEventPublisher() { } public AgentEventPublisher(List initial) { if (initial != null) { listeners.addAll(initial); } } public void addListener(AgentListener listener) { if (listener != null) { listeners.add(listener); } } public void publish(AgentEvent event) { if (event == null) { return; } for (AgentListener listener : listeners) { try { listener.onEvent(event); } catch (Exception ignored) { // Listener errors should not break agent execution. } } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEventType.java ================================================ package io.github.lnyocly.ai4j.agent.event; public enum AgentEventType { STEP_START, STEP_END, MODEL_REQUEST, MODEL_RETRY, MODEL_REASONING, MODEL_RESPONSE, TOOL_CALL, TOOL_RESULT, HANDOFF_START, HANDOFF_END, TEAM_TASK_CREATED, TEAM_TASK_UPDATED, TEAM_MESSAGE, MEMORY_COMPRESS, FINAL_OUTPUT, ERROR } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentListener.java ================================================ package io.github.lnyocly.ai4j.agent.event; public interface AgentListener { void onEvent(AgentEvent event); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/Ai4jFlowGramLlmNodeRunner.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentBuilder; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentRuntime; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.runtime.ReActRuntime; import io.github.lnyocly.ai4j.agent.trace.TracePricing; import io.github.lnyocly.ai4j.agent.trace.TracePricingResolver; import java.util.ArrayList; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class Ai4jFlowGramLlmNodeRunner implements FlowGramLlmNodeRunner { private final AgentModelClient defaultModelClient; private final ModelClientResolver modelClientResolver; private final AgentRuntime runtime; private final AgentOptions agentOptions; private final TracePricingResolver pricingResolver; public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient) { this(modelClient, null, new ReActRuntime(), defaultOptions(), null); } public Ai4jFlowGramLlmNodeRunner(ModelClientResolver modelClientResolver) { this(null, modelClientResolver, new ReActRuntime(), defaultOptions(), null); } public Ai4jFlowGramLlmNodeRunner(ModelClientResolver modelClientResolver, TracePricingResolver pricingResolver) { this(null, modelClientResolver, new ReActRuntime(), defaultOptions(), pricingResolver); } public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient, AgentRuntime runtime, AgentOptions agentOptions) { this(modelClient, null, runtime, agentOptions, null); } public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient, ModelClientResolver modelClientResolver, AgentRuntime runtime, AgentOptions agentOptions) { this(modelClient, modelClientResolver, runtime, agentOptions, null); } public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient, ModelClientResolver modelClientResolver, AgentRuntime runtime, AgentOptions agentOptions, TracePricingResolver pricingResolver) { this.defaultModelClient = modelClient; this.modelClientResolver = modelClientResolver; this.runtime = runtime == null ? new ReActRuntime() : runtime; this.agentOptions = agentOptions == null ? defaultOptions() : agentOptions; this.pricingResolver = pricingResolver; } @Override public Map run(FlowGramNodeSchema node, Map inputs) throws Exception { AgentModelClient modelClient = resolveModelClient(node, inputs); if (modelClient == null) { throw new IllegalStateException("No AgentModelClient is available for FlowGram LLM node: " + safeNodeId(node)); } String model = firstNonBlank( valueAsString(inputs == null ? null : inputs.get("modelName")), valueAsString(inputs == null ? null : inputs.get("model")), valueAsString(inputs == null ? null : inputs.get("modelId")) ); if (isBlank(model)) { throw new IllegalArgumentException("FlowGram LLM node requires modelName/model input"); } String prompt = firstNonBlank( valueAsString(inputs == null ? null : inputs.get("prompt")), valueAsString(inputs == null ? null : inputs.get("message")), valueAsString(inputs == null ? null : inputs.get("input")) ); if (isBlank(prompt)) { throw new IllegalArgumentException("FlowGram LLM node requires prompt input"); } Agent agent = new AgentBuilder() .runtime(runtime) .modelClient(modelClient) .model(model) .systemPrompt(valueAsString(inputs == null ? null : inputs.get("systemPrompt"))) .instructions(valueAsString(inputs == null ? null : inputs.get("instructions"))) .temperature(valueAsDouble(inputs == null ? null : inputs.get("temperature"))) .topP(valueAsDouble(inputs == null ? null : inputs.get("topP"))) .maxOutputTokens(valueAsInteger(inputs == null ? null : inputs.get("maxOutputTokens"))) .options(agentOptions) .build(); long startedAt = System.currentTimeMillis(); AgentResult result = agent.run(AgentRequest.builder().input(prompt).build()); long durationMillis = Math.max(System.currentTimeMillis() - startedAt, 0L); Map outputs = new LinkedHashMap(); outputs.put("result", result == null ? null : result.getOutputText()); outputs.put("outputText", result == null ? null : result.getOutputText()); outputs.put("rawResponse", result == null ? null : result.getRawResponse()); outputs.put("metrics", buildMetrics(model, durationMillis, result == null ? null : result.getRawResponse())); return outputs; } private Map buildMetrics(String model, long durationMillis, Object rawResponse) { Map metrics = new LinkedHashMap(); metrics.put("durationMillis", durationMillis); if (!isBlank(model)) { metrics.put("model", model); } Object usage = propertyValue(normalizeTree(rawResponse), "usage"); if (usage == null) { return metrics; } Long promptTokens = tokenValue(usage, "promptTokens", "prompt_tokens", "input"); Long completionTokens = tokenValue(usage, "completionTokens", "completion_tokens", "output"); Long totalTokens = tokenValue(usage, "totalTokens", "total_tokens", "total"); if (promptTokens != null) { metrics.put("promptTokens", promptTokens); } if (completionTokens != null) { metrics.put("completionTokens", completionTokens); } if (totalTokens != null) { metrics.put("totalTokens", totalTokens); } TracePricing pricing = pricingResolver == null || isBlank(model) ? null : pricingResolver.resolve(model); if (pricing != null) { Double inputCost = pricing.getInputCostPerMillionTokens() == null ? null : ((promptTokens == null ? 0L : promptTokens.longValue()) / 1000000D) * pricing.getInputCostPerMillionTokens(); Double outputCost = pricing.getOutputCostPerMillionTokens() == null ? null : ((completionTokens == null ? 0L : completionTokens.longValue()) / 1000000D) * pricing.getOutputCostPerMillionTokens(); Double totalCost = inputCost == null && outputCost == null ? null : (inputCost == null ? 0D : inputCost) + (outputCost == null ? 0D : outputCost); if (inputCost != null) { metrics.put("inputCost", inputCost); } if (outputCost != null) { metrics.put("outputCost", outputCost); } if (totalCost != null) { metrics.put("totalCost", totalCost); } if (!isBlank(pricing.getCurrency())) { metrics.put("currency", pricing.getCurrency()); } } return metrics; } private Long tokenValue(Object usage, String camelName, String snakeName, String alias) { return longObject(firstNonNull( propertyValue(usage, camelName), propertyValue(usage, snakeName), propertyValue(usage, alias) )); } private AgentModelClient resolveModelClient(FlowGramNodeSchema node, Map inputs) { if (modelClientResolver != null) { AgentModelClient resolved = modelClientResolver.resolve(node, inputs); if (resolved != null) { return resolved; } } return defaultModelClient; } private static AgentOptions defaultOptions() { return AgentOptions.builder() .maxSteps(1) .stream(false) .build(); } private String safeNodeId(FlowGramNodeSchema node) { return node == null || isBlank(node.getId()) ? "(unknown)" : node.getId(); } private static String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private static Double valueAsDouble(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).doubleValue(); } String text = String.valueOf(value).trim(); if (text.isEmpty()) { return null; } try { return Double.parseDouble(text); } catch (NumberFormatException ex) { return null; } } private static Integer valueAsInteger(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).intValue(); } String text = String.valueOf(value).trim(); if (text.isEmpty()) { return null; } try { return Integer.parseInt(text); } catch (NumberFormatException ex) { return null; } } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } @SuppressWarnings("unchecked") private static Map mapValue(Object value) { if (!(value instanceof Map)) { return null; } Map copy = new LinkedHashMap(); Map source = (Map) value; for (Map.Entry entry : source.entrySet()) { copy.put(String.valueOf(entry.getKey()), entry.getValue()); } return copy; } private static Object firstNonNull(Object... values) { if (values == null) { return null; } for (Object value : values) { if (value != null) { return value; } } return null; } @SuppressWarnings("unchecked") private static Object propertyValue(Object source, String name) { if (source == null || isBlank(name)) { return null; } Object normalized = normalizeTree(source); if (normalized instanceof Map) { return ((Map) normalized).get(name); } Object value = invokeAccessor(normalized, "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1)); if (value != null) { return value; } value = invokeAccessor(normalized, "is" + Character.toUpperCase(name.charAt(0)) + name.substring(1)); if (value != null) { return value; } return fieldValue(normalized, name); } private static Object normalizedSource(Object source) { if (source == null || source instanceof Map || source instanceof List || source instanceof String || source instanceof Number || source instanceof Boolean) { return source; } try { return JSON.parseObject(JSON.toJSONString(source)); } catch (RuntimeException ignored) { return source; } } @SuppressWarnings("unchecked") private static Object normalizeTree(Object source) { Object normalized = normalizedSource(source); if (normalized == null || normalized instanceof String || normalized instanceof Number || normalized instanceof Boolean) { return normalized; } if (normalized instanceof Map) { Map copy = new LinkedHashMap(); Map sourceMap = (Map) normalized; for (Map.Entry entry : sourceMap.entrySet()) { copy.put(String.valueOf(entry.getKey()), normalizeTree(entry.getValue())); } return copy; } if (normalized instanceof List) { List copy = new ArrayList(); for (Object item : (List) normalized) { copy.add(normalizeTree(item)); } return copy; } return normalized; } private static Object invokeAccessor(Object source, String methodName) { if (source == null || isBlank(methodName)) { return null; } try { Method method = source.getClass().getMethod(methodName); method.setAccessible(true); return method.invoke(source); } catch (Exception ignored) { // fall through } Class type = source.getClass(); while (type != null && type != Object.class) { try { Method method = type.getDeclaredMethod(methodName); method.setAccessible(true); return method.invoke(source); } catch (Exception ignored) { type = type.getSuperclass(); } } return null; } private static Object fieldValue(Object source, String name) { if (source == null || isBlank(name)) { return null; } Class type = source.getClass(); while (type != null && type != Object.class) { try { Field field = type.getDeclaredField(name); field.setAccessible(true); return field.get(source); } catch (Exception ignored) { type = type.getSuperclass(); } } return null; } private static Long longObject(Object value) { if (value == null) { return null; } if (value instanceof Number) { return Long.valueOf(((Number) value).longValue()); } try { return Long.valueOf(Long.parseLong(String.valueOf(value))); } catch (NumberFormatException ex) { return null; } } public interface ModelClientResolver { AgentModelClient resolve(FlowGramNodeSchema node, Map inputs); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramLlmNodeRunner.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import java.util.Map; public interface FlowGramLlmNodeRunner { Map run(FlowGramNodeSchema node, Map inputs) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramNodeExecutionContext.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramNodeExecutionContext { private String taskId; private FlowGramNodeSchema node; private Map inputs; private Map taskInputs; private Map nodeOutputs; private Map locals; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramNodeExecutionResult.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramNodeExecutionResult { private Map outputs; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramNodeExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; public interface FlowGramNodeExecutor { String getType(); FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeEvent.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramRuntimeEvent { private Type type; private long timestamp; private String taskId; private String nodeId; private String status; private String error; public enum Type { TASK_STARTED, TASK_FINISHED, TASK_FAILED, TASK_CANCELED, NODE_STARTED, NODE_FINISHED, NODE_FAILED, NODE_CANCELED } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeListener.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; public interface FlowGramRuntimeListener { void onEvent(FlowGramRuntimeEvent event); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeService.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramEdgeSchema; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskCancelOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunInput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramWorkflowSchema; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; public class FlowGramRuntimeService implements AutoCloseable { private static final String NODE_TYPE_START = "START"; private static final String NODE_TYPE_END = "END"; private static final String NODE_TYPE_LLM = "LLM"; private static final String NODE_TYPE_CONDITION = "CONDITION"; private static final String NODE_TYPE_LOOP = "LOOP"; private static final String STATUS_PENDING = "pending"; private static final String STATUS_PROCESSING = "processing"; private static final String STATUS_SUCCESS = "success"; private static final String STATUS_FAILED = "failed"; private static final String STATUS_CANCELED = "canceled"; private final FlowGramLlmNodeRunner llmNodeRunner; private final ExecutorService executorService; private final boolean ownsExecutor; private final List listeners = new CopyOnWriteArrayList(); private final ConcurrentMap tasks = new ConcurrentHashMap(); private final ConcurrentMap customExecutors = new ConcurrentHashMap(); public FlowGramRuntimeService(FlowGramLlmNodeRunner llmNodeRunner) { this(llmNodeRunner, createExecutor(), true); } public FlowGramRuntimeService(FlowGramLlmNodeRunner llmNodeRunner, ExecutorService executorService) { this(llmNodeRunner, executorService, false); } private FlowGramRuntimeService(FlowGramLlmNodeRunner llmNodeRunner, ExecutorService executorService, boolean ownsExecutor) { this.llmNodeRunner = llmNodeRunner; this.executorService = executorService == null ? createExecutor() : executorService; this.ownsExecutor = executorService == null || ownsExecutor; } public FlowGramRuntimeService registerNodeExecutor(FlowGramNodeExecutor executor) { if (executor == null || isBlank(executor.getType())) { return this; } customExecutors.put(normalizeType(executor.getType()), executor); return this; } public FlowGramRuntimeService registerListener(FlowGramRuntimeListener listener) { if (listener == null) { return this; } listeners.add(listener); return this; } public FlowGramTaskRunOutput runTask(FlowGramTaskRunInput input) { ParsedTask parsed = parseAndValidate(input); String taskId = UUID.randomUUID().toString(); TaskRecord record = new TaskRecord(taskId, parsed.schema, parsed.nodeIndex, safeMap(input == null ? null : input.getInputs())); tasks.put(taskId, record); record.future = executorService.submit(new Runnable() { @Override public void run() { executeTask(record, parsed.startNode); } }); return FlowGramTaskRunOutput.builder().taskID(taskId).build(); } public FlowGramTaskValidateOutput validateTask(FlowGramTaskRunInput input) { List errors = validateInternal(input); return FlowGramTaskValidateOutput.builder() .valid(errors.isEmpty()) .errors(errors) .build(); } public FlowGramTaskReportOutput getTaskReport(String taskId) { TaskRecord record = tasks.get(taskId); return record == null ? null : record.toReport(); } public FlowGramTaskResultOutput getTaskResult(String taskId) { TaskRecord record = tasks.get(taskId); return record == null ? null : record.toResult(); } public FlowGramTaskCancelOutput cancelTask(String taskId) { TaskRecord record = tasks.get(taskId); if (record == null) { return FlowGramTaskCancelOutput.builder().success(false).build(); } record.cancelRequested.set(true); Future future = record.future; if (future != null) { future.cancel(true); } return FlowGramTaskCancelOutput.builder().success(true).build(); } @Override public void close() { if (ownsExecutor) { executorService.shutdownNow(); } } private void executeTask(TaskRecord record, FlowGramNodeSchema startNode) { record.updateWorkflow(STATUS_PROCESSING, false, null); publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_STARTED, STATUS_PROCESSING, null); try { GraphSegment graph = GraphSegment.root(record.schema); executeFromNode(record, graph, startNode.getId(), Collections.emptyMap(), new LinkedHashSet()); if (record.cancelRequested.get()) { record.updateWorkflow(STATUS_CANCELED, true, null); publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_CANCELED, STATUS_CANCELED, null); return; } if (record.result == null) { throw new IllegalStateException("FlowGram workflow finished without reaching an End node"); } record.updateWorkflow(STATUS_SUCCESS, true, null); publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_FINISHED, STATUS_SUCCESS, null); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); record.updateWorkflow(STATUS_CANCELED, true, null); publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_CANCELED, STATUS_CANCELED, null); } catch (Exception ex) { String error = safeMessage(ex); record.updateWorkflow(STATUS_FAILED, true, error); publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_FAILED, STATUS_FAILED, error); } } private Map executeFromNode(TaskRecord record, GraphSegment graph, String nodeId, Map locals, Set activePath) throws Exception { checkCanceled(record); if (isBlank(nodeId)) { return Collections.emptyMap(); } if (!activePath.add(nodeId)) { throw new IllegalStateException("Cycle detected in FlowGram graph at node " + nodeId); } try { FlowGramNodeSchema node = graph.getNode(nodeId); if (node == null) { throw new IllegalArgumentException("Node not found in graph: " + nodeId); } Map outputs = executeNode(record, graph, node, locals); List nextEdges = selectNextEdges(record, graph, node, outputs); Map last = outputs; for (FlowGramEdgeSchema edge : nextEdges) { last = executeFromNode(record, graph, edge.getTargetNodeID(), locals, new LinkedHashSet(activePath)); } return last; } finally { activePath.remove(nodeId); } } private Map executeNode(TaskRecord record, GraphSegment graph, FlowGramNodeSchema node, Map locals) throws Exception { checkCanceled(record); String nodeId = node.getId(); record.recordNodeOutputs(nodeId, Collections.emptyMap()); record.updateNode(nodeId, STATUS_PROCESSING, null, false); publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_STARTED, STATUS_PROCESSING, null); try { Map outputs; String type = normalizeType(node.getType()); if (NODE_TYPE_START.equals(type)) { outputs = executeStartNode(record, node); } else if (NODE_TYPE_END.equals(type)) { outputs = executeEndNode(record, node, locals, graph.isTerminalResultGraph()); } else if (NODE_TYPE_LLM.equals(type)) { outputs = executeLlmNode(record, node, locals); } else if (NODE_TYPE_CONDITION.equals(type)) { outputs = executeConditionNode(record, node, locals); } else if (NODE_TYPE_LOOP.equals(type)) { outputs = executeLoopNode(record, node, locals); } else { outputs = executeCustomNode(record, node, locals); } Map safeOutputs = safeMap(outputs); record.recordNodeOutputs(nodeId, safeOutputs); record.updateNode(nodeId, STATUS_SUCCESS, null, true); publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_FINISHED, STATUS_SUCCESS, null); return safeOutputs; } catch (InterruptedException ex) { record.recordNodeOutputs(nodeId, Collections.emptyMap()); record.updateNode(nodeId, STATUS_CANCELED, null, true); publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_CANCELED, STATUS_CANCELED, null); throw ex; } catch (Exception ex) { if (record.cancelRequested.get()) { record.recordNodeOutputs(nodeId, Collections.emptyMap()); record.updateNode(nodeId, STATUS_CANCELED, null, true); publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_CANCELED, STATUS_CANCELED, null); throw new InterruptedException("FlowGram task canceled"); } record.recordNodeOutputs(nodeId, Collections.emptyMap()); String error = safeMessage(ex); record.updateNode(nodeId, STATUS_FAILED, error, true); publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_FAILED, STATUS_FAILED, error); throw ex; } } private void publishTaskEvent(TaskRecord record, FlowGramRuntimeEvent.Type type, String status, String error) { if (record == null || type == null) { return; } publishEvent(FlowGramRuntimeEvent.builder() .type(type) .timestamp(System.currentTimeMillis()) .taskId(record.taskId) .status(status) .error(error) .build()); } private void publishNodeEvent(TaskRecord record, String nodeId, FlowGramRuntimeEvent.Type type, String status, String error) { if (record == null || type == null || isBlank(nodeId)) { return; } publishEvent(FlowGramRuntimeEvent.builder() .type(type) .timestamp(System.currentTimeMillis()) .taskId(record.taskId) .nodeId(nodeId) .status(status) .error(error) .build()); } private void publishEvent(FlowGramRuntimeEvent event) { if (event == null || listeners.isEmpty()) { return; } for (FlowGramRuntimeListener listener : listeners) { if (listener == null) { continue; } try { listener.onEvent(event); } catch (RuntimeException ignored) { // Listener failures must not break workflow execution. } } } private Map executeStartNode(TaskRecord record, FlowGramNodeSchema node) { record.recordNodeInputs(node.getId(), record.taskInputs); Map outputSchema = schemaMap(node, "outputs"); Map outputs = applyObjectDefaults(outputSchema, record.taskInputs); validateObjectSchema("Start node " + safeNodeId(node) + " inputs", outputSchema, outputs); return outputs; } private Map executeEndNode(TaskRecord record, FlowGramNodeSchema node, Map locals, boolean terminalResultGraph) { Map inputs = resolveInputs(record, node, locals); record.recordNodeInputs(node.getId(), inputs); Map inputSchema = schemaMap(node, "inputs"); validateObjectSchema("End node " + safeNodeId(node) + " inputs", inputSchema, inputs); Map result = safeMap(inputs); if (terminalResultGraph) { record.result = result; } return result; } private Map executeLlmNode(TaskRecord record, FlowGramNodeSchema node, Map locals) throws Exception { if (llmNodeRunner == null) { throw new IllegalStateException("FlowGram LLM node runner is not configured"); } Map inputs = resolveInputs(record, node, locals); record.recordNodeInputs(node.getId(), inputs); Map inputSchema = schemaMap(node, "inputs"); validateObjectSchema("LLM node " + safeNodeId(node) + " inputs", inputSchema, inputs); Map outputs = llmNodeRunner.run(node, inputs); Map outputSchema = schemaMap(node, "outputs"); validateObjectSchema("LLM node " + safeNodeId(node) + " outputs", outputSchema, outputs); return outputs; } private Map executeConditionNode(TaskRecord record, FlowGramNodeSchema node, Map locals) { Map inputs = resolveInputs(record, node, locals); record.recordNodeInputs(node.getId(), inputs); Map inputSchema = schemaMap(node, "inputs"); validateObjectSchema("Condition node " + safeNodeId(node) + " inputs", inputSchema, inputs); String matchedBranch = resolveConditionBranch(record, node, locals, inputs); Map outputs = new LinkedHashMap(); outputs.put("branchKey", matchedBranch); outputs.putAll(inputs); return outputs; } private Map executeLoopNode(TaskRecord record, FlowGramNodeSchema node, Map locals) throws Exception { Map inputs = resolveInputs(record, node, locals); record.recordNodeInputs(node.getId(), inputs); Map inputSchema = schemaMap(node, "inputs"); validateObjectSchema("Loop node " + safeNodeId(node) + " inputs", inputSchema, inputs); Object loopFor = inputs.get("loopFor"); if (!(loopFor instanceof List)) { throw new IllegalArgumentException("Loop node " + safeNodeId(node) + " requires loopFor to be an array"); } List items = (List) loopFor; Map> aggregates = new LinkedHashMap>(); Map loopOutputDefs = mapValue(firstNonNull(dataValue(node, "loopOutputs"), dataValue(node, "outputsValues"))); GraphSegment loopGraph = GraphSegment.loop(node); for (int i = 0; i < items.size(); i++) { checkCanceled(record); Map iterationLocals = new LinkedHashMap(safeMap(locals)); Map loopLocals = new LinkedHashMap(); loopLocals.put("item", copyValue(items.get(i))); loopLocals.put("index", i); iterationLocals.put(node.getId() + "_locals", loopLocals); if (!loopGraph.isEmpty()) { List entryNodeIds = loopGraph.entryNodeIds(); for (String entryNodeId : entryNodeIds) { executeFromNode(record, loopGraph, entryNodeId, iterationLocals, new LinkedHashSet()); } } if (loopOutputDefs != null) { for (Map.Entry entry : loopOutputDefs.entrySet()) { List values = aggregates.get(entry.getKey()); if (values == null) { values = new ArrayList(); aggregates.put(entry.getKey(), values); } values.add(copyValue(evaluateValue(entry.getValue(), record, iterationLocals))); } } } Map outputs = new LinkedHashMap(); for (Map.Entry> entry : aggregates.entrySet()) { outputs.put(entry.getKey(), entry.getValue()); } Map outputSchema = schemaMap(node, "outputs"); validateObjectSchema("Loop node " + safeNodeId(node) + " outputs", outputSchema, outputs); return outputs; } private Map executeCustomNode(TaskRecord record, FlowGramNodeSchema node, Map locals) throws Exception { FlowGramNodeExecutor executor = customExecutors.get(normalizeType(node.getType())); if (executor == null) { throw new IllegalArgumentException("Unsupported FlowGram node type: " + node.getType()); } Map inputs = resolveInputs(record, node, locals); record.recordNodeInputs(node.getId(), inputs); FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder() .taskId(record.taskId) .node(node) .inputs(inputs) .taskInputs(copyMap(record.taskInputs)) .nodeOutputs(copyMap(record.nodeOutputs)) .locals(copyMap(locals)) .build()); return result == null ? Collections.emptyMap() : safeMap(result.getOutputs()); } private List selectNextEdges(TaskRecord record, GraphSegment graph, FlowGramNodeSchema node, Map outputs) { if (NODE_TYPE_END.equals(normalizeType(node.getType()))) { return Collections.emptyList(); } List outgoing = graph.outgoing(node.getId()); if (outgoing.isEmpty()) { return Collections.emptyList(); } if (!NODE_TYPE_CONDITION.equals(normalizeType(node.getType()))) { return outgoing; } String branchKey = valueAsString(outputs.get("branchKey")); if (isBlank(branchKey)) { return Collections.emptyList(); } List matched = new ArrayList(); for (FlowGramEdgeSchema edge : outgoing) { String edgeKey = firstNonBlank(edge.getSourcePortID(), edge.getSourcePort()); if (branchKey.equals(edgeKey)) { matched.add(edge); } } return matched; } private String resolveConditionBranch(TaskRecord record, FlowGramNodeSchema node, Map locals, Map inputs) { Object conditionsObject = dataValue(node, "conditions"); if (conditionsObject instanceof List) { @SuppressWarnings("unchecked") List rules = (List) conditionsObject; for (Object ruleObject : rules) { Map rule = mapValue(ruleObject); if (rule == null || !conditionMatches(rule, record, locals, inputs)) { continue; } String branchKey = firstNonBlank( valueAsString(rule.get("branchKey")), valueAsString(rule.get("key")), valueAsString(rule.get("sourcePortID")), valueAsString(rule.get("sourcePort")) ); if (!isBlank(branchKey)) { return branchKey; } } } Object explicit = firstNonNull(inputs.get("branchKey"), inputs.get("sourcePort"), inputs.get("sourcePortID")); if (explicit != null) { return String.valueOf(explicit); } Object passed = firstNonNull(inputs.get("passed"), inputs.get("matched"), inputs.get("condition")); if (passed instanceof Boolean) { return Boolean.TRUE.equals(passed) ? "true" : "false"; } throw new IllegalArgumentException("Condition node " + safeNodeId(node) + " did not resolve any branch"); } private boolean conditionMatches(Map rule, TaskRecord record, Map locals, Map inputs) { String operator = normalizeOperator(valueAsString(rule.get("operator"))); if ("DEFAULT".equals(operator)) { return true; } Object left = firstNonNull( evaluateConditionOperand(rule.get("left"), record, locals, inputs), evaluateConditionOperand(rule.get("leftValue"), record, locals, inputs), resolveInputValue(inputs, valueAsString(firstNonNull(rule.get("leftKey"), rule.get("inputKey")))) ); Object right = firstNonNull( evaluateConditionOperand(rule.get("right"), record, locals, inputs), evaluateConditionOperand(rule.get("rightValue"), record, locals, inputs), rule.get("value") ); return compareCondition(left, operator, right); } private Object evaluateConditionOperand(Object operand, TaskRecord record, Map locals, Map inputs) { if (operand == null) { return null; } if (operand instanceof Map) { return evaluateValue(operand, record, locals); } String key = valueAsString(operand); if (!isBlank(key) && inputs.containsKey(key)) { return inputs.get(key); } return operand; } private boolean compareCondition(Object left, String operator, Object right) { if (operator == null) { operator = "EQ"; } if ("TRUTHY".equals(operator)) { return truthy(left); } if ("FALSY".equals(operator)) { return !truthy(left); } if ("EQ".equals(operator) || "==".equals(operator)) { return valuesEqual(left, right); } if ("NE".equals(operator) || "!=".equals(operator)) { return !valuesEqual(left, right); } Double leftNumber = valueAsDouble(left); Double rightNumber = valueAsDouble(right); if (leftNumber != null && rightNumber != null) { if ("GT".equals(operator) || ">".equals(operator)) { return leftNumber > rightNumber; } if ("GTE".equals(operator) || ">=".equals(operator)) { return leftNumber >= rightNumber; } if ("LT".equals(operator) || "<".equals(operator)) { return leftNumber < rightNumber; } if ("LTE".equals(operator) || "<=".equals(operator)) { return leftNumber <= rightNumber; } } String leftText = valueAsString(left); String rightText = valueAsString(right); if ("CONTAINS".equals(operator)) { return leftText != null && rightText != null && leftText.contains(rightText); } if ("STARTS_WITH".equals(operator)) { return leftText != null && rightText != null && leftText.startsWith(rightText); } if ("ENDS_WITH".equals(operator)) { return leftText != null && rightText != null && leftText.endsWith(rightText); } return valuesEqual(left, right); } private Map resolveInputs(TaskRecord record, FlowGramNodeSchema node, Map locals) { Map rawInputs = mapValue(dataValue(node, "inputsValues")); Map resolved = new LinkedHashMap(); if (rawInputs != null) { for (Map.Entry entry : rawInputs.entrySet()) { resolved.put(entry.getKey(), copyValue(evaluateValue(entry.getValue(), record, locals))); } } return applyObjectDefaults(schemaMap(node, "inputs"), resolved); } private Object evaluateValue(Object value, TaskRecord record, Map locals) { if (!(value instanceof Map)) { return copyValue(value); } Map valueMap = mapValue(value); if (valueMap == null) { return copyValue(value); } String type = normalizeType(valueAsString(valueMap.get("type"))); if ("REF".equals(type)) { return resolveReference(valueMap.get("content"), record, locals); } if ("CONSTANT".equals(type)) { return copyValue(valueMap.get("content")); } if ("TEMPLATE".equals(type)) { return renderTemplate(valueAsString(valueMap.get("content")), record, locals); } if ("EXPRESSION".equals(type)) { return evaluateExpression(valueAsString(valueMap.get("content")), record, locals); } return copyValue(valueMap.get("content")); } private Object resolveReference(Object content, TaskRecord record, Map locals) { List path = objectList(content); if (path.isEmpty()) { return null; } Object current = resolveRootReference(path.get(0), record, locals); for (int i = 1; i < path.size(); i++) { current = descend(current, path.get(i)); if (current == null) { return null; } } return current; } private Object resolveRootReference(Object segment, TaskRecord record, Map locals) { String key = valueAsString(segment); if (isBlank(key)) { return null; } if (locals != null && locals.containsKey(key)) { return locals.get(key); } if ("inputs".equals(key) || "taskInputs".equals(key) || "$inputs".equals(key)) { return record.taskInputs; } return record.nodeOutputs.get(key); } private Object evaluateExpression(String expression, TaskRecord record, Map locals) { if (isBlank(expression)) { return expression; } String trimmed = expression.trim(); if (trimmed.startsWith("${") && trimmed.endsWith("}")) { return resolvePathExpression(trimmed.substring(2, trimmed.length() - 1), record, locals); } if (trimmed.startsWith("{{") && trimmed.endsWith("}}")) { return resolvePathExpression(trimmed.substring(2, trimmed.length() - 2), record, locals); } if ("true".equalsIgnoreCase(trimmed) || "false".equalsIgnoreCase(trimmed)) { return Boolean.parseBoolean(trimmed); } Double number = valueAsDouble(trimmed); if (number != null) { return trimmed.contains(".") ? number : Integer.valueOf(number.intValue()); } return renderTemplate(trimmed, record, locals); } private String renderTemplate(String template, TaskRecord record, Map locals) { if (template == null) { return null; } String rendered = template; rendered = replaceTemplatePattern(rendered, "${", "}", record, locals); rendered = replaceTemplatePattern(rendered, "{{", "}}", record, locals); return rendered; } private String replaceTemplatePattern(String template, String prefix, String suffix, TaskRecord record, Map locals) { String rendered = template; int start = rendered.indexOf(prefix); while (start >= 0) { int end = rendered.indexOf(suffix, start + prefix.length()); if (end < 0) { break; } String expr = rendered.substring(start + prefix.length(), end).trim(); Object value = resolvePathExpression(expr, record, locals); rendered = rendered.substring(0, start) + (value == null ? "" : String.valueOf(value)) + rendered.substring(end + suffix.length()); start = rendered.indexOf(prefix, start); } return rendered; } private Object resolvePathExpression(String expression, TaskRecord record, Map locals) { if (isBlank(expression)) { return null; } List path = new ArrayList(); for (String segment : expression.split("\\.")) { String trimmed = segment.trim(); if (!trimmed.isEmpty()) { path.add(trimmed); } } return resolveReference(path, record, locals); } private Object descend(Object current, Object segment) { if (current == null || segment == null) { return null; } if (current instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) current; return map.get(String.valueOf(segment)); } if (current instanceof List) { Integer index = valueAsInteger(segment); if (index == null) { return null; } List list = (List) current; return index >= 0 && index < list.size() ? list.get(index) : null; } return null; } private ParsedTask parseAndValidate(FlowGramTaskRunInput input) { List errors = validateInternal(input); if (!errors.isEmpty()) { throw new IllegalArgumentException(errors.get(0)); } FlowGramWorkflowSchema schema = parseSchema(input); Map index = new LinkedHashMap(); collectNodes(schema.getNodes(), index, new ArrayList()); return new ParsedTask(schema, index, findSingleStart(index.values())); } private List validateInternal(FlowGramTaskRunInput input) { List errors = new ArrayList(); FlowGramWorkflowSchema schema = parseSchema(input, errors); if (schema == null) { return errors; } Map nodeIndex = new LinkedHashMap(); collectNodes(schema.getNodes(), nodeIndex, errors); validateGraph("workflow", schema.getNodes(), schema.getEdges(), true, errors); validateNodeDefinitions(schema.getNodes(), nodeIndex, errors); FlowGramNodeSchema startNode = findSingleStart(nodeIndex.values()); if (startNode != null) { Map startSchema = schemaMap(startNode, "outputs"); Map inputs = applyObjectDefaults(startSchema, safeMap(input == null ? null : input.getInputs())); collectObjectSchemaErrors("workflow inputs", startSchema, inputs, errors); } return errors; } private FlowGramWorkflowSchema parseSchema(FlowGramTaskRunInput input) { List errors = new ArrayList(); FlowGramWorkflowSchema schema = parseSchema(input, errors); if (!errors.isEmpty()) { throw new IllegalArgumentException(errors.get(0)); } return schema; } private FlowGramWorkflowSchema parseSchema(FlowGramTaskRunInput input, List errors) { if (input == null || isBlank(input.getSchema())) { errors.add("FlowGram schema is required"); return null; } try { FlowGramWorkflowSchema schema = JSON.parseObject(input.getSchema(), FlowGramWorkflowSchema.class); if (schema == null || schema.getNodes() == null || schema.getNodes().isEmpty()) { errors.add("FlowGram schema must contain at least one node"); return null; } return schema; } catch (Exception ex) { errors.add("Failed to parse FlowGram schema: " + safeMessage(ex)); return null; } } private void validateGraph(String graphName, List nodes, List edges, boolean rootGraph, List errors) { List safeNodes = nodes == null ? Collections.emptyList() : nodes; Map localIndex = new LinkedHashMap(); int startCount = 0; int endCount = 0; for (FlowGramNodeSchema node : safeNodes) { if (node == null) { continue; } localIndex.put(node.getId(), node); String type = normalizeType(node.getType()); if (NODE_TYPE_START.equals(type)) { startCount++; } if (NODE_TYPE_END.equals(type)) { endCount++; } if (!isSupportedType(type)) { errors.add("Unsupported FlowGram node type '" + node.getType() + "' at node " + safeNodeId(node)); } if (NODE_TYPE_LOOP.equals(type)) { validateGraph("loop:" + safeNodeId(node), node.getBlocks(), node.getEdges(), false, errors); } } if (rootGraph) { if (startCount != 1) { errors.add("FlowGram workflow must contain exactly one Start node"); } if (endCount < 1) { errors.add("FlowGram workflow must contain at least one End node"); } } if (edges != null) { for (FlowGramEdgeSchema edge : edges) { if (edge == null) { continue; } if (!localIndex.containsKey(edge.getSourceNodeID())) { errors.add("Edge source node not found in " + graphName + ": " + edge.getSourceNodeID()); } if (!localIndex.containsKey(edge.getTargetNodeID())) { errors.add("Edge target node not found in " + graphName + ": " + edge.getTargetNodeID()); } } } } private void validateNodeDefinitions(List nodes, Map nodeIndex, List errors) { if (nodes == null) { return; } for (FlowGramNodeSchema node : nodes) { if (node == null) { continue; } validateRequiredInputBindings(node, errors); validateOutputRefs(node, nodeIndex, errors); validateNodeDefinitions(node.getBlocks(), nodeIndex, errors); } } private void validateRequiredInputBindings(FlowGramNodeSchema node, List errors) { Map inputSchema = schemaMap(node, "inputs"); List required = stringList(inputSchema == null ? null : inputSchema.get("required")); if (required.isEmpty()) { return; } Map inputsValues = mapValue(dataValue(node, "inputsValues")); for (String key : required) { if (inputsValues == null || !inputsValues.containsKey(key)) { Object defaultValue = propertyDefault(inputSchema, key); if (defaultValue == null) { errors.add("Node " + safeNodeId(node) + " is missing required input binding: " + key); } } } } private void validateOutputRefs(FlowGramNodeSchema node, Map nodeIndex, List errors) { Map loopOutputs = mapValue(firstNonNull(dataValue(node, "loopOutputs"), dataValue(node, "outputsValues"))); if (loopOutputs == null) { return; } for (Object value : loopOutputs.values()) { validateRefValue(node, value, nodeIndex, errors); } } private void validateRefValue(FlowGramNodeSchema node, Object rawValue, Map nodeIndex, List errors) { Map value = mapValue(rawValue); if (value == null || !"REF".equals(normalizeType(valueAsString(value.get("type"))))) { return; } List path = objectList(value.get("content")); if (path.isEmpty()) { return; } String root = valueAsString(path.get(0)); if (isBlank(root) || root.endsWith("_locals") || "inputs".equals(root) || "taskInputs".equals(root) || "$inputs".equals(root)) { return; } if (!nodeIndex.containsKey(root)) { errors.add("Node " + safeNodeId(node) + " references unknown node output: " + root); } } private void collectNodes(List nodes, Map nodeIndex, List errors) { if (nodes == null) { return; } for (FlowGramNodeSchema node : nodes) { if (node == null) { continue; } if (isBlank(node.getId())) { errors.add("FlowGram node id is required"); continue; } if (nodeIndex.containsKey(node.getId())) { errors.add("Duplicate FlowGram node id: " + node.getId()); continue; } nodeIndex.put(node.getId(), node); collectNodes(node.getBlocks(), nodeIndex, errors); } } private FlowGramNodeSchema findSingleStart(Iterable nodes) { if (nodes == null) { return null; } FlowGramNodeSchema start = null; for (FlowGramNodeSchema node : nodes) { if (!NODE_TYPE_START.equals(normalizeType(node.getType()))) { continue; } if (start != null) { return null; } start = node; } return start; } private boolean isSupportedType(String type) { if (NODE_TYPE_START.equals(type) || NODE_TYPE_END.equals(type) || NODE_TYPE_LLM.equals(type) || NODE_TYPE_CONDITION.equals(type) || NODE_TYPE_LOOP.equals(type)) { return true; } return type != null && customExecutors.containsKey(type); } private void checkCanceled(TaskRecord record) throws InterruptedException { if (record.cancelRequested.get() || Thread.currentThread().isInterrupted()) { throw new InterruptedException("FlowGram task canceled"); } } private static ExecutorService createExecutor() { AtomicInteger sequence = new AtomicInteger(1); return Executors.newCachedThreadPool(new ThreadFactory() { @Override public Thread newThread(Runnable runnable) { Thread thread = new Thread(runnable, "ai4j-flowgram-" + sequence.getAndIncrement()); thread.setDaemon(true); return thread; } }); } private Map applyObjectDefaults(Map schema, Map values) { Map resolved = safeMap(values); if (schema == null) { return resolved; } Map properties = mapValue(schema.get("properties")); if (properties == null) { return resolved; } for (Map.Entry entry : properties.entrySet()) { Map propertySchema = mapValue(entry.getValue()); if (propertySchema == null) { continue; } if (!resolved.containsKey(entry.getKey()) && propertySchema.containsKey("default")) { resolved.put(entry.getKey(), copyValue(propertySchema.get("default"))); } } return resolved; } private void validateObjectSchema(String label, Map schema, Map value) { List errors = new ArrayList(); collectObjectSchemaErrors(label, schema, value, errors); if (!errors.isEmpty()) { throw new IllegalArgumentException(errors.get(0)); } } private void collectObjectSchemaErrors(String label, Map schema, Map value, List errors) { if (schema == null) { return; } if (!"object".equalsIgnoreCase(valueAsString(schema.get("type")))) { return; } Map safeValue = value == null ? Collections.emptyMap() : value; for (String required : stringList(schema.get("required"))) { if (!safeValue.containsKey(required) || safeValue.get(required) == null) { errors.add(label + " is missing required field '" + required + "'"); } } Map properties = mapValue(schema.get("properties")); if (properties == null) { return; } for (Map.Entry entry : properties.entrySet()) { if (!safeValue.containsKey(entry.getKey())) { continue; } collectPropertyErrors(label + "." + entry.getKey(), mapValue(entry.getValue()), safeValue.get(entry.getKey()), errors); } } private void collectPropertyErrors(String label, Map schema, Object value, List errors) { if (schema == null || value == null) { return; } String type = valueAsString(schema.get("type")); if (!isBlank(type) && !matchesType(type, value)) { errors.add(label + " expected " + type + " but got " + actualType(value)); return; } Object enumValue = schema.get("enum"); if (enumValue instanceof List && !((List) enumValue).contains(value)) { errors.add(label + " is not in enum " + enumValue); } if ("object".equalsIgnoreCase(type) && value instanceof Map) { @SuppressWarnings("unchecked") Map child = (Map) value; collectObjectSchemaErrors(label, schema, child, errors); return; } if ("array".equalsIgnoreCase(type) && value instanceof List) { Map itemSchema = mapValue(schema.get("items")); if (itemSchema == null) { return; } int index = 0; for (Object item : (List) value) { collectPropertyErrors(label + "[" + index + "]", itemSchema, item, errors); index++; } } } private boolean matchesType(String type, Object value) { String normalized = type.trim().toLowerCase(Locale.ROOT); if ("string".equals(normalized)) { return value instanceof String; } if ("number".equals(normalized)) { return value instanceof Number; } if ("integer".equals(normalized)) { return value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long; } if ("boolean".equals(normalized)) { return value instanceof Boolean; } if ("object".equals(normalized)) { return value instanceof Map; } if ("array".equals(normalized)) { return value instanceof List; } return true; } private Object propertyDefault(Map objectSchema, String key) { Map properties = mapValue(objectSchema == null ? null : objectSchema.get("properties")); Map propertySchema = mapValue(properties == null ? null : properties.get(key)); return propertySchema == null ? null : propertySchema.get("default"); } private Map schemaMap(FlowGramNodeSchema node, String key) { return mapValue(dataValue(node, key)); } private Object dataValue(FlowGramNodeSchema node, String key) { return node == null || node.getData() == null ? null : node.getData().get(key); } private static Map safeMap(Map value) { Map copy = new LinkedHashMap(); if (value == null) { return copy; } for (Map.Entry entry : value.entrySet()) { copy.put(entry.getKey(), copyValue(entry.getValue())); } return copy; } private static Map copyMap(Map value) { Map copy = new LinkedHashMap(); if (value == null) { return copy; } for (Map.Entry entry : value.entrySet()) { copy.put(entry.getKey(), copyValue(entry.getValue())); } return copy; } @SuppressWarnings("unchecked") private static Object copyValue(Object value) { if (value instanceof Map) { Map copy = new LinkedHashMap(); Map source = (Map) value; for (Map.Entry entry : source.entrySet()) { copy.put(String.valueOf(entry.getKey()), copyValue(entry.getValue())); } return copy; } if (value instanceof List) { List copy = new ArrayList(); for (Object item : (List) value) { copy.add(copyValue(item)); } return copy; } return value; } @SuppressWarnings("unchecked") private static Map mapValue(Object value) { if (!(value instanceof Map)) { return null; } Map copy = new LinkedHashMap(); Map source = (Map) value; for (Map.Entry entry : source.entrySet()) { copy.put(String.valueOf(entry.getKey()), entry.getValue()); } return copy; } @SuppressWarnings("unchecked") private static List objectList(Object value) { if (!(value instanceof List)) { return Collections.emptyList(); } return new ArrayList((List) value); } @SuppressWarnings("unchecked") private static List stringList(Object value) { if (!(value instanceof List)) { return Collections.emptyList(); } List result = new ArrayList(); for (Object item : (List) value) { if (item != null) { result.add(String.valueOf(item)); } } return result; } private static String normalizeType(String value) { return isBlank(value) ? null : value.trim().toUpperCase(Locale.ROOT); } private static String normalizeOperator(String value) { if (isBlank(value)) { return null; } String trimmed = value.trim(); if ("==".equals(trimmed) || "!=".equals(trimmed) || ">".equals(trimmed) || ">=".equals(trimmed) || "<".equals(trimmed) || "<=".equals(trimmed)) { return trimmed; } return trimmed.toUpperCase(Locale.ROOT).replace(' ', '_'); } private static String safeNodeId(FlowGramNodeSchema node) { return node == null || isBlank(node.getId()) ? "(unknown)" : node.getId(); } private static String safeMessage(Throwable throwable) { if (throwable == null || isBlank(throwable.getMessage())) { return throwable == null ? null : throwable.getClass().getSimpleName(); } return throwable.getMessage(); } private static Object firstNonNull(Object... values) { if (values == null) { return null; } for (Object value : values) { if (value != null) { return value; } } return null; } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private static boolean valuesEqual(Object left, Object right) { return left == null ? right == null : left.equals(right); } private static boolean truthy(Object value) { if (value == null) { return false; } if (value instanceof Boolean) { return (Boolean) value; } if (value instanceof Number) { return ((Number) value).doubleValue() != 0D; } if (value instanceof String) { return !((String) value).trim().isEmpty(); } if (value instanceof List) { return !((List) value).isEmpty(); } if (value instanceof Map) { return !((Map) value).isEmpty(); } return true; } private static String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private static Double valueAsDouble(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).doubleValue(); } String text = String.valueOf(value).trim(); if (text.isEmpty()) { return null; } try { return Double.parseDouble(text); } catch (NumberFormatException ex) { return null; } } private static Integer valueAsInteger(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).intValue(); } String text = String.valueOf(value).trim(); if (text.isEmpty()) { return null; } try { return Integer.parseInt(text); } catch (NumberFormatException ex) { return null; } } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static String actualType(Object value) { if (value == null) { return "null"; } if (value instanceof Map) { return "object"; } if (value instanceof List) { return "array"; } if (value instanceof String) { return "string"; } if (value instanceof Boolean) { return "boolean"; } if (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte) { return "integer"; } if (value instanceof Number) { return "number"; } return value.getClass().getSimpleName(); } private static Object resolveInputValue(Map inputs, String key) { return inputs == null || isBlank(key) ? null : inputs.get(key); } private static final class ParsedTask { private final FlowGramWorkflowSchema schema; private final Map nodeIndex; private final FlowGramNodeSchema startNode; private ParsedTask(FlowGramWorkflowSchema schema, Map nodeIndex, FlowGramNodeSchema startNode) { this.schema = schema; this.nodeIndex = nodeIndex; this.startNode = startNode; } } private static final class GraphSegment { private final Map nodes; private final Map> outgoing; private final boolean terminalResultGraph; private GraphSegment(Map nodes, Map> outgoing, boolean terminalResultGraph) { this.nodes = nodes; this.outgoing = outgoing; this.terminalResultGraph = terminalResultGraph; } private static GraphSegment root(FlowGramWorkflowSchema schema) { return new GraphSegment(indexNodes(schema == null ? null : schema.getNodes()), indexOutgoing(schema == null ? null : schema.getEdges()), true); } private static GraphSegment loop(FlowGramNodeSchema node) { return new GraphSegment(indexNodes(node == null ? null : node.getBlocks()), indexOutgoing(node == null ? null : node.getEdges()), false); } private FlowGramNodeSchema getNode(String nodeId) { return nodes.get(nodeId); } private List outgoing(String nodeId) { List edges = outgoing.get(nodeId); return edges == null ? Collections.emptyList() : edges; } private List entryNodeIds() { if (nodes.isEmpty()) { return Collections.emptyList(); } Set incoming = new LinkedHashSet(); for (List edges : outgoing.values()) { if (edges == null) { continue; } for (FlowGramEdgeSchema edge : edges) { if (edge != null && !isBlank(edge.getTargetNodeID())) { incoming.add(edge.getTargetNodeID()); } } } List roots = new ArrayList(); for (String nodeId : nodes.keySet()) { if (!incoming.contains(nodeId)) { roots.add(nodeId); } } return roots.isEmpty() ? new ArrayList(nodes.keySet()) : roots; } private boolean isEmpty() { return nodes.isEmpty(); } private boolean isTerminalResultGraph() { return terminalResultGraph; } private static Map indexNodes(List nodes) { Map index = new LinkedHashMap(); if (nodes == null) { return index; } for (FlowGramNodeSchema node : nodes) { if (node == null || isBlank(node.getId())) { continue; } index.put(node.getId(), node); } return index; } private static Map> indexOutgoing(List edges) { Map> index = new LinkedHashMap>(); if (edges == null) { return index; } for (FlowGramEdgeSchema edge : edges) { if (edge == null || isBlank(edge.getSourceNodeID()) || isBlank(edge.getTargetNodeID())) { continue; } List outgoing = index.get(edge.getSourceNodeID()); if (outgoing == null) { outgoing = new ArrayList(); index.put(edge.getSourceNodeID(), outgoing); } outgoing.add(edge); } return index; } } private static final class TaskRecord { private final String taskId; private final FlowGramWorkflowSchema schema; private final Map nodeIndex; private final Map taskInputs; private final Map> nodeInputs = new ConcurrentHashMap>(); private final Map> nodeOutputs = new ConcurrentHashMap>(); private final ConcurrentMap nodeStatuses = new ConcurrentHashMap(); private final AtomicBoolean cancelRequested = new AtomicBoolean(false); private volatile String workflowStatus = STATUS_PENDING; private volatile boolean terminated = false; private volatile String workflowError; private volatile Long workflowStartTime; private volatile Long workflowEndTime; private volatile Future future; private volatile Map result; private TaskRecord(String taskId, FlowGramWorkflowSchema schema, Map nodeIndex, Map taskInputs) { this.taskId = taskId; this.schema = schema; this.nodeIndex = nodeIndex == null ? Collections.emptyMap() : nodeIndex; this.taskInputs = taskInputs == null ? Collections.emptyMap() : taskInputs; for (String nodeId : this.nodeIndex.keySet()) { nodeStatuses.put(nodeId, FlowGramTaskReportOutput.NodeStatus.builder() .status(STATUS_PENDING) .build()); } } private void updateNode(String nodeId, String status, String error, boolean finished) { FlowGramTaskReportOutput.NodeStatus current = nodeStatuses.get(nodeId); long now = System.currentTimeMillis(); FlowGramTaskReportOutput.NodeStatus.NodeStatusBuilder builder = current == null ? FlowGramTaskReportOutput.NodeStatus.builder() : current.toBuilder(); builder.status(status); builder.terminated(finished || STATUS_SUCCESS.equals(status) || STATUS_FAILED.equals(status) || STATUS_CANCELED.equals(status)); if (STATUS_PROCESSING.equals(status) && (current == null || current.getStartTime() == null)) { builder.startTime(now); } if (finished) { if (current == null || current.getStartTime() == null) { builder.startTime(now); } builder.endTime(now); } builder.error(error); nodeStatuses.put(nodeId, builder.build()); } private void recordNodeInputs(String nodeId, Map inputs) { nodeInputs.put(nodeId, copyMap(inputs)); } private void recordNodeOutputs(String nodeId, Map outputs) { nodeOutputs.put(nodeId, copyMap(outputs)); } private void updateWorkflow(String status, boolean terminated, String error) { long now = System.currentTimeMillis(); this.workflowStatus = status; this.terminated = terminated; this.workflowError = error; if (STATUS_PROCESSING.equals(status) && workflowStartTime == null) { workflowStartTime = now; } if (terminated) { if (workflowStartTime == null) { workflowStartTime = now; } workflowEndTime = now; } } private FlowGramTaskReportOutput toReport() { Map snapshot = new LinkedHashMap(); for (Map.Entry entry : nodeStatuses.entrySet()) { FlowGramTaskReportOutput.NodeStatus value = entry.getValue(); snapshot.put(entry.getKey(), value == null ? null : value.toBuilder() .inputs(copyMap(nodeInputs.get(entry.getKey()))) .outputs(copyMap(nodeOutputs.get(entry.getKey()))) .build()); } return FlowGramTaskReportOutput.builder() .inputs(copyMap(taskInputs)) .outputs(copyMap(result)) .workflow(FlowGramTaskReportOutput.WorkflowStatus.builder() .status(workflowStatus) .terminated(terminated) .startTime(workflowStartTime) .endTime(workflowEndTime) .error(workflowError) .build()) .nodes(snapshot) .build(); } private FlowGramTaskResultOutput toResult() { return FlowGramTaskResultOutput.builder() .status(workflowStatus) .terminated(terminated) .error(workflowError) .result(result == null ? null : copyMap(result)) .build(); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramEdgeSchema.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramEdgeSchema { private String sourceNodeID; private String sourcePort; private String sourcePortID; private String targetNodeID; private String targetPort; private String targetPortID; public String sourcePortKey() { return firstNonBlank(sourcePortID, sourcePort); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramNodeSchema.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramNodeSchema { private String id; private String type; private String name; private Map meta; private Map data; private List blocks; private List edges; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskCancelOutput.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskCancelOutput { private boolean success; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskReportOutput.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskReportOutput { private Map inputs; private Map outputs; private WorkflowStatus workflow; private Map nodes; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class WorkflowStatus { private String status; private boolean terminated; private Long startTime; private Long endTime; private String error; } @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class NodeStatus { private String status; private boolean terminated; private Long startTime; private Long endTime; private String error; private Map inputs; private Map outputs; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskResultOutput.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskResultOutput { private String status; private boolean terminated; private String error; private Map result; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskRunInput.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskRunInput { private String schema; private Map inputs; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskRunOutput.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskRunOutput { private String taskID; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskValidateOutput.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskValidateOutput { private boolean valid; private List errors; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramWorkflowSchema.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramWorkflowSchema { private List nodes; private List edges; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/AgentMemory.java ================================================ package io.github.lnyocly.ai4j.agent.memory; import java.util.List; public interface AgentMemory { void addUserInput(Object input); void addOutputItems(List items); void addToolOutput(String callId, String output); List getItems(); String getSummary(); void clear(); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/InMemoryAgentMemory.java ================================================ package io.github.lnyocly.ai4j.agent.memory; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class InMemoryAgentMemory implements AgentMemory { private final List items = new ArrayList<>(); private String summary; private MemoryCompressor compressor; public InMemoryAgentMemory() { } public InMemoryAgentMemory(MemoryCompressor compressor) { this.compressor = compressor; } public void setCompressor(MemoryCompressor compressor) { this.compressor = compressor; } @Override public void addUserInput(Object input) { if (input == null) { return; } if (input instanceof String) { items.add(AgentInputItem.userMessage((String) input)); } else { items.add(input); } maybeCompress(); } @Override public void addOutputItems(List outputItems) { if (outputItems == null || outputItems.isEmpty()) { return; } items.addAll(outputItems); maybeCompress(); } @Override public void addToolOutput(String callId, String output) { if (callId == null) { return; } items.add(AgentInputItem.functionCallOutput(callId, output)); maybeCompress(); } @Override public List getItems() { if (summary == null || summary.trim().isEmpty()) { return new ArrayList<>(items); } List merged = new ArrayList<>(); merged.add(AgentInputItem.systemMessage(summary)); merged.addAll(items); return merged; } @Override public String getSummary() { return summary; } public void setSummary(String summary) { this.summary = summary; } public MemorySnapshot snapshot() { return MemorySnapshot.from(items, summary); } public void restore(MemorySnapshot snapshot) { items.clear(); if (snapshot != null && snapshot.getItems() != null) { items.addAll(snapshot.getItems()); } summary = snapshot == null ? null : snapshot.getSummary(); } @Override public void clear() { items.clear(); summary = null; } private void maybeCompress() { if (compressor == null) { return; } MemorySnapshot snapshot = compressor.compress(MemorySnapshot.from(items, summary)); items.clear(); if (snapshot.getItems() != null) { items.addAll(snapshot.getItems()); } summary = snapshot.getSummary(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemory.java ================================================ package io.github.lnyocly.ai4j.agent.memory; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import javax.sql.DataSource; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class JdbcAgentMemory implements AgentMemory { private static final String ENTRY_TYPE_ITEM = "item"; private static final String ENTRY_TYPE_SUMMARY = "summary"; private final DataSource dataSource; private final String jdbcUrl; private final String username; private final String password; private final String sessionId; private final String tableName; private MemoryCompressor compressor; public JdbcAgentMemory(JdbcAgentMemoryConfig config) { if (config == null) { throw new IllegalArgumentException("config is required"); } this.dataSource = config.getDataSource(); this.jdbcUrl = trimToNull(config.getJdbcUrl()); this.username = trimToNull(config.getUsername()); this.password = config.getPassword(); this.sessionId = requiredText(config.getSessionId(), "sessionId"); this.tableName = validIdentifier(config.getTableName()); this.compressor = config.getCompressor(); if (this.dataSource == null && this.jdbcUrl == null) { throw new IllegalArgumentException("dataSource or jdbcUrl is required"); } if (config.isInitializeSchema()) { initializeSchema(); } } public JdbcAgentMemory(String jdbcUrl, String sessionId) { this(JdbcAgentMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId(sessionId) .build()); } public JdbcAgentMemory(String jdbcUrl, String username, String password, String sessionId) { this(JdbcAgentMemoryConfig.builder() .jdbcUrl(jdbcUrl) .username(username) .password(password) .sessionId(sessionId) .build()); } public JdbcAgentMemory(DataSource dataSource, String sessionId) { this(JdbcAgentMemoryConfig.builder() .dataSource(dataSource) .sessionId(sessionId) .build()); } public void setCompressor(MemoryCompressor compressor) { this.compressor = compressor; synchronized (this) { replaceSnapshot(applyCompressor(loadSnapshot())); } } public synchronized void setSummary(String summary) { MemorySnapshot snapshot = loadSnapshot(); snapshot.setSummary(summary); replaceSnapshot(applyCompressor(snapshot)); } public synchronized MemorySnapshot snapshot() { MemorySnapshot snapshot = loadSnapshot(); return MemorySnapshot.from(snapshot.getItems(), snapshot.getSummary()); } public synchronized void restore(MemorySnapshot snapshot) { MemorySnapshot target = snapshot == null ? MemorySnapshot.from(Collections.emptyList(), null) : MemorySnapshot.from(snapshot.getItems(), snapshot.getSummary()); replaceSnapshot(applyCompressor(target)); } @Override public synchronized void addUserInput(Object input) { if (input == null) { return; } MemorySnapshot snapshot = loadSnapshot(); List items = copyItems(snapshot.getItems()); if (input instanceof String) { items.add(AgentInputItem.userMessage((String) input)); } else { items.add(input); } replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary()))); } @Override public synchronized void addOutputItems(List outputItems) { if (outputItems == null || outputItems.isEmpty()) { return; } MemorySnapshot snapshot = loadSnapshot(); List items = copyItems(snapshot.getItems()); items.addAll(outputItems); replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary()))); } @Override public synchronized void addToolOutput(String callId, String output) { if (callId == null) { return; } MemorySnapshot snapshot = loadSnapshot(); List items = copyItems(snapshot.getItems()); items.add(AgentInputItem.functionCallOutput(callId, output)); replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary()))); } @Override public synchronized List getItems() { MemorySnapshot snapshot = loadSnapshot(); List items = copyItems(snapshot.getItems()); if (snapshot.getSummary() == null || snapshot.getSummary().trim().isEmpty()) { return items; } List merged = new ArrayList(items.size() + 1); merged.add(AgentInputItem.systemMessage(snapshot.getSummary())); merged.addAll(items); return merged; } @Override public synchronized String getSummary() { return loadSnapshot().getSummary(); } @Override public synchronized void clear() { replaceSnapshot(MemorySnapshot.from(Collections.emptyList(), null)); } private void initializeSchema() { String sql = "create table if not exists " + tableName + " (" + "session_id varchar(191) not null, " + "entry_type varchar(16) not null, " + "entry_index integer not null, " + "entry_json text, " + "updated_at bigint not null, " + "primary key (session_id, entry_type, entry_index)" + ")"; try (Connection connection = openConnection(); Statement statement = connection.createStatement()) { statement.executeUpdate(sql); } catch (Exception e) { throw new IllegalStateException("Failed to initialize agent memory schema", e); } } private MemorySnapshot loadSnapshot() { String summarySql = "select entry_json from " + tableName + " where session_id = ? and entry_type = ? and entry_index = 0"; String itemsSql = "select entry_json from " + tableName + " where session_id = ? and entry_type = ? order by entry_index asc"; try (Connection connection = openConnection()) { String summary = null; try (PreparedStatement summaryStatement = connection.prepareStatement(summarySql)) { summaryStatement.setString(1, sessionId); summaryStatement.setString(2, ENTRY_TYPE_SUMMARY); try (ResultSet resultSet = summaryStatement.executeQuery()) { if (resultSet.next()) { Object value = JSON.parse(resultSet.getString("entry_json")); summary = value == null ? null : String.valueOf(value); } } } List items = new ArrayList(); try (PreparedStatement itemsStatement = connection.prepareStatement(itemsSql)) { itemsStatement.setString(1, sessionId); itemsStatement.setString(2, ENTRY_TYPE_ITEM); try (ResultSet resultSet = itemsStatement.executeQuery()) { while (resultSet.next()) { items.add(JSON.parse(resultSet.getString("entry_json"))); } } } return MemorySnapshot.from(items, summary); } catch (Exception e) { throw new IllegalStateException("Failed to load agent memory", e); } } private void replaceSnapshot(MemorySnapshot snapshot) { String deleteSql = "delete from " + tableName + " where session_id = ?"; String insertSql = "insert into " + tableName + " (session_id, entry_type, entry_index, entry_json, updated_at) values (?, ?, ?, ?, ?)"; try (Connection connection = openConnection()) { boolean autoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); try { try (PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) { deleteStatement.setString(1, sessionId); deleteStatement.executeUpdate(); } long now = System.currentTimeMillis(); if (snapshot != null && snapshot.getSummary() != null) { try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) { insertStatement.setString(1, sessionId); insertStatement.setString(2, ENTRY_TYPE_SUMMARY); insertStatement.setInt(3, 0); insertStatement.setString(4, JSON.toJSONString(snapshot.getSummary())); insertStatement.setLong(5, now); insertStatement.executeUpdate(); } } if (snapshot != null && snapshot.getItems() != null && !snapshot.getItems().isEmpty()) { try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) { for (int i = 0; i < snapshot.getItems().size(); i++) { insertStatement.setString(1, sessionId); insertStatement.setString(2, ENTRY_TYPE_ITEM); insertStatement.setInt(3, i); insertStatement.setString(4, JSON.toJSONString(snapshot.getItems().get(i))); insertStatement.setLong(5, now); insertStatement.addBatch(); } insertStatement.executeBatch(); } } connection.commit(); } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(autoCommit); } } catch (Exception e) { throw new IllegalStateException("Failed to persist agent memory", e); } } private MemorySnapshot applyCompressor(MemorySnapshot snapshot) { if (compressor == null) { return MemorySnapshot.from(snapshot == null ? null : snapshot.getItems(), snapshot == null ? null : snapshot.getSummary()); } return compressor.compress(snapshot == null ? MemorySnapshot.from(Collections.emptyList(), null) : MemorySnapshot.from(snapshot.getItems(), snapshot.getSummary())); } private List copyItems(List items) { return items == null ? new ArrayList() : new ArrayList(items); } private Connection openConnection() throws Exception { if (dataSource != null) { return dataSource.getConnection(); } if (username == null) { return DriverManager.getConnection(jdbcUrl); } return DriverManager.getConnection(jdbcUrl, username, password); } private String validIdentifier(String value) { String identifier = requiredText(value, "tableName"); if (!identifier.matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?")) { throw new IllegalArgumentException("Invalid sql identifier: " + value); } return identifier; } private String requiredText(String value, String fieldName) { String text = trimToNull(value); if (text == null) { throw new IllegalArgumentException(fieldName + " is required"); } return text; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemoryConfig.java ================================================ package io.github.lnyocly.ai4j.agent.memory; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import javax.sql.DataSource; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class JdbcAgentMemoryConfig { private DataSource dataSource; private String jdbcUrl; private String username; private String password; private String sessionId; @Builder.Default private String tableName = "ai4j_agent_memory"; @Builder.Default private boolean initializeSchema = true; private MemoryCompressor compressor; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/MemoryCompressor.java ================================================ package io.github.lnyocly.ai4j.agent.memory; public interface MemoryCompressor { MemorySnapshot compress(MemorySnapshot snapshot); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/MemorySnapshot.java ================================================ package io.github.lnyocly.ai4j.agent.memory; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class MemorySnapshot { private List items; private String summary; public static MemorySnapshot from(List items, String summary) { return MemorySnapshot.builder() .items(items == null ? new ArrayList() : new ArrayList(items)) .summary(summary) .build(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/WindowedMemoryCompressor.java ================================================ package io.github.lnyocly.ai4j.agent.memory; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class WindowedMemoryCompressor implements MemoryCompressor { private final int maxItems; public WindowedMemoryCompressor(int maxItems) { if (maxItems <= 0) { throw new IllegalArgumentException("maxItems must be greater than 0"); } this.maxItems = maxItems; } @Override public MemorySnapshot compress(MemorySnapshot snapshot) { if (snapshot == null) { return MemorySnapshot.from(Collections.emptyList(), null); } List items = snapshot.getItems(); if (items == null || items.size() <= maxItems) { return snapshot; } List trimmed = new ArrayList<>(items.subList(items.size() - maxItems, items.size())); return MemorySnapshot.from(trimmed, snapshot.getSummary()); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentModelClient.java ================================================ package io.github.lnyocly.ai4j.agent.model; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; public interface AgentModelClient { AgentModelResult create(AgentPrompt prompt) throws Exception; AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentModelResult.java ================================================ package io.github.lnyocly.ai4j.agent.model; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentModelResult { private String reasoningText; private String outputText; private List toolCalls; private List memoryItems; private Object rawResponse; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentModelStreamListener.java ================================================ package io.github.lnyocly.ai4j.agent.model; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; public interface AgentModelStreamListener { default void onReasoningDelta(String delta) { } default void onDeltaText(String delta) { } default void onToolCall(AgentToolCall call) { } default void onEvent(Object event) { } default void onComplete(AgentModelResult result) { } default void onError(Throwable t) { } default void onRetry(String message, int attempt, int maxAttempts, Throwable cause) { } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentPrompt.java ================================================ package io.github.lnyocly.ai4j.agent.model; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class AgentPrompt { private String model; private List items; private String systemPrompt; private String instructions; private List tools; private Object toolChoice; private Boolean parallelToolCalls; private Double temperature; private Double topP; private Integer maxOutputTokens; private Object reasoning; private Boolean store; private Boolean stream; private String user; private Map extraBody; private StreamExecutionOptions streamExecution; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ChatModelClient.java ================================================ package io.github.lnyocly.ai4j.agent.model; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Content; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.service.IChatService; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class ChatModelClient implements AgentModelClient { private static final ConcurrentMap ACTIVE_STREAMS = new ConcurrentHashMap(); private final IChatService chatService; private final String baseUrl; private final String apiKey; public ChatModelClient(IChatService chatService) { this(chatService, null, null); } public ChatModelClient(IChatService chatService, String baseUrl, String apiKey) { this.chatService = chatService; this.baseUrl = baseUrl; this.apiKey = apiKey; } @Override public AgentModelResult create(AgentPrompt prompt) throws Exception { ChatCompletion completion = toChatCompletion(prompt, false); ChatCompletionResponse response = chatService.chatCompletion(baseUrl, apiKey, completion); return toModelResult(response); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception { ChatCompletion completion = toChatCompletion(prompt, true); completion.setPassThroughToolCalls(Boolean.TRUE); StreamingSseListener sseListener = new StreamingSseListener(listener); Thread currentThread = Thread.currentThread(); ACTIVE_STREAMS.put(currentThread, sseListener); try { chatService.chatCompletionStream(baseUrl, apiKey, completion, sseListener); throwIfInterrupted(sseListener); sseListener.dispatchFailure(); AgentModelResult result = sseListener.toResult(); throwIfInterrupted(sseListener); if (listener != null) { listener.onComplete(result); } return result; } catch (InterruptedException ex) { sseListener.cancelStream(); Thread.currentThread().interrupt(); throw ex; } catch (Exception ex) { if (currentThread.isInterrupted()) { sseListener.cancelStream(); } throw ex; } finally { ACTIVE_STREAMS.remove(currentThread, sseListener); } } public static void cancelActiveStream(Thread thread) { if (thread == null) { return; } SseListener listener = ACTIVE_STREAMS.get(thread); if (listener != null) { listener.cancelStream(); } } private void throwIfInterrupted(SseListener sseListener) throws InterruptedException { if (!Thread.currentThread().isInterrupted()) { return; } sseListener.cancelStream(); throw new InterruptedException("Model stream interrupted"); } private ChatCompletion toChatCompletion(AgentPrompt prompt, boolean stream) { if (prompt == null) { throw new IllegalArgumentException("prompt is required"); } List messages = new ArrayList<>(); if (prompt.getSystemPrompt() != null && !prompt.getSystemPrompt().trim().isEmpty()) { messages.add(ChatMessage.withSystem(prompt.getSystemPrompt())); } if (prompt.getInstructions() != null && !prompt.getInstructions().trim().isEmpty()) { messages.add(ChatMessage.withSystem(prompt.getInstructions())); } if (prompt.getItems() != null) { for (Object item : prompt.getItems()) { ChatMessage message = convertToMessage(item); if (message != null) { messages.add(message); } } } ChatCompletion.ChatCompletionBuilder builder = ChatCompletion.builder() .model(prompt.getModel()) .messages(messages) .stream(stream) .streamExecution(prompt.getStreamExecution()) .temperature(prompt.getTemperature() == null ? null : prompt.getTemperature().floatValue()) .topP(prompt.getTopP() == null ? null : prompt.getTopP().floatValue()) .maxCompletionTokens(prompt.getMaxOutputTokens()) .user(prompt.getUser()); if (prompt.getParallelToolCalls() != null) { builder.parallelToolCalls(prompt.getParallelToolCalls()); } if (prompt.getToolChoice() instanceof String) { builder.toolChoice((String) prompt.getToolChoice()); } List tools = convertTools(prompt.getTools()); if (!tools.isEmpty()) { builder.tools(tools); builder.passThroughToolCalls(Boolean.TRUE); } Map extraBody = prompt.getExtraBody(); if (extraBody != null) { builder.extraBody(extraBody); } return builder.build(); } private List convertTools(List tools) { List converted = new ArrayList<>(); if (tools == null) { return converted; } for (Object tool : tools) { if (tool instanceof Tool) { converted.add((Tool) tool); } } return converted; } private ChatMessage convertToMessage(Object item) { if (item == null) { return null; } if (item instanceof ChatMessage) { return (ChatMessage) item; } if (item instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) item; Object type = map.get("type"); if ("message".equals(type)) { String role = valueAsString(map.get("role")); List toolCalls = convertMessageToolCalls(map.get("tool_calls")); ChatMessage message = buildMessageFromContent(role, map.get("content")); if (!toolCalls.isEmpty() && "assistant".equals(role)) { if (message == null) { return ChatMessage.withAssistant(toolCalls); } return ChatMessage.builder() .role(message.getRole()) .content(message.getContent()) .name(message.getName()) .reasoningContent(message.getReasoningContent()) .toolCalls(toolCalls) .build(); } if (message != null) { return message; } } if ("function_call_output".equals(type)) { String callId = valueAsString(map.get("call_id")); String output = valueAsString(map.get("output")); if (callId != null) { return ChatMessage.withTool(output, callId); } } } return null; } private List convertMessageToolCalls(Object value) { List toolCalls = new ArrayList(); if (!(value instanceof List)) { return toolCalls; } @SuppressWarnings("unchecked") List rawCalls = (List) value; for (Object rawCall : rawCalls) { if (rawCall instanceof ToolCall) { toolCalls.add((ToolCall) rawCall); continue; } if (!(rawCall instanceof Map)) { continue; } @SuppressWarnings("unchecked") Map rawMap = (Map) rawCall; Object functionValue = rawMap.get("function"); if (!(functionValue instanceof Map)) { continue; } @SuppressWarnings("unchecked") Map functionMap = (Map) functionValue; toolCalls.add(new ToolCall( valueAsString(rawMap.get("id")), valueAsString(rawMap.get("type")), new ToolCall.Function( valueAsString(functionMap.get("name")), valueAsString(functionMap.get("arguments")) ) )); } return toolCalls; } private ChatMessage buildMessageFromContent(String role, Object content) { if (role == null || content == null) { return null; } if (content instanceof String) { String text = (String) content; return text.isEmpty() ? null : new ChatMessage(role, text); } if (content instanceof List) { @SuppressWarnings("unchecked") List parts = (List) content; List multiModals = new ArrayList<>(); StringBuilder textBuilder = new StringBuilder(); boolean hasImage = false; for (Object part : parts) { if (!(part instanceof Map)) { continue; } @SuppressWarnings("unchecked") Map map = (Map) part; Object type = map.get("type"); if ("input_text".equals(type)) { String text = valueAsString(map.get("text")); if (text != null && !text.isEmpty()) { textBuilder.append(text); multiModals.add(new Content.MultiModal(Content.MultiModal.Type.TEXT.getType(), text, null)); } } if ("input_image".equals(type)) { String url = extractImageUrl(map.get("image_url")); if (url != null && !url.isEmpty()) { hasImage = true; multiModals.add(new Content.MultiModal(Content.MultiModal.Type.IMAGE_URL.getType(), null, new Content.MultiModal.ImageUrl(url))); } } } if (hasImage) { if (multiModals.isEmpty()) { return null; } return ChatMessage.builder() .role(role) .content(Content.ofMultiModals(multiModals)) .build(); } if (textBuilder.length() > 0) { return new ChatMessage(role, textBuilder.toString()); } } return null; } private String extractImageUrl(Object imageUrl) { if (imageUrl == null) { return null; } if (imageUrl instanceof String) { return (String) imageUrl; } if (imageUrl instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) imageUrl; return valueAsString(map.get("url")); } return null; } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private AgentModelResult toModelResult(ChatCompletionResponse response) { if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) { return AgentModelResult.builder().rawResponse(response).build(); } ChatMessage message = response.getChoices().get(0).getMessage(); String outputText = null; String reasoningText = null; List toolCalls = new ArrayList<>(); if (message != null) { if (message.getContent() != null) { outputText = message.getContent().getText(); } reasoningText = message.getReasoningContent(); if (message.getToolCalls() != null) { for (ToolCall toolCall : message.getToolCalls()) { if (toolCall == null || toolCall.getFunction() == null) { continue; } toolCalls.add(AgentToolCall.builder() .callId(toolCall.getId()) .name(toolCall.getFunction().getName()) .arguments(toolCall.getFunction().getArguments()) .type(toolCall.getType()) .build()); } } } List memoryItems = buildAssistantMemoryItems(outputText, toolCalls); return AgentModelResult.builder() .reasoningText(reasoningText == null ? "" : reasoningText) .outputText(outputText == null ? "" : outputText) .toolCalls(toolCalls) .memoryItems(memoryItems) .rawResponse(response) .build(); } private final class StreamingSseListener extends SseListener { private final AgentModelStreamListener listener; private StreamingSseListener(AgentModelStreamListener listener) { this.listener = listener; } @Override protected void error(Throwable t, okhttp3.Response response) { if (listener != null) { listener.onError(t); } } @Override protected void send() { if (listener == null) { return; } String delta = getCurrStr(); if (delta == null || delta.isEmpty()) { return; } if (isReasoning()) { listener.onReasoningDelta(delta); return; } listener.onDeltaText(delta); } @Override protected void retry(Throwable t, int attempt, int maxAttempts) { if (listener == null) { return; } String message = t == null || t.getMessage() == null || t.getMessage().trim().isEmpty() ? "Retrying model stream" : t.getMessage().trim(); listener.onRetry(message, attempt, maxAttempts, t); } private AgentModelResult toResult() { String outputText = getOutput().toString(); String reasoningText = getReasoningOutput().toString(); List calls = convertToolCalls(getToolCalls()); List memoryItems = buildAssistantMemoryItems(outputText, calls); Map rawResponse = new LinkedHashMap<>(); rawResponse.put("mode", "chat.stream"); rawResponse.put("outputText", outputText); rawResponse.put("reasoningText", reasoningText); rawResponse.put("finishReason", getFinishReason()); rawResponse.put("toolCalls", getToolCalls()); rawResponse.put("usage", getUsage()); return AgentModelResult.builder() .reasoningText(reasoningText) .outputText(outputText) .toolCalls(calls) .memoryItems(memoryItems) .rawResponse(rawResponse) .build(); } } private List buildAssistantMemoryItems(String outputText, List toolCalls) { List memoryItems = new ArrayList(); if (toolCalls != null && !toolCalls.isEmpty()) { memoryItems.add(io.github.lnyocly.ai4j.agent.util.AgentInputItem.assistantToolCallsMessage( outputText == null ? "" : outputText, toolCalls )); return memoryItems; } if (outputText != null && !outputText.isEmpty()) { memoryItems.add(io.github.lnyocly.ai4j.agent.util.AgentInputItem.message("assistant", outputText)); } return memoryItems; } private List convertToolCalls(List toolCalls) { List calls = new ArrayList<>(); if (toolCalls == null) { return calls; } for (ToolCall toolCall : toolCalls) { if (toolCall == null || toolCall.getFunction() == null) { continue; } calls.add(AgentToolCall.builder() .callId(toolCall.getId()) .name(toolCall.getFunction().getName()) .arguments(toolCall.getFunction().getArguments()) .type(toolCall.getType()) .build()); } return calls; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ResponsesModelClient.java ================================================ package io.github.lnyocly.ai4j.agent.model; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.util.ResponseUtil; import io.github.lnyocly.ai4j.listener.ResponseSseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.service.IResponsesService; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class ResponsesModelClient implements AgentModelClient { private static final ConcurrentMap ACTIVE_STREAMS = new ConcurrentHashMap(); private final IResponsesService responsesService; private final String baseUrl; private final String apiKey; public ResponsesModelClient(IResponsesService responsesService) { this(responsesService, null, null); } public ResponsesModelClient(IResponsesService responsesService, String baseUrl, String apiKey) { this.responsesService = responsesService; this.baseUrl = baseUrl; this.apiKey = apiKey; } @Override public AgentModelResult create(AgentPrompt prompt) throws Exception { ResponseRequest request = toResponseRequest(prompt, false); Response response = responsesService.create(baseUrl, apiKey, request); return toModelResult(response); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception { ResponseRequest request = toResponseRequest(prompt, true); ResponseSseListener sseListener = new ResponseSseListener() { @Override protected void onEvent() { if (listener != null && !getCurrText().isEmpty()) { listener.onDeltaText(getCurrText()); } } @Override protected void error(Throwable t, okhttp3.Response response) { if (listener != null) { listener.onError(t); } } @Override protected void retry(Throwable t, int attempt, int maxAttempts) { if (listener == null) { return; } String message = t == null || t.getMessage() == null || t.getMessage().trim().isEmpty() ? "Retrying model stream" : t.getMessage().trim(); listener.onRetry(message, attempt, maxAttempts, t); } }; Thread currentThread = Thread.currentThread(); ACTIVE_STREAMS.put(currentThread, sseListener); try { responsesService.createStream(baseUrl, apiKey, request, sseListener); throwIfInterrupted(sseListener); sseListener.dispatchFailure(); Response response = sseListener.getResponse(); AgentModelResult result = toModelResult(response); throwIfInterrupted(sseListener); if (listener != null) { listener.onComplete(result); } return result; } catch (InterruptedException ex) { sseListener.cancelStream(); Thread.currentThread().interrupt(); throw ex; } catch (Exception ex) { if (currentThread.isInterrupted()) { sseListener.cancelStream(); } throw ex; } finally { ACTIVE_STREAMS.remove(currentThread, sseListener); } } public static void cancelActiveStream(Thread thread) { if (thread == null) { return; } ResponseSseListener listener = ACTIVE_STREAMS.get(thread); if (listener != null) { listener.cancelStream(); } } private void throwIfInterrupted(ResponseSseListener sseListener) throws InterruptedException { if (!Thread.currentThread().isInterrupted()) { return; } sseListener.cancelStream(); throw new InterruptedException("Model stream interrupted"); } private ResponseRequest toResponseRequest(AgentPrompt prompt, boolean forceStream) { if (prompt == null) { throw new IllegalArgumentException("prompt is required"); } ResponseRequest.ResponseRequestBuilder builder = ResponseRequest.builder(); builder.model(prompt.getModel()); builder.input(buildItems(prompt)); builder.tools(prompt.getTools()); builder.toolChoice(prompt.getToolChoice()); builder.parallelToolCalls(prompt.getParallelToolCalls()); builder.temperature(prompt.getTemperature()); builder.topP(prompt.getTopP()); builder.maxOutputTokens(prompt.getMaxOutputTokens()); builder.reasoning(prompt.getReasoning()); builder.store(prompt.getStore()); builder.user(prompt.getUser()); Boolean stream = prompt.getStream(); if (stream == null) { stream = forceStream; } builder.stream(stream); builder.streamExecution(prompt.getStreamExecution()); if (prompt.getSystemPrompt() != null && !prompt.getSystemPrompt().trim().isEmpty()) { builder.instructions(prompt.getSystemPrompt()); } Map extraBody = prompt.getExtraBody(); if (extraBody != null) { builder.extraBody(extraBody); } return builder.build(); } private AgentModelResult toModelResult(Response response) { List calls = ResponseUtil.extractToolCalls(response); List memoryItems = new ArrayList<>(); if (response != null && response.getOutput() != null) { memoryItems.addAll(response.getOutput()); } return AgentModelResult.builder() .outputText(ResponseUtil.extractOutputText(response)) .toolCalls(calls) .memoryItems(memoryItems) .rawResponse(response) .build(); } private List buildItems(AgentPrompt prompt) { List items = new ArrayList<>(); if (prompt.getItems() != null) { items.addAll(prompt.getItems()); } if (prompt.getInstructions() != null && !prompt.getInstructions().trim().isEmpty()) { items.add(0, io.github.lnyocly.ai4j.agent.util.AgentInputItem.systemMessage(prompt.getInstructions())); } return items; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/AgentToolExecutionScope.java ================================================ package io.github.lnyocly.ai4j.agent.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEventType; public final class AgentToolExecutionScope { public interface EventEmitter { void emit(AgentEventType type, String message, Object payload); } public interface ScopeCallable { T call() throws Exception; } private static final ThreadLocal CURRENT = new ThreadLocal(); private AgentToolExecutionScope() { } public static T runWithEmitter(EventEmitter emitter, ScopeCallable callable) throws Exception { if (callable == null) { return null; } EventEmitter previous = CURRENT.get(); if (emitter == null) { CURRENT.remove(); } else { CURRENT.set(emitter); } try { return callable.call(); } finally { if (previous == null) { CURRENT.remove(); } else { CURRENT.set(previous); } } } public static void emit(AgentEventType type, String message, Object payload) { EventEmitter emitter = CURRENT.get(); if (emitter != null && type != null) { emitter.emit(type, message, payload); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java ================================================ package io.github.lnyocly.ai4j.agent.runtime; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventPublisher; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.memory.AgentMemory; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolCallSanitizer; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; public abstract class BaseAgentRuntime implements io.github.lnyocly.ai4j.agent.AgentRuntime { protected String runtimeName() { return "base"; } protected String runtimeInstructions() { return null; } @Override public AgentResult run(AgentContext context, AgentRequest request) throws Exception { return runInternal(context, request, null); } @Override public void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { runInternal(context, request, listener); } @Override public AgentResult runStreamResult(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { return runInternal(context, request, listener); } protected AgentResult runInternal(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { AgentOptions options = context.getOptions(); int maxSteps = options == null ? 0 : options.getMaxSteps(); boolean stream = listener != null && options != null && options.isStream(); AgentMemory memory = context.getMemory(); if (memory == null) { throw new IllegalStateException("memory is required"); } if (request != null && request.getInput() != null) { memory.addUserInput(request.getInput()); } List toolCalls = new ArrayList<>(); List toolResults = new ArrayList<>(); int step = 0; AgentModelResult lastResult = null; boolean stepLimited = maxSteps > 0; while (!stepLimited || step < maxSteps) { throwIfInterrupted(); publish(context, listener, AgentEventType.STEP_START, step, runtimeName(), null); AgentPrompt prompt = buildPrompt(context, memory, stream); AgentModelResult modelResult = executeModel(context, prompt, listener, step, stream); throwIfInterrupted(); lastResult = modelResult; if (modelResult != null && modelResult.getMemoryItems() != null) { memory.addOutputItems(modelResult.getMemoryItems()); } List calls = normalizeToolCalls(modelResult == null ? null : modelResult.getToolCalls(), step); if (calls == null || calls.isEmpty()) { String outputText = modelResult == null ? "" : modelResult.getOutputText(); publish(context, listener, AgentEventType.FINAL_OUTPUT, step, outputText, modelResult == null ? null : modelResult.getRawResponse()); publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null); return AgentResult.builder() .outputText(outputText) .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) .steps(step + 1) .build(); } List validatedCalls = new ArrayList(); for (AgentToolCall call : calls) { toolCalls.add(call); publish(context, listener, AgentEventType.TOOL_CALL, step, call.getName(), call); String validationError = AgentToolCallSanitizer.validationError(call); if (validationError == null) { validatedCalls.add(call); continue; } String output = buildToolValidationErrorOutput(call, validationError); AgentToolResult toolResult = AgentToolResult.builder() .name(call.getName()) .callId(call.getCallId()) .output(output) .build(); toolResults.add(toolResult); memory.addToolOutput(call.getCallId(), output); publish(context, listener, AgentEventType.TOOL_RESULT, step, output, toolResult); } boolean parallelExecution = Boolean.TRUE.equals(context.getParallelToolCalls()) && validatedCalls.size() > 1; List outputs = parallelExecution ? executeToolCallsInParallel(context, validatedCalls, step, listener) : executeToolCallsSequential(context, validatedCalls, step, listener); throwIfInterrupted(); for (int i = 0; i < validatedCalls.size(); i++) { AgentToolCall call = validatedCalls.get(i); String output = outputs.get(i); AgentToolResult toolResult = AgentToolResult.builder() .name(call.getName()) .callId(call.getCallId()) .output(output) .build(); toolResults.add(toolResult); memory.addToolOutput(call.getCallId(), output); publish(context, listener, AgentEventType.TOOL_RESULT, step, output, toolResult); } publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null); step += 1; } String outputText = lastResult == null ? "" : lastResult.getOutputText(); return AgentResult.builder() .outputText(outputText) .rawResponse(lastResult == null ? null : lastResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) .steps(step) .build(); } private void throwIfInterrupted() throws InterruptedException { if (Thread.currentThread().isInterrupted()) { throw new InterruptedException("Agent run interrupted"); } } private List normalizeToolCalls(List calls, int step) { List normalized = new ArrayList(); if (calls == null || calls.isEmpty()) { return normalized; } int index = 0; for (AgentToolCall call : calls) { if (call == null) { index++; continue; } String callId = trimToNull(call.getCallId()); if (callId == null) { callId = "tool_step_" + step + "_" + index; } normalized.add(AgentToolCall.builder() .callId(callId) .name(trimToNull(call.getName()) == null ? "tool" : call.getName().trim()) .arguments(call.getArguments()) .type(call.getType()) .build()); index++; } return normalized; } protected AgentPrompt buildPrompt(AgentContext context, AgentMemory memory, boolean stream) { if (context.getModel() == null || context.getModel().trim().isEmpty()) { throw new IllegalStateException("model is required"); } AgentOptions options = context.getOptions(); String systemPrompt = mergeText(context.getSystemPrompt(), runtimeInstructions()); List tools = context.getToolRegistry() == null ? null : context.getToolRegistry().getTools(); AgentPrompt.AgentPromptBuilder builder = AgentPrompt.builder() .model(context.getModel()) .items(memory.getItems()) .systemPrompt(systemPrompt) .instructions(context.getInstructions()) .tools(tools) .toolChoice(context.getToolChoice()) .parallelToolCalls(context.getParallelToolCalls()) .temperature(context.getTemperature()) .topP(context.getTopP()) .maxOutputTokens(context.getMaxOutputTokens()) .reasoning(context.getReasoning()) .store(context.getStore()) .stream(stream) .user(context.getUser()) .extraBody(context.getExtraBody()) .streamExecution(options == null ? null : options.getStreamExecution()); return builder.build(); } protected AgentModelResult executeModel(AgentContext context, AgentPrompt prompt, AgentListener listener, int step, boolean stream) throws Exception { publish(context, listener, AgentEventType.MODEL_REQUEST, step, null, prompt); AgentModelResult result; if (stream) { final boolean[] streamedReasoning = new boolean[]{false}; final boolean[] streamedText = new boolean[]{false}; AgentModelStreamListener streamListener = new AgentModelStreamListener() { @Override public void onReasoningDelta(String delta) { if (delta != null && !delta.isEmpty()) { streamedReasoning[0] = true; publish(context, listener, AgentEventType.MODEL_REASONING, step, delta, null); } } @Override public void onDeltaText(String delta) { if (delta != null && !delta.isEmpty()) { streamedText[0] = true; publish(context, listener, AgentEventType.MODEL_RESPONSE, step, delta, null); } } @Override public void onToolCall(AgentToolCall call) { if (call != null) { publish(context, listener, AgentEventType.TOOL_CALL, step, call.getName(), call); } } @Override public void onEvent(Object event) { publish(context, listener, AgentEventType.MODEL_RESPONSE, step, null, event); } @Override public void onError(Throwable t) { publish(context, listener, AgentEventType.ERROR, step, t == null ? null : t.getMessage(), t); } @Override public void onRetry(String message, int attempt, int maxAttempts, Throwable cause) { publish(context, listener, AgentEventType.MODEL_RETRY, step, message, retryPayload(attempt, maxAttempts, cause)); } }; result = context.getModelClient().createStream(prompt, streamListener); if (!streamedReasoning[0] && result != null && result.getReasoningText() != null && !result.getReasoningText().isEmpty()) { publish(context, listener, AgentEventType.MODEL_REASONING, step, result.getReasoningText(), null); } if (!streamedText[0] && result != null && result.getOutputText() != null && !result.getOutputText().isEmpty()) { publish(context, listener, AgentEventType.MODEL_RESPONSE, step, result.getOutputText(), null); } publish(context, listener, AgentEventType.MODEL_RESPONSE, step, null, result == null ? null : result.getRawResponse()); } else { result = context.getModelClient().create(prompt); if (result != null && result.getReasoningText() != null && !result.getReasoningText().isEmpty()) { publish(context, listener, AgentEventType.MODEL_REASONING, step, result.getReasoningText(), null); } if (result != null && result.getOutputText() != null && !result.getOutputText().isEmpty()) { publish(context, listener, AgentEventType.MODEL_RESPONSE, step, result.getOutputText(), null); } publish(context, listener, AgentEventType.MODEL_RESPONSE, step, null, result == null ? null : result.getRawResponse()); } return result; } private Map retryPayload(int attempt, int maxAttempts, Throwable cause) { Map payload = new LinkedHashMap(); payload.put("attempt", attempt); payload.put("maxAttempts", maxAttempts); if (cause != null && cause.getMessage() != null && !cause.getMessage().trim().isEmpty()) { payload.put("reason", cause.getMessage().trim()); } return payload; } protected String executeTool(AgentContext context, AgentToolCall call) throws Exception { return executeTool(context, call, null, null); } protected String executeTool(AgentContext context, AgentToolCall call, Integer step, AgentListener listener) throws Exception { ToolExecutor executor = context.getToolExecutor(); if (executor == null) { throw new IllegalStateException("toolExecutor is required"); } try { return AgentToolExecutionScope.runWithEmitter(new AgentToolExecutionScope.EventEmitter() { @Override public void emit(AgentEventType type, String message, Object payload) { publish(context, listener, type, step == null ? 0 : step, message, payload); } }, new AgentToolExecutionScope.ScopeCallable() { @Override public String call() throws Exception { return executor.execute(call); } }); } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); throw interruptedException; } catch (Exception ex) { return buildToolErrorOutput(call, ex); } } protected String buildToolErrorOutput(AgentToolCall call, Exception error) { JSONObject payload = new JSONObject(); payload.put("error", safeToolErrorMessage(error)); if (call != null) { payload.put("tool", call.getName()); if (call.getCallId() != null) { payload.put("callId", call.getCallId()); } } return "TOOL_ERROR: " + JSON.toJSONString(payload); } protected String buildToolValidationErrorOutput(AgentToolCall call, String validationError) { return buildToolErrorOutput(call, new IllegalArgumentException(validationError)); } private String safeToolErrorMessage(Exception error) { if (error == null || error.getMessage() == null || error.getMessage().trim().isEmpty()) { return error == null ? "unknown tool failure" : error.getClass().getSimpleName(); } return error.getMessage().trim(); } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private List executeToolCallsSequential(AgentContext context, List calls, Integer step, AgentListener listener) throws Exception { List outputs = new ArrayList<>(); for (AgentToolCall call : calls) { outputs.add(executeTool(context, call, step, listener)); } return outputs; } private List executeToolCallsInParallel(AgentContext context, List calls, Integer step, AgentListener listener) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(calls.size()); try { List> futures = new ArrayList<>(); for (AgentToolCall call : calls) { futures.add(executor.submit(() -> executeTool(context, call, step, listener))); } List outputs = new ArrayList<>(); for (Future future : futures) { outputs.add(waitForFuture(future)); } return outputs; } finally { executor.shutdownNow(); } } private String waitForFuture(Future future) throws Exception { try { return future.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw e; } catch (ExecutionException e) { Throwable cause = e.getCause(); if (cause instanceof Exception) { throw (Exception) cause; } throw new RuntimeException(cause); } } protected void publish(AgentContext context, AgentListener listener, AgentEventType type, int step, String message, Object payload) { AgentEvent event = AgentEvent.builder() .type(type) .step(step) .message(message) .payload(payload) .build(); AgentEventPublisher publisher = context.getEventPublisher(); if (publisher != null) { publisher.publish(event); } if (listener != null) { listener.onEvent(event); } } private String mergeText(String base, String extra) { if (base == null || base.trim().isEmpty()) { return extra; } if (extra == null || extra.trim().isEmpty()) { return base; } return base + "\n" + extra; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/CodeActRuntime.java ================================================ package io.github.lnyocly.ai4j.agent.runtime; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.codeact.CodeActOptions; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutor; import io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.memory.AgentMemory; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.List; public class CodeActRuntime extends BaseAgentRuntime { private static final String CODE_CALL_ID = "code_execution"; @Override protected String runtimeName() { return "codeact"; } @Override public AgentResult run(AgentContext context, AgentRequest request) throws Exception { return runInternal(context, request, null); } @Override public void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { runInternal(context, request, listener); } protected AgentResult runInternal(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { AgentOptions options = context.getOptions(); int maxSteps = options == null ? 0 : options.getMaxSteps(); CodeActOptions codeActOptions = context.getCodeActOptions(); boolean reAct = codeActOptions != null && codeActOptions.isReAct(); AgentMemory memory = context.getMemory(); if (memory == null) { throw new IllegalStateException("memory is required"); } if (request != null && request.getInput() != null) { memory.addUserInput(request.getInput()); } CodeExecutor codeExecutor = context.getCodeExecutor(); if (codeExecutor == null) { throw new IllegalStateException("codeExecutor is required"); } List toolCalls = new ArrayList<>(); List toolResults = new ArrayList<>(); AgentModelResult lastResult = null; boolean finalizeRequested = false; int step = 0; boolean stepLimited = maxSteps > 0; while (!stepLimited || step < maxSteps) { publish(context, listener, AgentEventType.STEP_START, step, runtimeName(), null); AgentPrompt prompt = buildPrompt(context, memory, false); AgentModelResult modelResult = executeModel(context, prompt, listener, step, false); lastResult = modelResult; if (modelResult != null && modelResult.getMemoryItems() != null) { memory.addOutputItems(modelResult.getMemoryItems()); } String output = modelResult == null ? null : modelResult.getOutputText(); CodeActMessage message = parseMessage(output); if (reAct && finalizeRequested && message != null && "code".equals(message.type)) { memory.addOutputItems(java.util.Collections.singletonList( AgentInputItem.systemMessage("FINALIZE_MODE: Do not output code. Use the latest CODE_RESULT to respond with {\"type\":\"final\",\"output\":\"...\"}.") )); publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null); step += 1; continue; } if (message == null || "final".equals(message.type)) { String answer = message == null ? output : message.output; publish(context, listener, AgentEventType.FINAL_OUTPUT, step, answer, modelResult == null ? null : modelResult.getRawResponse()); publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null); return AgentResult.builder() .outputText(answer == null ? "" : answer) .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) .steps(step + 1) .build(); } if (!"code".equals(message.type) || message.code == null) { publish(context, listener, AgentEventType.FINAL_OUTPUT, step, output, modelResult == null ? null : modelResult.getRawResponse()); publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null); return AgentResult.builder() .outputText(output == null ? "" : output) .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) .steps(step + 1) .build(); } AgentToolCall toolCall = AgentToolCall.builder() .name("code") .arguments(message.code) .callId(CODE_CALL_ID + "_" + step) .build(); toolCalls.add(toolCall); publish(context, listener, AgentEventType.TOOL_CALL, step, toolCall.getName(), toolCall); CodeExecutionResult execResult = codeExecutor.execute(CodeExecutionRequest.builder() .language(message.language) .code(message.code) .toolNames(extractToolNames(context.getToolRegistry() == null ? null : context.getToolRegistry().getTools())) .toolExecutor(context.getToolExecutor()) .user(context.getUser()) .build()); String toolOutput = buildToolOutput(execResult); toolResults.add(AgentToolResult.builder() .name("code") .callId(toolCall.getCallId()) .output(toolOutput) .build()); String toolMessage = (execResult != null && execResult.isSuccess()) ? "CODE_RESULT: " + toolOutput : "CODE_ERROR: " + toolOutput; memory.addOutputItems(java.util.Collections.singletonList(AgentInputItem.systemMessage(toolMessage))); publish(context, listener, AgentEventType.TOOL_RESULT, step, toolOutput, execResult); if (reAct) { finalizeRequested = execResult != null && execResult.isSuccess(); } String directOutput = resolveDirectOutput(execResult); String fallbackOutput = resolveFallbackOutput(execResult, toolOutput); String finalOutput = directOutput == null ? fallbackOutput : directOutput; if (!reAct && finalOutput != null) { publish(context, listener, AgentEventType.FINAL_OUTPUT, step, finalOutput, modelResult == null ? null : modelResult.getRawResponse()); publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null); return AgentResult.builder() .outputText(finalOutput) .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) .steps(step + 1) .build(); } publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null); step += 1; } String outputText = lastResult == null ? "" : lastResult.getOutputText(); return AgentResult.builder() .outputText(outputText == null ? "" : outputText) .rawResponse(lastResult == null ? null : lastResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) .steps(step) .build(); } @Override protected AgentPrompt buildPrompt(AgentContext context, AgentMemory memory, boolean stream) { String systemPrompt = mergeText(context.getSystemPrompt(), runtimeInstructions(context)); AgentPrompt.AgentPromptBuilder builder = AgentPrompt.builder() .model(context.getModel()) .items(memory.getItems()) .systemPrompt(systemPrompt) .instructions(context.getInstructions()) .temperature(context.getTemperature()) .topP(context.getTopP()) .maxOutputTokens(context.getMaxOutputTokens()) .reasoning(context.getReasoning()) .store(context.getStore()) .stream(false) .user(context.getUser()) .extraBody(context.getExtraBody()); return builder.build(); } private String runtimeInstructions(AgentContext context) { StringBuilder builder = new StringBuilder(); if (context.getCodeExecutor() instanceof NashornCodeExecutor) { builder.append("You are a CodeAct agent. Use JavaScript code to call tools when needed. ") .append("Respond with a single JSON object only. ") .append("If you need to run code, respond with {\"type\":\"code\",\"language\":\"js\",\"code\":\"...\"}. ") .append("When you have the final answer, respond with {\"type\":\"final\",\"output\":\"...\"}. ") .append("Do not include any extra text outside the JSON. ") .append("In code, use JavaScript syntax compatible with Nashorn (ES5). ") .append("Do not use Promise, async/await, template literals, let/const, or arrow functions. ") .append("Always return a string or assign the final answer to __codeact_result before code ends. ") .append("If your code returns a value, it will be used as the final answer. ") .append("If you cannot use return, assign the final answer to __codeact_result. ") .append("In code, you may call tools by name (e.g. queryWeather({\"location\":\"Beijing\"})) ") .append("or use callTool(\"toolName\", args). ") .append("If you see a CODE_RESULT message, use it to respond with type=final unless more tools are required. "); } else { builder.append("You are a CodeAct agent. Use Python code to call tools when needed. ") .append("Respond with a single JSON object only. ") .append("If you need to run code, respond with {\"type\":\"code\",\"language\":\"python\",\"code\":\"...\"}. ") .append("When you have the final answer, respond with {\"type\":\"final\",\"output\":\"...\"}. ") .append("Do not include any extra text outside the JSON. ") .append("In code, use Python syntax only. ") .append("If your code returns a value, it will be used as the final answer. ") .append("If you cannot use return, assign the final answer to __codeact_result. ") .append("In code, you may call tools by name (e.g. queryWeather({\"location\":\"Beijing\"})) ") .append("or use callTool(\"toolName\", args). ") .append("If you see a CODE_RESULT message, use it to respond with type=final unless more tools are required. "); } List tools = context.getToolRegistry() == null ? null : context.getToolRegistry().getTools(); String toolGuide = buildToolGuide(tools); if (!toolGuide.isEmpty()) { builder.append("Available tools: ").append(toolGuide); } if (hasTool(tools, "queryWeather")) { if (context.getCodeExecutor() instanceof NashornCodeExecutor) { builder.append(" queryWeather returns Seniverse JSON (already parsed when possible).") .append(" Use results[0].daily[0] fields: text_day, high, low, date.") .append(" Example: var day = data.results[0].daily[0];"); } else { builder.append(" queryWeather returns Seniverse JSON string.") .append(" Parse first, then read data['results'][0]['daily'][0] fields text_day/high/low/date."); } } return builder.toString(); } private boolean hasTool(List tools, String name) { if (tools == null || name == null) { return false; } for (Object tool : tools) { if (tool instanceof Tool) { Tool.Function fn = ((Tool) tool).getFunction(); if (fn != null && name.equals(fn.getName())) { return true; } } } return false; } private String buildToolGuide(List tools) { if (tools == null || tools.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (Object tool : tools) { if (tool instanceof Tool) { Tool.Function fn = ((Tool) tool).getFunction(); if (fn != null && fn.getName() != null) { if (builder.length() > 0) { builder.append("; "); } builder.append(fn.getName()); if (fn.getDescription() != null) { builder.append(" - ").append(fn.getDescription()); } } } } return builder.toString(); } private List extractToolNames(List tools) { List names = new ArrayList<>(); if (tools == null) { return names; } for (Object tool : tools) { if (tool instanceof Tool) { Tool.Function fn = ((Tool) tool).getFunction(); if (fn != null && fn.getName() != null) { names.add(fn.getName()); } } } return names; } private CodeActMessage parseMessage(String output) { if (output == null || output.trim().isEmpty()) { return null; } String json = extractJson(output); if (json == null) { return null; } try { JSONObject obj = JSON.parseObject(json); CodeActMessage message = new CodeActMessage(); message.type = valueAsString(obj.get("type")); message.language = valueAsString(obj.get("language")); message.code = valueAsString(obj.get("code")); message.output = valueAsString(obj.get("output")); return message; } catch (Exception e) { return null; } } private String extractJson(String text) { int start = -1; int depth = 0; boolean inString = false; boolean escape = false; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (inString) { if (escape) { escape = false; } else if (c == '\\') { escape = true; } else if (c == '"') { inString = false; } continue; } if (c == '"') { inString = true; continue; } if (c == '{') { if (start == -1) { start = i; } depth += 1; } else if (c == '}') { depth -= 1; if (depth == 0 && start != -1) { return text.substring(start, i + 1); } } } return null; } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private String buildToolOutput(CodeExecutionResult result) { if (result == null) { return ""; } JSONObject obj = new JSONObject(); if (result.getResult() != null) { obj.put("result", result.getResult()); } if (result.getStdout() != null && !result.getStdout().isEmpty()) { obj.put("stdout", result.getStdout()); } if (result.getError() != null && !result.getError().isEmpty()) { obj.put("error", result.getError()); } return obj.toJSONString(); } private String resolveDirectOutput(CodeExecutionResult result) { if (result == null || !result.isSuccess()) { return null; } String value = result.getResult(); if (value == null) { return null; } String trimmed = value.trim(); if (trimmed.isEmpty()) { return null; } if ("undefined".equalsIgnoreCase(trimmed) || "null".equalsIgnoreCase(trimmed)) { return null; } return trimmed; } private String resolveFallbackOutput(CodeExecutionResult result, String toolOutput) { if (result == null) { return toolOutput == null || toolOutput.isEmpty() ? null : toolOutput; } if (result.getError() != null && !result.getError().isEmpty()) { return "CODE_ERROR: " + result.getError(); } if (result.getStdout() != null && !result.getStdout().isEmpty()) { return result.getStdout().trim(); } if (toolOutput != null && !toolOutput.isEmpty()) { return toolOutput; } return null; } private String mergeText(String base, String extra) { if (base == null || base.trim().isEmpty()) { return extra; } if (extra == null || extra.trim().isEmpty()) { return base; } return base + "\n" + extra; } private static class CodeActMessage { private String type; private String language; private String code; private String output; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/DeepResearchRuntime.java ================================================ package io.github.lnyocly.ai4j.agent.runtime; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import java.util.List; public class DeepResearchRuntime extends BaseAgentRuntime { private final Planner planner; public DeepResearchRuntime() { this(Planner.simple()); } public DeepResearchRuntime(Planner planner) { this.planner = planner; } @Override protected String runtimeName() { return "deepresearch"; } @Override protected String runtimeInstructions() { return "Break down tasks into steps and call tools for evidence. Provide a structured final summary."; } @Override public io.github.lnyocly.ai4j.agent.AgentResult run(AgentContext context, AgentRequest request) throws Exception { preparePlan(context, request); return super.run(context, request); } @Override public void runStream(AgentContext context, AgentRequest request, io.github.lnyocly.ai4j.agent.event.AgentListener listener) throws Exception { preparePlan(context, request); super.runStream(context, request, listener); } private void preparePlan(AgentContext context, AgentRequest request) { if (planner == null || request == null || request.getInput() == null) { return; } if (!(request.getInput() instanceof String)) { return; } String goal = (String) request.getInput(); List steps = planner.plan(goal); if (steps == null || steps.isEmpty()) { return; } StringBuilder planText = new StringBuilder("Plan:\n"); for (int i = 0; i < steps.size(); i++) { planText.append(i + 1).append(". ").append(steps.get(i)).append("\n"); } context.getMemory().addUserInput(AgentInputItem.systemMessage(planText.toString())); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/Planner.java ================================================ package io.github.lnyocly.ai4j.agent.runtime; import java.util.Collections; import java.util.List; public interface Planner { List plan(String goal); static Planner simple() { return new SimplePlanner(); } class SimplePlanner implements Planner { @Override public List plan(String goal) { if (goal == null) { return Collections.emptyList(); } return Collections.singletonList(goal); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/ReActRuntime.java ================================================ package io.github.lnyocly.ai4j.agent.runtime; public class ReActRuntime extends BaseAgentRuntime { @Override protected String runtimeName() { return "react"; } @Override protected String runtimeInstructions() { return "Use tools when necessary. Return concise final answers."; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffContext.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; public final class HandoffContext { private static final ThreadLocal DEPTH = new ThreadLocal<>(); private HandoffContext() { } public static int currentDepth() { Integer depth = DEPTH.get(); return depth == null ? 0 : depth; } public static T runWithDepth(int depth, HandoffCallable callable) throws Exception { Integer previous = DEPTH.get(); DEPTH.set(depth); try { return callable.call(); } finally { if (previous == null) { DEPTH.remove(); } else { DEPTH.set(previous); } } } public interface HandoffCallable { T call() throws Exception; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffFailureAction.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; public enum HandoffFailureAction { FAIL, FALLBACK_TO_PRIMARY } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffInputFilter.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; public interface HandoffInputFilter { AgentToolCall filter(AgentToolCall call) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffPolicy.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; import lombok.Builder; import lombok.Data; import java.util.Set; @Data @Builder(toBuilder = true) public class HandoffPolicy { @Builder.Default private boolean enabled = true; /** * Maximum nested handoff depth. 1 means lead -> subagent only. */ @Builder.Default private int maxDepth = 1; /** * Retry count after the first failed attempt. */ @Builder.Default private int maxRetries = 0; /** * Timeout for one handoff attempt in milliseconds, 0 disables timeout. */ @Builder.Default private long timeoutMillis = 0L; /** * Optional allow-list by subagent tool name. Empty means allow all. */ private Set allowedTools; /** * Optional deny-list by subagent tool name. */ private Set deniedTools; @Builder.Default private HandoffFailureAction onDenied = HandoffFailureAction.FAIL; @Builder.Default private HandoffFailureAction onError = HandoffFailureAction.FAIL; private HandoffInputFilter inputFilter; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/StaticSubAgentRegistry.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class StaticSubAgentRegistry implements SubAgentRegistry { private final Map subAgents; public StaticSubAgentRegistry(List definitions) { if (definitions == null || definitions.isEmpty()) { this.subAgents = Collections.emptyMap(); return; } Map map = new LinkedHashMap<>(); for (SubAgentDefinition definition : definitions) { RuntimeSubAgent runtimeSubAgent = RuntimeSubAgent.from(definition); if (map.containsKey(runtimeSubAgent.toolName)) { throw new IllegalArgumentException("duplicate subagent tool name: " + runtimeSubAgent.toolName); } map.put(runtimeSubAgent.toolName, runtimeSubAgent); } this.subAgents = map; } @Override public List getTools() { if (subAgents.isEmpty()) { return Collections.emptyList(); } List tools = new ArrayList<>(); for (RuntimeSubAgent subAgent : subAgents.values()) { tools.add(subAgent.tool); } return tools; } @Override public boolean supports(String toolName) { return toolName != null && subAgents.containsKey(toolName); } @Override public SubAgentDefinition getDefinition(String toolName) { RuntimeSubAgent subAgent = toolName == null ? null : subAgents.get(toolName); return subAgent == null ? null : subAgent.toDefinition(); } @Override public String execute(AgentToolCall call) throws Exception { if (call == null || call.getName() == null) { throw new IllegalArgumentException("subagent tool call is invalid"); } RuntimeSubAgent subAgent = subAgents.get(call.getName()); if (subAgent == null) { throw new IllegalArgumentException("unknown subagent tool: " + call.getName()); } String input = resolveInput(call.getArguments()); AgentResult result = subAgent.invoke(input); JSONObject payload = new JSONObject(); payload.put("subagent", subAgent.name); payload.put("toolName", subAgent.toolName); payload.put("output", result == null ? null : result.getOutputText()); payload.put("steps", result == null ? 0 : result.getSteps()); return payload.toJSONString(); } private String resolveInput(String arguments) { if (arguments == null || arguments.trim().isEmpty()) { return ""; } try { JSONObject obj = JSON.parseObject(arguments); String task = trimToNull(obj.getString("task")); if (task == null) { task = trimToNull(obj.getString("input")); } String context = trimToNull(obj.getString("context")); if (task == null) { return arguments; } if (context == null) { return task; } return task + "\n\nContext:\n" + context; } catch (Exception e) { return arguments; } } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private static class RuntimeSubAgent { private final String name; private final String toolName; private final Agent agent; private final SubAgentSessionMode sessionMode; private final Tool tool; private final Map sessions = new ConcurrentHashMap<>(); private RuntimeSubAgent(String name, String toolName, Agent agent, SubAgentSessionMode sessionMode, Tool tool) { this.name = name; this.toolName = toolName; this.agent = agent; this.sessionMode = sessionMode; this.tool = tool; } private AgentResult invoke(String input) throws Exception { if (sessionMode == SubAgentSessionMode.REUSE_SESSION) { AgentSession session = sessions.computeIfAbsent(toolName, key -> agent.newSession()); synchronized (session) { return session.run(AgentRequest.builder().input(input).build()); } } AgentSession session = agent.newSession(); return session.run(AgentRequest.builder().input(input).build()); } private static RuntimeSubAgent from(SubAgentDefinition definition) { if (definition == null) { throw new IllegalArgumentException("subagent definition cannot be null"); } if (definition.getAgent() == null) { throw new IllegalArgumentException("subagent agent is required"); } String name = trimToNull(definition.getName()); if (name == null) { throw new IllegalArgumentException("subagent name is required"); } String description = trimToNull(definition.getDescription()); if (description == null) { description = "Delegate a specialized task to subagent " + name; } String toolName = trimToNull(definition.getToolName()); if (toolName == null) { toolName = "subagent_" + normalizeToolName(name); } Tool tool = createTool(toolName, description); SubAgentSessionMode sessionMode = definition.getSessionMode() == null ? SubAgentSessionMode.NEW_SESSION : definition.getSessionMode(); return new RuntimeSubAgent(name, toolName, definition.getAgent(), sessionMode, tool); } private SubAgentDefinition toDefinition() { return SubAgentDefinition.builder() .name(name) .description(tool == null || tool.getFunction() == null ? null : tool.getFunction().getDescription()) .toolName(toolName) .agent(agent) .sessionMode(sessionMode) .build(); } private static Tool createTool(String toolName, String description) { Tool.Function.Property taskProperty = new Tool.Function.Property(); taskProperty.setType("string"); taskProperty.setDescription("Task to delegate to this subagent"); Tool.Function.Property contextProperty = new Tool.Function.Property(); contextProperty.setType("string"); contextProperty.setDescription("Optional extra context for the task"); Map properties = new LinkedHashMap<>(); properties.put("task", taskProperty); properties.put("context", contextProperty); Tool.Function.Parameter parameter = new Tool.Function.Parameter("object", properties, Arrays.asList("task")); Tool.Function function = new Tool.Function(toolName, description, parameter); Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); return tool; } private static String normalizeToolName(String raw) { String normalized = raw.toLowerCase().replaceAll("[^a-z0-9_]", "_"); normalized = normalized.replaceAll("_+", "_"); normalized = normalized.replaceAll("^_+", ""); normalized = normalized.replaceAll("_+$", ""); if (normalized.isEmpty()) { normalized = "agent"; } if (Character.isDigit(normalized.charAt(0))) { normalized = "agent_" + normalized; } return normalized; } private static String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentDefinition.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; import io.github.lnyocly.ai4j.agent.Agent; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class SubAgentDefinition { private String name; private String description; private String toolName; private Agent agent; @Builder.Default private SubAgentSessionMode sessionMode = SubAgentSessionMode.NEW_SESSION; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentRegistry.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import java.util.List; public interface SubAgentRegistry { List getTools(); boolean supports(String toolName); default SubAgentDefinition getDefinition(String toolName) { return null; } String execute(AgentToolCall call) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentSessionMode.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; public enum SubAgentSessionMode { NEW_SESSION, REUSE_SESSION } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentToolExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.subagent; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.runtime.AgentToolExecutionScope; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; public class SubAgentToolExecutor implements ToolExecutor { private static final AtomicInteger HANDOFF_THREAD_INDEX = new AtomicInteger(1); private static final ExecutorService HANDOFF_EXECUTOR = Executors.newCachedThreadPool(new ThreadFactory() { @Override public Thread newThread(Runnable runnable) { Thread thread = new Thread(runnable, "ai4j-handoff-" + HANDOFF_THREAD_INDEX.getAndIncrement()); thread.setDaemon(true); return thread; } }); private final SubAgentRegistry subAgentRegistry; private final ToolExecutor delegate; private final HandoffPolicy policy; private final Set allowedTools; private final Set deniedTools; public SubAgentToolExecutor(SubAgentRegistry subAgentRegistry, ToolExecutor delegate) { this(subAgentRegistry, delegate, HandoffPolicy.builder().build()); } public SubAgentToolExecutor(SubAgentRegistry subAgentRegistry, ToolExecutor delegate, HandoffPolicy policy) { this.subAgentRegistry = subAgentRegistry; this.delegate = delegate; this.policy = policy == null ? HandoffPolicy.builder().build() : policy; this.allowedTools = toSafeSet(this.policy.getAllowedTools()); this.deniedTools = toSafeSet(this.policy.getDeniedTools()); } @Override public String execute(AgentToolCall call) throws Exception { if (call == null) { return null; } String toolName = call.getName(); if (subAgentRegistry != null && subAgentRegistry.supports(toolName)) { return executeSubAgent(call, toolName); } if (delegate == null) { throw new IllegalStateException("toolExecutor is required for non-subagent tool: " + toolName); } return delegate.execute(call); } private String executeSubAgent(AgentToolCall call, String toolName) throws Exception { if (!policy.isEnabled()) { return executeWithoutPolicy(call, toolName); } SubAgentDefinition definition = subAgentRegistry == null ? null : subAgentRegistry.getDefinition(toolName); int nextDepth = HandoffContext.currentDepth() + 1; String handoffId = resolveHandoffId(call); long startedAt = System.currentTimeMillis(); emitHandoffEvent(AgentEventType.HANDOFF_START, buildHandoffPayload( handoffId, call, definition, toolName, nextDepth, "starting", "Delegating to subagent " + resolveSubAgentName(definition, toolName) + ".", null, null, null, 0L )); String deniedReason = denyReason(toolName, nextDepth); if (deniedReason != null) { return onDenied(call, definition, toolName, handoffId, nextDepth, startedAt, deniedReason); } AgentToolCall filteredCall = applyInputFilter(call); Exception lastError = null; int attempts = Math.max(1, policy.getMaxRetries() + 1); for (int attempt = 0; attempt < attempts; attempt++) { try { String output = executeOnce(filteredCall, nextDepth, toolName); emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, nextDepth, "completed", "Subagent completed.", extractResultOutput(output), null, Integer.valueOf(attempt + 1), System.currentTimeMillis() - startedAt )); return output; } catch (Exception ex) { lastError = ex; } } return onError(call, definition, toolName, handoffId, nextDepth, startedAt, lastError, attempts); } private String executeWithoutPolicy(AgentToolCall call, String toolName) throws Exception { SubAgentDefinition definition = subAgentRegistry == null ? null : subAgentRegistry.getDefinition(toolName); int depth = HandoffContext.currentDepth() + 1; String handoffId = resolveHandoffId(call); long startedAt = System.currentTimeMillis(); emitHandoffEvent(AgentEventType.HANDOFF_START, buildHandoffPayload( handoffId, call, definition, toolName, depth, "starting", "Delegating to subagent " + resolveSubAgentName(definition, toolName) + ".", null, null, null, 0L )); try { String output = executeOnce(call, depth, toolName); emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "completed", "Subagent completed.", extractResultOutput(output), null, Integer.valueOf(1), System.currentTimeMillis() - startedAt )); return output; } catch (Exception ex) { emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "failed", safeMessage(ex), null, safeMessage(ex), Integer.valueOf(1), System.currentTimeMillis() - startedAt )); throw ex; } } private AgentToolCall applyInputFilter(AgentToolCall call) throws Exception { HandoffInputFilter inputFilter = policy.getInputFilter(); if (inputFilter == null) { return call; } AgentToolCall filtered = inputFilter.filter(call); return filtered == null ? call : filtered; } private String executeOnce(AgentToolCall call, int depth, String toolName) throws Exception { long timeoutMillis = policy.getTimeoutMillis(); if (timeoutMillis <= 0L) { return HandoffContext.runWithDepth(depth, () -> subAgentRegistry.execute(call)); } Future future = HANDOFF_EXECUTOR.submit(() -> HandoffContext.runWithDepth(depth, () -> subAgentRegistry.execute(call))); try { return future.get(timeoutMillis, TimeUnit.MILLISECONDS); } catch (TimeoutException timeoutException) { future.cancel(true); throw new RuntimeException("Handoff timeout for subagent tool " + toolName + " after " + timeoutMillis + " ms", timeoutException); } catch (ExecutionException executionException) { Throwable cause = executionException.getCause(); if (cause instanceof Exception) { throw (Exception) cause; } throw new RuntimeException(cause); } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); throw interruptedException; } } private String onDenied(AgentToolCall call, SubAgentDefinition definition, String toolName, String handoffId, int depth, long startedAt, String deniedReason) throws Exception { if (policy.getOnDenied() == HandoffFailureAction.FALLBACK_TO_PRIMARY && delegate != null) { try { String output = delegate.execute(call); emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "fallback", "Handoff denied. Fell back to primary tool executor.", extractResultOutput(output), deniedReason, Integer.valueOf(0), System.currentTimeMillis() - startedAt )); return output; } catch (Exception ex) { emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "failed", safeMessage(ex), null, safeMessage(ex), Integer.valueOf(0), System.currentTimeMillis() - startedAt )); throw ex; } } emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "failed", deniedReason, null, deniedReason, Integer.valueOf(0), System.currentTimeMillis() - startedAt )); throw new IllegalStateException("Handoff denied for subagent tool " + toolName + ": " + deniedReason); } private String onError(AgentToolCall call, SubAgentDefinition definition, String toolName, String handoffId, int depth, long startedAt, Exception error, int attempts) throws Exception { if (policy.getOnError() == HandoffFailureAction.FALLBACK_TO_PRIMARY && delegate != null) { try { String output = delegate.execute(call); emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "fallback", "Subagent failed. Fell back to primary tool executor.", extractResultOutput(output), safeMessage(error), Integer.valueOf(attempts), System.currentTimeMillis() - startedAt )); return output; } catch (Exception ex) { emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "failed", safeMessage(ex), null, safeMessage(ex), Integer.valueOf(attempts), System.currentTimeMillis() - startedAt )); throw ex; } } emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload( handoffId, call, definition, toolName, depth, "failed", safeMessage(error), null, safeMessage(error), Integer.valueOf(attempts), System.currentTimeMillis() - startedAt )); if (error == null) { throw new RuntimeException("Handoff failed for subagent tool " + toolName); } throw error; } private String denyReason(String toolName, int nextDepth) { if (!allowedTools.isEmpty() && !allowedTools.contains(toolName)) { return "tool is not in allowedTools"; } if (!deniedTools.isEmpty() && deniedTools.contains(toolName)) { return "tool is in deniedTools"; } if (policy.getMaxDepth() > 0 && nextDepth > policy.getMaxDepth()) { return "handoff depth " + nextDepth + " exceeds maxDepth " + policy.getMaxDepth(); } return null; } private Set toSafeSet(Set source) { if (source == null || source.isEmpty()) { return Collections.emptySet(); } return Collections.unmodifiableSet(new HashSet<>(source)); } private void emitHandoffEvent(AgentEventType type, Map payload) { AgentToolExecutionScope.emit( type, payload == null ? null : String.valueOf(payload.get("title")), payload ); } private Map buildHandoffPayload(String handoffId, AgentToolCall call, SubAgentDefinition definition, String toolName, int depth, String status, String detail, String output, String error, Integer attempts, long durationMillis) { Map payload = new LinkedHashMap(); String subAgentName = resolveSubAgentName(definition, toolName); payload.put("handoffId", handoffId); payload.put("callId", call == null ? null : call.getCallId()); payload.put("tool", toolName); payload.put("subagent", subAgentName); payload.put("title", "Subagent " + subAgentName); payload.put("detail", detail); payload.put("status", status == null ? null : status.toLowerCase(Locale.ROOT)); payload.put("depth", Integer.valueOf(depth)); payload.put("sessionMode", definition == null || definition.getSessionMode() == null ? null : definition.getSessionMode().name().toLowerCase(Locale.ROOT)); payload.put("attempts", attempts); payload.put("durationMillis", Long.valueOf(durationMillis)); payload.put("output", output); payload.put("error", error); return payload; } private String resolveSubAgentName(SubAgentDefinition definition, String toolName) { String name = definition == null ? null : trimToNull(definition.getName()); return name == null ? firstNonBlank(trimToNull(toolName), "subagent") : name; } private String resolveHandoffId(AgentToolCall call) { String callId = call == null ? null : trimToNull(call.getCallId()); return "handoff:" + (callId == null ? UUID.randomUUID().toString() : callId); } private String extractResultOutput(String raw) { String value = trimToNull(raw); if (value == null) { return null; } try { JSONObject object = JSON.parseObject(value); String output = trimToNull(object == null ? null : object.getString("output")); return output == null ? value : output; } catch (Exception ignored) { return value; } } private String safeMessage(Throwable throwable) { if (throwable == null) { return null; } Throwable current = throwable; String message = null; while (current != null) { if (!isBlank(current.getMessage())) { message = current.getMessage().trim(); } current = current.getCause(); } return message == null ? throwable.getClass().getSimpleName() : message; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { String normalized = trimToNull(value); if (normalized != null) { return normalized; } } return null; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeam.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.team.tool.AgentTeamToolExecutor; import io.github.lnyocly.ai4j.agent.team.tool.AgentTeamToolRegistry; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; public class AgentTeam implements AgentTeamControl { private static final String SYSTEM_MEMBER = "system"; private static final String LEAD_MEMBER = "lead"; private final AgentTeamPlanner planner; private final AgentTeamSynthesizer synthesizer; private final AgentTeamOptions options; private final String teamId; private final List orderedMembers; private final Map membersById; private final AgentTeamMessageBus messageBus; private final AgentTeamStateStore stateStore; private final AgentTeamPlanApproval planApproval; private final List hooks; private final AgentTeamToolRegistry teamToolRegistry; private final Object memberLock = new Object(); private final Object runtimeLock = new Object(); private volatile AgentTeamTaskBoard activeBoard; private volatile List lastTaskStates = Collections.emptyList(); private volatile String activeObjective; private volatile String lastOutput; private volatile int lastRounds; private volatile long lastRunStartedAt; private volatile long lastRunCompletedAt; AgentTeam(AgentTeamBuilder builder) { if (builder == null) { throw new IllegalArgumentException("builder is required"); } this.options = builder.getOptions() == null ? AgentTeamOptions.builder().build() : builder.getOptions(); this.teamId = firstNonBlank(builder.getTeamId(), UUID.randomUUID().toString()); this.messageBus = resolveMessageBus(builder); this.stateStore = resolveStateStore(builder); this.planApproval = builder.getPlanApproval(); this.hooks = builder.getHooks() == null ? Collections.emptyList() : new ArrayList<>(builder.getHooks()); this.teamToolRegistry = new AgentTeamToolRegistry(); List rawMembers = builder.getMembers(); if (rawMembers == null || rawMembers.isEmpty()) { throw new IllegalStateException("at least one team member is required"); } this.orderedMembers = new ArrayList<>(); this.membersById = new LinkedHashMap<>(); for (AgentTeamMember member : rawMembers) { RuntimeMember runtimeMember = RuntimeMember.from(member); if (membersById.containsKey(runtimeMember.id)) { throw new IllegalStateException("duplicate team member id: " + runtimeMember.id); } orderedMembers.add(runtimeMember); membersById.put(runtimeMember.id, runtimeMember); } Agent leadAgent = builder.getLeadAgent(); AgentTeamPlanner plannerOverride = builder.getPlanner(); if (plannerOverride != null) { this.planner = plannerOverride; } else { Agent plannerAgent = builder.getPlannerAgent(); if (plannerAgent == null) { plannerAgent = leadAgent; } if (plannerAgent == null) { throw new IllegalStateException("planner or plannerAgent or leadAgent is required"); } this.planner = new LlmAgentTeamPlanner(plannerAgent); } AgentTeamSynthesizer synthesizerOverride = builder.getSynthesizer(); if (synthesizerOverride != null) { this.synthesizer = synthesizerOverride; } else { Agent synthesizerAgent = builder.getSynthesizerAgent(); if (synthesizerAgent == null) { synthesizerAgent = leadAgent; } if (synthesizerAgent == null && builder.getPlannerAgent() != null) { synthesizerAgent = builder.getPlannerAgent(); } if (synthesizerAgent == null) { throw new IllegalStateException("synthesizer or synthesizerAgent or leadAgent is required"); } this.synthesizer = new LlmAgentTeamSynthesizer(synthesizerAgent); } } public static AgentTeamBuilder builder() { return AgentTeamBuilder.builder(); } public String getTeamId() { return teamId; } public AgentTeamState snapshotState() { return AgentTeamState.builder() .teamId(teamId) .objective(currentObjective()) .members(snapshotMemberViews()) .taskStates(copyTaskStates(listTaskStates())) .messages(options.isEnableMessageBus() ? copyMessages(messageBus.snapshot()) : Collections.emptyList()) .lastOutput(lastOutput) .lastRounds(lastRounds) .lastRunStartedAt(lastRunStartedAt) .lastRunCompletedAt(lastRunCompletedAt) .updatedAt(System.currentTimeMillis()) .runActive(currentBoard() != null) .build(); } public AgentTeamState loadPersistedState() { if (stateStore == null) { return null; } AgentTeamState state = stateStore.load(teamId); restoreState(state); return state; } public void restoreState(AgentTeamState state) { if (state == null) { return; } if (!isSameTeam(state)) { throw new IllegalArgumentException("team state does not belong to teamId=" + teamId); } if (options.isEnableMessageBus()) { messageBus.restore(copyMessages(state.getMessages())); } synchronized (runtimeLock) { activeObjective = state.getObjective(); activeBoard = null; lastTaskStates = copyTaskStates(state.getTaskStates()); } lastOutput = state.getLastOutput(); lastRounds = state.getLastRounds(); lastRunStartedAt = state.getLastRunStartedAt(); lastRunCompletedAt = state.getLastRunCompletedAt(); } public boolean clearPersistedState() { if (options.isEnableMessageBus()) { messageBus.clear(); } synchronized (runtimeLock) { activeBoard = null; activeObjective = null; lastTaskStates = Collections.emptyList(); } lastOutput = null; lastRounds = 0; lastRunStartedAt = 0L; lastRunCompletedAt = 0L; if (stateStore == null) { return false; } return stateStore.delete(teamId); } @Override public void registerMember(AgentTeamMember member) { if (!options.isAllowDynamicMemberRegistration()) { throw new IllegalStateException("dynamic member registration is disabled"); } RuntimeMember runtimeMember = RuntimeMember.from(member); synchronized (memberLock) { if (membersById.containsKey(runtimeMember.id)) { throw new IllegalStateException("duplicate team member id: " + runtimeMember.id); } orderedMembers.add(runtimeMember); membersById.put(runtimeMember.id, runtimeMember); } persistState(); } @Override public boolean unregisterMember(String memberId) { if (!options.isAllowDynamicMemberRegistration()) { throw new IllegalStateException("dynamic member registration is disabled"); } String normalized = normalize(memberId); if (normalized == null) { return false; } synchronized (memberLock) { if (!membersById.containsKey(normalized)) { return false; } if (orderedMembers.size() <= 1) { return false; } RuntimeMember removed = membersById.remove(normalized); if (removed != null) { orderedMembers.remove(removed); persistState(); return true; } return false; } } @Override public List listMembers() { return snapshotMembers(); } @Override public List listMessages() { if (!options.isEnableMessageBus()) { return Collections.emptyList(); } return messageBus.snapshot(); } @Override public List listMessagesFor(String memberId, int limit) { if (!options.isEnableMessageBus()) { return Collections.emptyList(); } String normalizedMemberId = normalize(memberId); if (normalizedMemberId != null && !"*".equals(normalizedMemberId)) { validateKnownMemberId(normalizedMemberId, true, "memberId"); } return messageBus.historyFor(normalizedMemberId, limit); } @Override public void publishMessage(AgentTeamMessage message) { publishMessageInternal(message); } @Override public void sendMessage(String fromMemberId, String toMemberId, String type, String taskId, String content) { String from = normalize(fromMemberId); String to = normalize(toMemberId); validateKnownMemberId(from, true, "fromMemberId"); validateKnownMemberId(to, false, "toMemberId"); publishMessage(from, to, type, taskId, content); } @Override public void broadcastMessage(String fromMemberId, String type, String taskId, String content) { String from = normalize(fromMemberId); validateKnownMemberId(from, true, "fromMemberId"); publishMessage(from, "*", type, taskId, content); } @Override public List listTaskStates() { AgentTeamTaskBoard board = currentBoard(); if (board != null) { return board.snapshot(); } synchronized (runtimeLock) { if (lastTaskStates == null || lastTaskStates.isEmpty()) { return Collections.emptyList(); } return new ArrayList<>(lastTaskStates); } } @Override public boolean claimTask(String taskId, String memberId) { AgentTeamTaskBoard board = currentBoard(); if (board == null) { return false; } String normalizedMemberId = normalize(memberId); validateKnownMemberId(normalizedMemberId, false, "memberId"); boolean claimed = board.claimTask(taskId, normalizedMemberId); if (claimed) { fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(normalizedMemberId), "Claimed by " + normalizedMemberId + "."); } return claimed; } @Override public boolean releaseTask(String taskId, String memberId, String reason) { AgentTeamTaskBoard board = currentBoard(); if (board == null) { return false; } String normalizedMemberId = normalize(memberId); validateKnownMemberId(normalizedMemberId, false, "memberId"); boolean released = board.releaseTask(taskId, normalizedMemberId, reason); if (released) { fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(normalizedMemberId), firstNonBlank(reason, "Released back to queue.")); } return released; } @Override public boolean reassignTask(String taskId, String fromMemberId, String toMemberId) { AgentTeamTaskBoard board = currentBoard(); if (board == null) { return false; } String from = normalize(fromMemberId); String to = normalize(toMemberId); validateKnownMemberId(from, false, "fromMemberId"); validateKnownMemberId(to, false, "toMemberId"); boolean reassigned = board.reassignTask(taskId, from, to); if (reassigned) { fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(to), "Reassigned from " + from + " to " + to + "."); } return reassigned; } @Override public boolean heartbeatTask(String taskId, String memberId) { AgentTeamTaskBoard board = currentBoard(); if (board == null) { return false; } String normalizedMemberId = normalize(memberId); validateKnownMemberId(normalizedMemberId, false, "memberId"); boolean heartbeated = board.heartbeatTask(taskId, normalizedMemberId); if (heartbeated) { fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(normalizedMemberId), "Heartbeat from " + normalizedMemberId + "."); } return heartbeated; } public AgentTeamResult run(String objective) throws Exception { return run(AgentRequest.builder().input(objective).build()); } public AgentTeamResult run(AgentRequest request) throws Exception { long start = System.currentTimeMillis(); String objective = request == null ? "" : toText(request.getInput()); lastRunStartedAt = start; lastRunCompletedAt = 0L; if (options.isEnableMessageBus()) { messageBus.clear(); } List members = snapshotMembers(); fireBeforePlan(objective, members); AgentTeamPlan plan = planner.plan(objective, members, options); if (plan == null) { plan = AgentTeamPlan.builder().tasks(Collections.emptyList()).build(); } AgentTeamTaskBoard board = new AgentTeamTaskBoard(plan.getTasks()); plan = plan.toBuilder().tasks(board.normalizedTasks()).build(); synchronized (runtimeLock) { activeBoard = board; lastTaskStates = Collections.emptyList(); activeObjective = objective; } persistState(); try { fireAfterPlan(objective, plan); ensurePlanApproved(objective, plan, members); DispatchOutcome dispatch = dispatchTasks(objective, board); AgentResult synthesis = synthesizer.synthesize(objective, plan, dispatch.results, options); fireAfterSynthesis(objective, synthesis); List taskStates = board.snapshot(); rememberTaskStates(taskStates); lastRounds = dispatch.rounds; lastOutput = synthesis == null ? "" : synthesis.getOutputText(); lastRunCompletedAt = System.currentTimeMillis(); persistState(); return AgentTeamResult.builder() .teamId(teamId) .objective(objective) .plan(plan) .memberResults(dispatch.results) .taskStates(taskStates) .messages(options.isEnableMessageBus() ? messageBus.snapshot() : Collections.emptyList()) .rounds(dispatch.rounds) .synthesisResult(synthesis) .output(synthesis == null ? "" : synthesis.getOutputText()) .totalDurationMillis(System.currentTimeMillis() - start) .build(); } finally { rememberTaskStates(board.snapshot()); lastRunCompletedAt = System.currentTimeMillis(); persistState(); synchronized (runtimeLock) { activeBoard = null; activeObjective = null; } } } private void ensurePlanApproved(String objective, AgentTeamPlan plan, List members) { if (planApproval == null) { if (options.isRequirePlanApproval()) { throw new IllegalStateException("plan approval is required but no planApproval callback provided"); } return; } boolean approved = planApproval.approve(objective, plan, members, options); if (!approved) { throw new IllegalStateException("plan rejected by planApproval callback"); } } private DispatchOutcome dispatchTasks(String objective, AgentTeamTaskBoard board) throws Exception { if (board == null || board.size() == 0) { return new DispatchOutcome(Collections.emptyList(), 0); } List results = new ArrayList<>(); int rounds = 0; int maxRounds = options.getMaxRounds() <= 0 ? 64 : options.getMaxRounds(); while (board.hasWorkRemaining()) { if (options.getTaskClaimTimeoutMillis() > 0L) { board.recoverTimedOutClaims(options.getTaskClaimTimeoutMillis(), "task claim timeout"); } if (rounds >= maxRounds) { board.markStalledAsBlocked("max rounds exceeded: " + maxRounds); break; } int batchSize = options.isParallelDispatch() ? Math.max(1, options.getMaxConcurrency()) : 1; List readyTasks = board.nextReadyTasks(batchSize); if (readyTasks.isEmpty()) { board.markStalledAsBlocked("unresolved dependencies or cyclic plan"); break; } rounds++; List prepared = new ArrayList<>(); for (AgentTeamTaskState state : readyTasks) { RuntimeMember member; try { member = resolveMember(state.getTask() == null ? null : state.getTask().getMemberId()); } catch (Exception e) { board.markFailed(state.getTaskId(), e.getMessage(), 0L); AgentTeamMemberResult failure = AgentTeamMemberResult.builder() .taskId(state.getTaskId()) .task(state.getTask()) .taskStatus(AgentTeamTaskStatus.FAILED) .memberId(state.getTask() == null ? null : state.getTask().getMemberId()) .error(e.getMessage()) .durationMillis(0L) .build(); results.add(failure); fireAfterTask(objective, failure); if (!options.isContinueOnMemberError()) { throw new IllegalStateException("team member failed: " + failure.getMemberId() + " -> " + failure.getError()); } continue; } if (!board.claimTask(state.getTaskId(), member.id)) { continue; } prepared.add(new PreparedDispatch(state.getTaskId(), state.getTask(), member)); } if (prepared.isEmpty()) { continue; } List roundResults = executeRound(objective, prepared, board); for (AgentTeamMemberResult result : roundResults) { results.add(result); if (!result.isSuccess() && !options.isContinueOnMemberError()) { throw new IllegalStateException("team member failed: " + result.getMemberId() + " -> " + result.getError()); } } } return new DispatchOutcome(results, rounds); } private List executeRound(String objective, List dispatches, AgentTeamTaskBoard board) throws Exception { if (dispatches.size() <= 1 || !options.isParallelDispatch()) { List results = new ArrayList<>(); for (PreparedDispatch dispatch : dispatches) { results.add(executePreparedTask(objective, dispatch, board)); } return results; } int configured = options.getMaxConcurrency() <= 0 ? dispatches.size() : options.getMaxConcurrency(); int threads = Math.max(1, Math.min(configured, dispatches.size())); ExecutorService executor = Executors.newFixedThreadPool(threads); try { List> futures = new ArrayList<>(); for (PreparedDispatch dispatch : dispatches) { futures.add(executor.submit(new Callable() { @Override public AgentTeamMemberResult call() throws Exception { return executePreparedTask(objective, dispatch, board); } })); } List results = new ArrayList<>(); for (Future future : futures) { results.add(waitForFuture(future)); } return results; } finally { executor.shutdownNow(); } } private AgentTeamMemberResult waitForFuture(Future future) throws Exception { try { return future.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw e; } catch (ExecutionException e) { Throwable cause = e.getCause(); if (cause instanceof Exception) { throw (Exception) cause; } throw new RuntimeException(cause); } } private AgentTeamMemberResult executePreparedTask(String objective, PreparedDispatch dispatch, AgentTeamTaskBoard board) { long start = System.currentTimeMillis(); AgentTeamMember memberView = dispatch.member.toPublicMember(); fireBeforeTask(objective, dispatch.task, memberView); publishMessage(SYSTEM_MEMBER, dispatch.member.id, "task.assigned", dispatch.taskId, safe(dispatch.task == null ? null : dispatch.task.getTask())); try { String input = buildDispatchInput(objective, dispatch.member, dispatch.task); AgentResult result = runMemberTask(dispatch, input); String output = result == null ? "" : result.getOutputText(); long duration = System.currentTimeMillis() - start; board.markCompleted(dispatch.taskId, output, duration); publishMessage(dispatch.member.id, LEAD_MEMBER, "task.result", dispatch.taskId, safeShort(output)); AgentTeamMemberResult memberResult = AgentTeamMemberResult.builder() .taskId(dispatch.taskId) .memberId(dispatch.member.id) .memberName(dispatch.member.name) .task(dispatch.task) .taskStatus(AgentTeamTaskStatus.COMPLETED) .output(output) .rawResult(result) .durationMillis(duration) .build(); fireAfterTask(objective, memberResult); return memberResult; } catch (Exception e) { long duration = System.currentTimeMillis() - start; board.markFailed(dispatch.taskId, e.getMessage(), duration); publishMessage(dispatch.member.id, LEAD_MEMBER, "task.error", dispatch.taskId, safe(e.getMessage())); AgentTeamMemberResult memberResult = AgentTeamMemberResult.builder() .taskId(dispatch.taskId) .memberId(dispatch.member.id) .memberName(dispatch.member.name) .task(dispatch.task) .taskStatus(AgentTeamTaskStatus.FAILED) .error(e.getMessage()) .durationMillis(duration) .build(); fireAfterTask(objective, memberResult); return memberResult; } } private AgentResult runMemberTask(PreparedDispatch dispatch, String input) throws Exception { AgentSession session = dispatch.member.agent.newSession(); if (session == null) { throw new IllegalStateException("failed to create team member session"); } AgentContext sessionContext = session.getContext(); if (sessionContext != null && options.isEnableMemberTeamTools()) { AgentToolRegistry originalRegistry = sessionContext.getToolRegistry(); ToolExecutor originalExecutor = sessionContext.getToolExecutor(); AgentToolRegistry mergedRegistry; if (originalRegistry == null) { mergedRegistry = teamToolRegistry; } else { mergedRegistry = new CompositeToolRegistry(originalRegistry, teamToolRegistry); } sessionContext.setToolRegistry(mergedRegistry); sessionContext.setToolExecutor(new AgentTeamToolExecutor(this, dispatch.member.id, dispatch.taskId, originalExecutor)); } return session.run(AgentRequest.builder().input(input).build()); } private String buildDispatchInput(String objective, RuntimeMember member, AgentTeamTask task) { StringBuilder sb = new StringBuilder(); sb.append("Team member role: ").append(member.name).append("\n"); if (member.description != null && !member.description.trim().isEmpty()) { sb.append("Expertise: ").append(member.description).append("\n"); } if (task != null && task.getId() != null) { sb.append("Task ID: ").append(task.getId()).append("\n"); } if (task != null && task.getDependsOn() != null && !task.getDependsOn().isEmpty()) { sb.append("Dependencies: ").append(task.getDependsOn()).append("\n"); } if (options.isIncludeOriginalObjectiveInDispatch()) { sb.append("Objective:\n").append(objective == null ? "" : objective).append("\n\n"); } sb.append("Assigned task:\n").append(task == null ? "" : safe(task.getTask())).append("\n"); if (options.isIncludeTaskContextInDispatch()) { String context = task == null ? null : safe(task.getContext()); if (context != null && !context.trim().isEmpty()) { sb.append("Additional context:\n").append(context).append("\n"); } } if (options.isEnableMessageBus() && options.isIncludeMessageHistoryInDispatch()) { List history = messageBus.historyFor(member.id, options.getMessageHistoryLimit()); if (history != null && !history.isEmpty()) { sb.append("Recent team messages:\n"); for (AgentTeamMessage message : history) { sb.append("- [").append(safe(message.getType())).append("] ") .append(safe(message.getFromMemberId())).append(" -> ") .append(safe(message.getToMemberId())).append(": ") .append(safeShort(message.getContent())).append("\n"); } } } sb.append("Return concise, high-signal output for the team lead."); return sb.toString(); } private void publishMessage(String from, String to, String type, String taskId, String content) { if (!options.isEnableMessageBus()) { return; } AgentTeamMessage message = AgentTeamMessage.builder() .id(UUID.randomUUID().toString()) .fromMemberId(from) .toMemberId(to) .type(type) .taskId(taskId) .content(content) .createdAt(System.currentTimeMillis()) .build(); publishMessageInternal(message); } private void publishMessageInternal(AgentTeamMessage message) { if (!options.isEnableMessageBus() || message == null) { return; } AgentTeamMessage safeMessage = message; if (safeMessage.getId() == null || safeMessage.getId().trim().isEmpty() || safeMessage.getCreatedAt() <= 0L) { safeMessage = safeMessage.toBuilder() .id(safeMessage.getId() == null || safeMessage.getId().trim().isEmpty() ? UUID.randomUUID().toString() : safeMessage.getId()) .createdAt(safeMessage.getCreatedAt() <= 0L ? System.currentTimeMillis() : safeMessage.getCreatedAt()) .build(); } messageBus.publish(safeMessage); persistState(); for (AgentTeamHook hook : hooks) { try { hook.onMessage(safeMessage); } catch (Exception ignored) { // hook failures must not break dispatch } } } private RuntimeMember resolveMember(String requestedId) { synchronized (memberLock) { if (orderedMembers.isEmpty()) { throw new IllegalStateException("no team member available"); } if (requestedId == null || requestedId.trim().isEmpty()) { return orderedMembers.get(0); } String normalized = normalize(requestedId); RuntimeMember member = membersById.get(normalized); if (member != null) { return member; } if (options.isFailOnUnknownMember()) { throw new IllegalStateException("unknown team member: " + requestedId); } return orderedMembers.get(0); } } private List snapshotMembers() { synchronized (memberLock) { List snapshot = new ArrayList<>(); for (RuntimeMember member : orderedMembers) { snapshot.add(member.toPublicMember()); } return snapshot; } } private AgentTeamTaskBoard currentBoard() { synchronized (runtimeLock) { return activeBoard; } } private List snapshotMemberViews() { synchronized (memberLock) { List snapshot = new ArrayList(); for (RuntimeMember member : orderedMembers) { snapshot.add(AgentTeamMemberSnapshot.from(member.toPublicMember())); } return snapshot; } } private void rememberTaskStates(List taskStates) { synchronized (runtimeLock) { if (taskStates == null || taskStates.isEmpty()) { lastTaskStates = Collections.emptyList(); } else { lastTaskStates = new ArrayList<>(taskStates); } } persistState(); } private String currentObjective() { synchronized (runtimeLock) { return activeObjective; } } private AgentTeamMessageBus resolveMessageBus(AgentTeamBuilder builder) { if (builder.getMessageBus() != null) { return builder.getMessageBus(); } if (builder.getStorageDirectory() != null) { return new FileAgentTeamMessageBus( builder.getStorageDirectory() .resolve("mailbox") .resolve(teamId + ".jsonl") ); } return new InMemoryAgentTeamMessageBus(); } private AgentTeamStateStore resolveStateStore(AgentTeamBuilder builder) { if (builder.getStateStore() != null) { return builder.getStateStore(); } if (builder.getStorageDirectory() != null) { return new FileAgentTeamStateStore(builder.getStorageDirectory().resolve("state")); } return null; } private void persistState() { if (stateStore == null) { return; } stateStore.save(snapshotState()); } private boolean isSameTeam(AgentTeamState state) { return state != null && state.getTeamId() != null && state.getTeamId().equals(teamId); } private List copyTaskStates(List taskStates) { if (taskStates == null || taskStates.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(taskStates.size()); for (AgentTeamTaskState state : taskStates) { copy.add(state == null ? null : state.toBuilder().build()); } return copy; } private List copyMessages(List messages) { if (messages == null || messages.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(messages.size()); for (AgentTeamMessage message : messages) { copy.add(message == null ? null : message.toBuilder().build()); } return copy; } private AgentTeamMember resolveMemberView(String memberId) { if (memberId == null || memberId.trim().isEmpty()) { return null; } synchronized (memberLock) { RuntimeMember member = membersById.get(memberId); return member == null ? null : member.toPublicMember(); } } private void fireTaskStateChanged(AgentTeamTaskState state, AgentTeamMember member, String detail) { for (AgentTeamHook hook : hooks) { try { hook.onTaskStateChanged(currentObjective(), state, member, detail); } catch (Exception ignored) { // hook failures must not break dispatch } } } private void validateKnownMemberId(String memberId, boolean allowReserved, String fieldName) { if (memberId == null || memberId.trim().isEmpty()) { throw new IllegalArgumentException(fieldName + " is required"); } if (allowReserved && isReservedMember(memberId)) { return; } synchronized (memberLock) { if (!membersById.containsKey(memberId)) { throw new IllegalArgumentException("unknown team member: " + memberId); } } } private boolean isReservedMember(String memberId) { return SYSTEM_MEMBER.equals(memberId) || LEAD_MEMBER.equals(memberId); } private void fireBeforePlan(String objective, List members) { for (AgentTeamHook hook : hooks) { try { hook.beforePlan(objective, members, options); } catch (Exception ignored) { // hook failures must not break dispatch } } } private void fireAfterPlan(String objective, AgentTeamPlan plan) { for (AgentTeamHook hook : hooks) { try { hook.afterPlan(objective, plan); } catch (Exception ignored) { // hook failures must not break dispatch } } } private void fireBeforeTask(String objective, AgentTeamTask task, AgentTeamMember member) { for (AgentTeamHook hook : hooks) { try { hook.beforeTask(objective, task, member); } catch (Exception ignored) { // hook failures must not break dispatch } } } private void fireAfterTask(String objective, AgentTeamMemberResult result) { for (AgentTeamHook hook : hooks) { try { hook.afterTask(objective, result); } catch (Exception ignored) { // hook failures must not break dispatch } } } private void fireAfterSynthesis(String objective, AgentResult synthesis) { for (AgentTeamHook hook : hooks) { try { hook.afterSynthesis(objective, synthesis); } catch (Exception ignored) { // hook failures must not break dispatch } } publishMessage(SYSTEM_MEMBER, LEAD_MEMBER, "run.complete", null, synthesis == null ? "" : safeShort(synthesis.getOutputText())); } private String normalize(String raw) { if (raw == null) { return null; } String normalized = raw.trim().toLowerCase(); if (normalized.isEmpty()) { return null; } normalized = normalized.replaceAll("[^a-z0-9_\\-]", "_"); normalized = normalized.replaceAll("_+", "_"); normalized = normalized.replaceAll("^_+", ""); normalized = normalized.replaceAll("_+$", ""); return normalized; } private String safe(String value) { return value == null ? "" : value; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value; } } return null; } private String safeShort(String value) { if (value == null) { return ""; } String text = value.trim(); if (text.length() <= 240) { return text; } return text.substring(0, 240) + "..."; } private String toText(Object value) { if (value == null) { return ""; } if (value instanceof String) { return (String) value; } return String.valueOf(value); } private static class RuntimeMember { private final String id; private final String name; private final String description; private final Agent agent; private RuntimeMember(String id, String name, String description, Agent agent) { this.id = id; this.name = name; this.description = description; this.agent = agent; } private static RuntimeMember from(AgentTeamMember member) { if (member == null) { throw new IllegalArgumentException("team member cannot be null"); } if (member.getAgent() == null) { throw new IllegalArgumentException("team member agent is required"); } String id = member.resolveId(); String name = member.getName(); if (name == null || name.trim().isEmpty()) { name = id; } return new RuntimeMember(id, name, member.getDescription(), member.getAgent()); } private AgentTeamMember toPublicMember() { return AgentTeamMember.builder() .id(id) .name(name) .description(description) .agent(agent) .build(); } } private static class PreparedDispatch { private final String taskId; private final AgentTeamTask task; private final RuntimeMember member; private PreparedDispatch(String taskId, AgentTeamTask task, RuntimeMember member) { this.taskId = taskId; this.task = task; this.member = member; } } private static class DispatchOutcome { private final List results; private final int rounds; private DispatchOutcome(List results, int rounds) { this.results = results; this.rounds = rounds; } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamAgentRuntime.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentRuntime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import java.util.ArrayList; import java.util.List; public class AgentTeamAgentRuntime implements AgentRuntime { private final AgentTeamBuilder template; public AgentTeamAgentRuntime(AgentTeamBuilder template) { if (template == null) { throw new IllegalArgumentException("template is required"); } this.template = template; } @Override public AgentResult run(AgentContext context, AgentRequest request) throws Exception { AgentTeam team = prepareTeam(null); AgentTeamResult result = team.run(request); return toAgentResult(result); } @Override public void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { try { AgentTeam team = prepareTeam(listener); AgentTeamResult result = team.run(request); if (listener != null) { listener.onEvent(AgentEvent.builder() .type(AgentEventType.FINAL_OUTPUT) .message(result == null ? null : result.getOutput()) .payload(result) .build()); } } catch (Exception ex) { if (listener != null) { listener.onEvent(AgentEvent.builder() .type(AgentEventType.ERROR) .message(ex.getMessage()) .payload(ex) .build()); } throw ex; } } private AgentTeam prepareTeam(AgentListener listener) { AgentTeamBuilder builder = copyBuilder(template); builder.hook(new AgentTeamEventHook(listener)); return builder.build(); } private AgentTeamBuilder copyBuilder(AgentTeamBuilder source) { AgentTeamBuilder copy = AgentTeam.builder(); copy.leadAgent(source.getLeadAgent()); copy.plannerAgent(source.getPlannerAgent()); copy.synthesizerAgent(source.getSynthesizerAgent()); copy.planner(source.getPlanner()); copy.synthesizer(source.getSynthesizer()); if (source.getMembers() != null) { copy.members(new ArrayList(source.getMembers())); } copy.options(source.getOptions()); copy.messageBus(source.getMessageBus()); copy.stateStore(source.getStateStore()); copy.teamId(source.getTeamId()); copy.storageDirectory(source.getStorageDirectory()); copy.planApproval(source.getPlanApproval()); if (source.getHooks() != null) { copy.hooks(new ArrayList(source.getHooks())); } return copy; } private AgentResult toAgentResult(AgentTeamResult result) { return AgentResult.builder() .outputText(result == null ? null : result.getOutput()) .rawResponse(result) .steps(result == null ? null : Integer.valueOf(result.getRounds())) .build(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamBuilder.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.memory.AgentMemory; import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; import java.util.function.Supplier; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; public class AgentTeamBuilder { private Agent leadAgent; private Agent plannerAgent; private Agent synthesizerAgent; private AgentTeamPlanner planner; private AgentTeamSynthesizer synthesizer; private final List members = new ArrayList<>(); private AgentTeamOptions options; private AgentTeamMessageBus messageBus; private AgentTeamStateStore stateStore; private String teamId; private Path storageDirectory; private AgentTeamPlanApproval planApproval; private final List hooks = new ArrayList<>(); public static AgentTeamBuilder builder() { return new AgentTeamBuilder(); } public Agent getLeadAgent() { return leadAgent; } public Agent getPlannerAgent() { return plannerAgent; } public Agent getSynthesizerAgent() { return synthesizerAgent; } public AgentTeamPlanner getPlanner() { return planner; } public AgentTeamSynthesizer getSynthesizer() { return synthesizer; } public List getMembers() { return members; } public AgentTeamOptions getOptions() { return options; } public AgentTeamMessageBus getMessageBus() { return messageBus; } public AgentTeamStateStore getStateStore() { return stateStore; } public String getTeamId() { return teamId; } public Path getStorageDirectory() { return storageDirectory; } public AgentTeamPlanApproval getPlanApproval() { return planApproval; } public List getHooks() { return hooks; } public AgentTeamBuilder leadAgent(Agent leadAgent) { this.leadAgent = leadAgent; return this; } public AgentTeamBuilder plannerAgent(Agent plannerAgent) { this.plannerAgent = plannerAgent; return this; } public AgentTeamBuilder synthesizerAgent(Agent synthesizerAgent) { this.synthesizerAgent = synthesizerAgent; return this; } public AgentTeamBuilder planner(AgentTeamPlanner planner) { this.planner = planner; return this; } public AgentTeamBuilder synthesizer(AgentTeamSynthesizer synthesizer) { this.synthesizer = synthesizer; return this; } public AgentTeamBuilder member(AgentTeamMember member) { if (member != null) { this.members.add(member); } return this; } public AgentTeamBuilder members(List members) { if (members != null && !members.isEmpty()) { this.members.addAll(members); } return this; } public AgentTeamBuilder options(AgentTeamOptions options) { this.options = options; return this; } public AgentTeamBuilder messageBus(AgentTeamMessageBus messageBus) { this.messageBus = messageBus; return this; } public AgentTeamBuilder stateStore(AgentTeamStateStore stateStore) { this.stateStore = stateStore; return this; } public AgentTeamBuilder teamId(String teamId) { this.teamId = teamId; return this; } public AgentTeamBuilder storageDirectory(Path storageDirectory) { this.storageDirectory = storageDirectory; return this; } public AgentTeamBuilder planApproval(AgentTeamPlanApproval planApproval) { this.planApproval = planApproval; return this; } public AgentTeamBuilder hook(AgentTeamHook hook) { if (hook != null) { this.hooks.add(hook); } return this; } public AgentTeamBuilder hooks(List hooks) { if (hooks != null && !hooks.isEmpty()) { this.hooks.addAll(hooks); } return this; } public AgentTeam build() { return new AgentTeam(this); } public Agent buildAgent() { return new Agent( new AgentTeamAgentRuntime(this), AgentContext.builder() .memory(new InMemoryAgentMemory()) .build(), new Supplier() { @Override public AgentMemory get() { return new InMemoryAgentMemory(); } } ); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamControl.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.List; public interface AgentTeamControl { void registerMember(AgentTeamMember member); boolean unregisterMember(String memberId); List listMembers(); List listMessages(); List listMessagesFor(String memberId, int limit); void publishMessage(AgentTeamMessage message); void sendMessage(String fromMemberId, String toMemberId, String type, String taskId, String content); void broadcastMessage(String fromMemberId, String type, String taskId, String content); List listTaskStates(); boolean claimTask(String taskId, String memberId); boolean releaseTask(String taskId, String memberId, String reason); boolean reassignTask(String taskId, String fromMemberId, String toMemberId); boolean heartbeatTask(String taskId, String memberId); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamEventHook.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.runtime.AgentToolExecutionScope; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class AgentTeamEventHook implements AgentTeamHook { private final AgentListener listener; public AgentTeamEventHook() { this(null); } public AgentTeamEventHook(AgentListener listener) { this.listener = listener; } @Override public void afterPlan(String objective, AgentTeamPlan plan) { if (plan == null || plan.getTasks() == null) { return; } for (AgentTeamTask task : plan.getTasks()) { if (task == null || isBlank(task.getId())) { continue; } emit(AgentEventType.TEAM_TASK_CREATED, buildTaskSummary(task, "planned"), buildTaskPayload( task, null, "planned", firstNonBlank(task.getTask(), "Team task planned."), null, null, 0L, null )); } } @Override public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) { if (task == null || isBlank(task.getId())) { return; } emit(AgentEventType.TEAM_TASK_UPDATED, buildTaskSummary(task, "running"), buildTaskPayload( task, member, "running", "Assigned to " + firstNonBlank(member == null ? null : member.getName(), member == null ? null : member.resolveId(), "member") + ".", null, null, 0L, null )); } @Override public void afterTask(String objective, AgentTeamMemberResult result) { if (result == null || result.getTask() == null || isBlank(result.getTaskId())) { return; } AgentTeamTask task = result.getTask(); AgentTeamMember member = AgentTeamMember.builder() .id(result.getMemberId()) .name(result.getMemberName()) .build(); String status = result.getTaskStatus() == AgentTeamTaskStatus.FAILED ? "failed" : "completed"; emit(AgentEventType.TEAM_TASK_UPDATED, buildTaskSummary(task, status), buildTaskPayload( task, member, status, firstNonBlank(result.getError(), result.getOutput(), status), result.getOutput(), result.getError(), result.getDurationMillis(), null )); } @Override public void onTaskStateChanged(String objective, AgentTeamTaskState state, AgentTeamMember member, String detail) { if (state == null || state.getTask() == null || isBlank(state.getTaskId())) { return; } AgentTeamTask task = state.getTask(); String status = normalizeStatus(state.getStatus()); emit(AgentEventType.TEAM_TASK_UPDATED, buildTaskSummary(task, status), buildTaskPayload( task, member, status, firstNonBlank(detail, state.getDetail(), state.getError(), state.getOutput(), status), state.getOutput(), state.getError(), state.getDurationMillis(), state )); } @Override public void onMessage(AgentTeamMessage message) { if (message == null) { return; } Map payload = new LinkedHashMap(); payload.put("messageId", message.getId()); payload.put("fromMemberId", message.getFromMemberId()); payload.put("toMemberId", message.getToMemberId()); payload.put("taskId", message.getTaskId()); payload.put("type", message.getType()); payload.put("content", message.getContent()); payload.put("createdAt", Long.valueOf(message.getCreatedAt())); payload.put("title", "Team message"); payload.put("detail", firstNonBlank(message.getContent(), message.getType(), "Team message")); emit(AgentEventType.TEAM_MESSAGE, firstNonBlank(message.getContent(), message.getType(), "Team message"), payload); } private Map buildTaskPayload(AgentTeamTask task, AgentTeamMember member, String status, String detail, String output, String error, long durationMillis, AgentTeamTaskState state) { Map payload = new LinkedHashMap(); String taskId = task == null ? null : task.getId(); payload.put("taskId", taskId); payload.put("callId", taskId == null ? null : "team-task:" + taskId); payload.put("title", "Team task " + firstNonBlank(task == null ? null : task.getId(), "task")); payload.put("status", status); payload.put("detail", detail); payload.put("phase", firstNonBlank(state == null ? null : state.getPhase(), status)); payload.put("percent", Integer.valueOf(resolvePercent(status, state == null ? null : state.getPercent()))); payload.put("updatedAtEpochMs", Long.valueOf(state == null ? System.currentTimeMillis() : state.getUpdatedAtEpochMs())); payload.put("heartbeatCount", Integer.valueOf(state == null ? 0 : state.getHeartbeatCount())); payload.put("startTime", Long.valueOf(state == null ? 0L : state.getStartTime())); payload.put("endTime", Long.valueOf(state == null ? 0L : state.getEndTime())); payload.put("lastHeartbeatTime", Long.valueOf(state == null ? 0L : state.getLastHeartbeatTime())); payload.put("memberId", firstNonBlank( member == null ? null : member.resolveId(), state == null ? null : state.getClaimedBy(), task == null ? null : task.getMemberId())); payload.put("memberName", firstNonBlank(member == null ? null : member.getName(), state == null ? null : state.getClaimedBy(), member == null ? null : member.resolveId(), task == null ? null : task.getMemberId())); payload.put("task", task == null ? null : task.getTask()); payload.put("context", task == null ? null : task.getContext()); payload.put("dependsOn", task == null ? null : task.getDependsOn()); payload.put("output", output); payload.put("error", error); payload.put("durationMillis", Long.valueOf(durationMillis)); return payload; } private String buildTaskSummary(AgentTeamTask task, String status) { return "Team task " + firstNonBlank(task == null ? null : task.getId(), "task") + " [" + firstNonBlank(status, "unknown") + "]"; } private String normalizeStatus(AgentTeamTaskStatus status) { return status == null ? null : status.name().toLowerCase(); } private int resolvePercent(String status, Integer statePercent) { if (statePercent != null) { return Math.max(0, Math.min(100, statePercent.intValue())); } if (isBlank(status)) { return 0; } String normalized = status.trim().toLowerCase(); if ("completed".equals(normalized) || "failed".equals(normalized) || "blocked".equals(normalized)) { return 100; } if ("running".equals(normalized) || "in_progress".equals(normalized) || "in-progress".equals(normalized)) { return 15; } if ("ready".equals(normalized)) { return 5; } return 0; } private void emit(AgentEventType type, String message, Map payload) { AgentToolExecutionScope.emit(type, message, payload); if (listener != null && type != null) { listener.onEvent(AgentEvent.builder() .type(type) .message(message) .payload(payload) .build()); } } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamHook.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.AgentResult; import java.util.List; public interface AgentTeamHook { default void beforePlan(String objective, List members, AgentTeamOptions options) { } default void afterPlan(String objective, AgentTeamPlan plan) { } default void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) { } default void afterTask(String objective, AgentTeamMemberResult result) { } default void onTaskStateChanged(String objective, AgentTeamTaskState state, AgentTeamMember member, String detail) { } default void afterSynthesis(String objective, AgentResult result) { } default void onMessage(AgentTeamMessage message) { } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMember.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.Agent; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class AgentTeamMember { private String id; private String name; private String description; private Agent agent; public String resolveId() { String candidate = normalize(id); if (candidate != null) { return candidate; } candidate = normalize(name); if (candidate != null) { return candidate; } throw new IllegalArgumentException("team member id or name is required"); } private String normalize(String raw) { if (raw == null) { return null; } String normalized = raw.trim().toLowerCase(); if (normalized.isEmpty()) { return null; } normalized = normalized.replaceAll("[^a-z0-9_\\-]", "_"); normalized = normalized.replaceAll("_+", "_"); normalized = normalized.replaceAll("^_+", ""); normalized = normalized.replaceAll("_+$", ""); return normalized.isEmpty() ? null : normalized; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMemberResult.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.AgentResult; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class AgentTeamMemberResult { private String taskId; private String memberId; private String memberName; private AgentTeamTask task; private AgentTeamTaskStatus taskStatus; private String output; private String error; private AgentResult rawResult; private long durationMillis; public boolean isSuccess() { return error == null; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMemberSnapshot.java ================================================ package io.github.lnyocly.ai4j.agent.team; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class AgentTeamMemberSnapshot { private String id; private String name; private String description; public static AgentTeamMemberSnapshot from(AgentTeamMember member) { if (member == null) { return null; } return AgentTeamMemberSnapshot.builder() .id(member.getId()) .name(member.getName()) .description(member.getDescription()) .build(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMessage.java ================================================ package io.github.lnyocly.ai4j.agent.team; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class AgentTeamMessage { private String id; private String fromMemberId; private String toMemberId; private String type; private String taskId; private String content; private long createdAt; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMessageBus.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.List; public interface AgentTeamMessageBus { void publish(AgentTeamMessage message); List snapshot(); List historyFor(String memberId, int limit); void clear(); void restore(List messages); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamOptions.java ================================================ package io.github.lnyocly.ai4j.agent.team; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class AgentTeamOptions { @Builder.Default private boolean parallelDispatch = true; @Builder.Default private int maxConcurrency = 4; @Builder.Default private boolean continueOnMemberError = true; @Builder.Default private boolean broadcastOnPlannerFailure = true; @Builder.Default private boolean failOnUnknownMember = false; @Builder.Default private boolean includeOriginalObjectiveInDispatch = true; @Builder.Default private boolean includeTaskContextInDispatch = true; @Builder.Default private boolean includeMessageHistoryInDispatch = true; @Builder.Default private int messageHistoryLimit = 20; @Builder.Default private boolean enableMessageBus = true; @Builder.Default private boolean allowDynamicMemberRegistration = true; @Builder.Default private boolean requirePlanApproval = false; @Builder.Default private int maxRounds = 64; @Builder.Default private long taskClaimTimeoutMillis = 0L; @Builder.Default private boolean enableMemberTeamTools = true; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlan.java ================================================ package io.github.lnyocly.ai4j.agent.team; import lombok.Builder; import lombok.Data; import java.util.List; @Data @Builder(toBuilder = true) public class AgentTeamPlan { private List tasks; private String rawPlanText; @Builder.Default private boolean fallback = false; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlanApproval.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.List; public interface AgentTeamPlanApproval { boolean approve(String objective, AgentTeamPlan plan, List members, AgentTeamOptions options); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlanParser.java ================================================ package io.github.lnyocly.ai4j.agent.team; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import java.util.ArrayList; import java.util.Collections; import java.util.List; final class AgentTeamPlanParser { private AgentTeamPlanParser() { } static List parseTasks(String rawText) { if (rawText == null || rawText.trim().isEmpty()) { return Collections.emptyList(); } List tasks = parseFromJson(rawText); if (!tasks.isEmpty()) { return tasks; } String jsonPart = extractJson(rawText); if (jsonPart == null) { return Collections.emptyList(); } return parseFromJson(jsonPart); } private static List parseFromJson(String text) { if (text == null) { return Collections.emptyList(); } String trimmed = text.trim(); if (trimmed.isEmpty()) { return Collections.emptyList(); } try { if (trimmed.startsWith("[")) { return parseArray(JSON.parseArray(trimmed)); } if (trimmed.startsWith("{")) { JSONObject obj = JSON.parseObject(trimmed); JSONArray tasksArray = firstArray(obj, "tasks", "plan", "delegations", "assignments"); if (tasksArray != null) { return parseArray(tasksArray); } AgentTeamTask singleTask = parseTask(obj); if (singleTask == null) { return Collections.emptyList(); } List tasks = new ArrayList<>(); tasks.add(singleTask); return tasks; } return Collections.emptyList(); } catch (Exception ignored) { return Collections.emptyList(); } } private static JSONArray firstArray(JSONObject obj, String... keys) { if (obj == null || keys == null) { return null; } for (String key : keys) { Object value = obj.get(key); if (value instanceof JSONArray) { return (JSONArray) value; } } return null; } private static List parseArray(JSONArray array) { if (array == null || array.isEmpty()) { return Collections.emptyList(); } List tasks = new ArrayList<>(); for (Object item : array) { if (!(item instanceof JSONObject)) { continue; } AgentTeamTask task = parseTask((JSONObject) item); if (task != null) { tasks.add(task); } } return tasks; } private static AgentTeamTask parseTask(JSONObject obj) { if (obj == null) { return null; } String taskId = firstString(obj, "id", "taskId", "task_id", "name"); String memberId = firstString(obj, "memberId", "member", "agent", "assignee"); String task = firstString(obj, "task", "instruction", "goal", "work", "input"); String context = firstString(obj, "context", "notes", "memo", "details"); List dependsOn = parseDependencies(firstValue(obj, "dependsOn", "depends_on", "deps", "after")); if (task == null || task.trim().isEmpty()) { return null; } return AgentTeamTask.builder() .id(taskId) .memberId(memberId) .task(task.trim()) .context(context == null ? null : context.trim()) .dependsOn(dependsOn) .build(); } private static List parseDependencies(Object raw) { if (raw == null) { return Collections.emptyList(); } List dependencies = new ArrayList<>(); if (raw instanceof JSONArray) { JSONArray array = (JSONArray) raw; for (Object item : array) { if (item instanceof String) { String value = ((String) item).trim(); if (!value.isEmpty()) { dependencies.add(value); } } } return dependencies; } if (raw instanceof String) { String[] parts = ((String) raw).split(","); for (String part : parts) { String value = part.trim(); if (!value.isEmpty()) { dependencies.add(value); } } return dependencies; } return Collections.emptyList(); } private static Object firstValue(JSONObject obj, String... keys) { if (obj == null || keys == null) { return null; } for (String key : keys) { if (obj.containsKey(key)) { return obj.get(key); } } return null; } private static String firstString(JSONObject obj, String... keys) { Object value = firstValue(obj, keys); if (value instanceof String) { String text = ((String) value).trim(); if (!text.isEmpty()) { return text; } } return null; } private static String extractJson(String text) { int objectStart = text.indexOf('{'); int arrayStart = text.indexOf('['); int start; char open; char close; if (objectStart < 0 && arrayStart < 0) { return null; } if (arrayStart >= 0 && (objectStart < 0 || arrayStart < objectStart)) { start = arrayStart; open = '['; close = ']'; } else { start = objectStart; open = '{'; close = '}'; } int depth = 0; boolean inString = false; boolean escaped = false; for (int i = start; i < text.length(); i++) { char c = text.charAt(i); if (inString) { if (escaped) { escaped = false; continue; } if (c == '\\') { escaped = true; continue; } if (c == '"') { inString = false; } continue; } if (c == '"') { inString = true; continue; } if (c == open) { depth++; continue; } if (c == close) { depth--; if (depth == 0) { return text.substring(start, i + 1); } } } return null; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlanner.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.List; public interface AgentTeamPlanner { AgentTeamPlan plan(String objective, List members, AgentTeamOptions options) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamResult.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.AgentResult; import lombok.Builder; import lombok.Data; import java.util.List; @Data @Builder(toBuilder = true) public class AgentTeamResult { private String teamId; private String objective; private AgentTeamPlan plan; private List memberResults; private List taskStates; private List messages; private int rounds; private String output; private AgentResult synthesisResult; private long totalDurationMillis; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamState.java ================================================ package io.github.lnyocly.ai4j.agent.team; import lombok.Builder; import lombok.Data; import java.util.List; @Data @Builder(toBuilder = true) public class AgentTeamState { private String teamId; private String objective; private List members; private List taskStates; private List messages; private String lastOutput; private int lastRounds; private long lastRunStartedAt; private long lastRunCompletedAt; private long updatedAt; private boolean runActive; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamStateStore.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.List; public interface AgentTeamStateStore { void save(AgentTeamState state); AgentTeamState load(String teamId); List list(); boolean delete(String teamId); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamSynthesizer.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.AgentResult; import java.util.List; public interface AgentTeamSynthesizer { AgentResult synthesize(String objective, AgentTeamPlan plan, List memberResults, AgentTeamOptions options) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTask.java ================================================ package io.github.lnyocly.ai4j.agent.team; import lombok.Builder; import lombok.Data; import java.util.List; @Data @Builder(toBuilder = true) public class AgentTeamTask { private String id; private String memberId; private String task; private String context; private List dependsOn; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTaskBoard.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; public class AgentTeamTaskBoard { private final LinkedHashMap states = new LinkedHashMap<>(); private static final int PERCENT_PLANNED = 0; private static final int PERCENT_READY = 5; private static final int PERCENT_IN_PROGRESS = 15; private static final int PERCENT_TERMINAL = 100; public AgentTeamTaskBoard(List tasks) { initialize(tasks); } private void initialize(List tasks) { if (tasks == null || tasks.isEmpty()) { return; } Set usedIds = new HashSet<>(); int seq = 1; for (AgentTeamTask task : tasks) { AgentTeamTask safeTask = task == null ? AgentTeamTask.builder().build() : task; String id = normalizeId(safeTask.getId()); if (id == null) { id = "task_" + seq; seq++; } while (usedIds.contains(id)) { id = id + "_" + seq; seq++; } usedIds.add(id); List dependencies = normalizeDependencies(safeTask.getDependsOn()); AgentTeamTask normalized = safeTask.toBuilder() .id(id) .dependsOn(dependencies) .build(); states.put(id, AgentTeamTaskState.builder() .taskId(id) .task(normalized) .status(AgentTeamTaskStatus.PENDING) .phase("planned") .percent(Integer.valueOf(PERCENT_PLANNED)) .updatedAtEpochMs(System.currentTimeMillis()) .build()); } refreshStatuses(); } public synchronized List normalizedTasks() { if (states.isEmpty()) { return Collections.emptyList(); } List tasks = new ArrayList<>(); for (AgentTeamTaskState state : states.values()) { tasks.add(state.getTask()); } return tasks; } public synchronized List nextReadyTasks(int maxCount) { refreshStatuses(); if (states.isEmpty()) { return Collections.emptyList(); } int safeCount = maxCount <= 0 ? 1 : maxCount; List ready = new ArrayList<>(); for (AgentTeamTaskState state : states.values()) { if (state.getStatus() == AgentTeamTaskStatus.READY) { ready.add(copy(state)); if (ready.size() >= safeCount) { break; } } } return ready; } public synchronized AgentTeamTaskState getTaskState(String taskId) { String key = resolveTaskKey(taskId); if (key == null) { return null; } return copy(states.get(key)); } public synchronized boolean claimTask(String taskId, String memberId) { refreshStatuses(); String key = resolveTaskKey(taskId); if (key == null) { return false; } AgentTeamTaskState state = states.get(key); if (state == null || state.getStatus() != AgentTeamTaskStatus.READY) { return false; } long now = System.currentTimeMillis(); states.put(key, state.toBuilder() .status(AgentTeamTaskStatus.IN_PROGRESS) .claimedBy(normalizeMemberId(memberId)) .startTime(now) .lastHeartbeatTime(now) .endTime(0L) .durationMillis(0L) .phase("running") .detail("Claimed by " + firstNonBlank(normalizeMemberId(memberId), "member") + ".") .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_IN_PROGRESS))) .updatedAtEpochMs(now) .heartbeatCount(0) .output(null) .error(null) .build()); return true; } public synchronized boolean releaseTask(String taskId, String memberId, String reason) { String key = resolveTaskKey(taskId); if (key == null) { return false; } AgentTeamTaskState state = states.get(key); if (state == null || state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) { return false; } if (!isSameMember(state.getClaimedBy(), memberId)) { return false; } states.put(key, state.toBuilder() .status(AgentTeamTaskStatus.PENDING) .claimedBy(null) .startTime(0L) .lastHeartbeatTime(0L) .endTime(0L) .durationMillis(0L) .phase("released") .detail(firstNonBlank(trimToNull(reason), "Released back to queue.")) .updatedAtEpochMs(System.currentTimeMillis()) .output(null) .error(reason) .build()); refreshStatuses(); return true; } public synchronized boolean reassignTask(String taskId, String fromMemberId, String toMemberId) { String key = resolveTaskKey(taskId); if (key == null) { return false; } AgentTeamTaskState state = states.get(key); if (state == null || state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) { return false; } if (!isSameMember(state.getClaimedBy(), fromMemberId)) { return false; } long now = System.currentTimeMillis(); states.put(key, state.toBuilder() .claimedBy(normalizeMemberId(toMemberId)) .lastHeartbeatTime(now) .phase("reassigned") .detail("Reassigned from " + firstNonBlank(normalizeMemberId(fromMemberId), "member") + " to " + firstNonBlank(normalizeMemberId(toMemberId), "member") + ".") .updatedAtEpochMs(now) .build()); return true; } public synchronized boolean heartbeatTask(String taskId, String memberId) { String key = resolveTaskKey(taskId); if (key == null) { return false; } AgentTeamTaskState state = states.get(key); if (state == null || state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) { return false; } if (!isSameMember(state.getClaimedBy(), memberId)) { return false; } long now = System.currentTimeMillis(); states.put(key, state.toBuilder() .lastHeartbeatTime(now) .phase("heartbeat") .detail("Heartbeat from " + firstNonBlank(normalizeMemberId(memberId), "member") + ".") .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_IN_PROGRESS))) .updatedAtEpochMs(now) .heartbeatCount(state.getHeartbeatCount() + 1) .build()); return true; } public synchronized int recoverTimedOutClaims(long timeoutMillis, String reason) { if (timeoutMillis <= 0 || states.isEmpty()) { return 0; } long now = System.currentTimeMillis(); int recovered = 0; for (Map.Entry entry : states.entrySet()) { AgentTeamTaskState state = entry.getValue(); if (state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) { continue; } long lastBeat = state.getLastHeartbeatTime() > 0 ? state.getLastHeartbeatTime() : state.getStartTime(); if (lastBeat <= 0 || now - lastBeat < timeoutMillis) { continue; } entry.setValue(state.toBuilder() .status(AgentTeamTaskStatus.PENDING) .claimedBy(null) .startTime(0L) .lastHeartbeatTime(0L) .endTime(0L) .durationMillis(0L) .phase("timeout_recovered") .detail(reason == null ? "Claim timed out." : reason) .updatedAtEpochMs(now) .output(null) .error(reason == null ? "claim timeout" : reason) .build()); recovered++; } if (recovered > 0) { refreshStatuses(); } return recovered; } public synchronized void markInProgress(String taskId, String claimedBy) { claimTask(taskId, claimedBy); } public synchronized void markCompleted(String taskId, String output, long durationMillis) { String key = resolveTaskKey(taskId); if (key == null) { return; } AgentTeamTaskState state = states.get(key); if (state == null) { return; } long now = System.currentTimeMillis(); states.put(key, state.toBuilder() .status(AgentTeamTaskStatus.COMPLETED) .phase("completed") .detail(firstNonBlank(trimToNull(output), "Task completed.")) .percent(Integer.valueOf(PERCENT_TERMINAL)) .updatedAtEpochMs(now) .output(output) .durationMillis(durationMillis) .endTime(now) .lastHeartbeatTime(now) .error(null) .build()); refreshStatuses(); } public synchronized void markFailed(String taskId, String error, long durationMillis) { String key = resolveTaskKey(taskId); if (key == null) { return; } AgentTeamTaskState state = states.get(key); if (state == null) { return; } long now = System.currentTimeMillis(); states.put(key, state.toBuilder() .status(AgentTeamTaskStatus.FAILED) .phase("failed") .detail(firstNonBlank(trimToNull(error), "Task failed.")) .percent(Integer.valueOf(PERCENT_TERMINAL)) .updatedAtEpochMs(now) .error(error) .durationMillis(durationMillis) .endTime(now) .lastHeartbeatTime(now) .build()); refreshStatuses(); } public synchronized void markStalledAsBlocked(String reason) { for (Map.Entry entry : states.entrySet()) { AgentTeamTaskState state = entry.getValue(); if (state.getStatus() == AgentTeamTaskStatus.PENDING || state.getStatus() == AgentTeamTaskStatus.READY) { entry.setValue(state.toBuilder() .status(AgentTeamTaskStatus.BLOCKED) .phase("blocked") .detail(reason) .percent(Integer.valueOf(PERCENT_TERMINAL)) .updatedAtEpochMs(System.currentTimeMillis()) .error(reason) .endTime(System.currentTimeMillis()) .build()); } } } public synchronized boolean hasWorkRemaining() { for (AgentTeamTaskState state : states.values()) { if (state.getStatus() == AgentTeamTaskStatus.PENDING || state.getStatus() == AgentTeamTaskStatus.READY || state.getStatus() == AgentTeamTaskStatus.IN_PROGRESS) { return true; } } return false; } public synchronized boolean hasFailed() { for (AgentTeamTaskState state : states.values()) { if (state.getStatus() == AgentTeamTaskStatus.FAILED) { return true; } } return false; } public synchronized List snapshot() { if (states.isEmpty()) { return Collections.emptyList(); } List all = new ArrayList<>(); for (AgentTeamTaskState state : states.values()) { all.add(copy(state)); } return all; } public synchronized int size() { return states.size(); } private void refreshStatuses() { if (states.isEmpty()) { return; } for (Map.Entry entry : states.entrySet()) { AgentTeamTaskState state = entry.getValue(); if (state.getStatus() == AgentTeamTaskStatus.IN_PROGRESS || state.isTerminal()) { continue; } AgentTeamTask task = state.getTask(); List dependencies = task == null ? null : task.getDependsOn(); if (dependencies == null || dependencies.isEmpty()) { entry.setValue(state.toBuilder() .status(AgentTeamTaskStatus.READY) .phase("ready") .detail("Ready for dispatch.") .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_READY))) .updatedAtEpochMs(selectUpdatedAt(state)) .error(null) .build()); continue; } boolean missing = false; boolean blocked = false; boolean allCompleted = true; for (String dependencyId : dependencies) { AgentTeamTaskState dependency = states.get(dependencyId); if (dependency == null) { missing = true; allCompleted = false; break; } if (dependency.getStatus() == AgentTeamTaskStatus.FAILED || dependency.getStatus() == AgentTeamTaskStatus.BLOCKED) { blocked = true; allCompleted = false; break; } if (dependency.getStatus() != AgentTeamTaskStatus.COMPLETED) { allCompleted = false; } } if (missing) { entry.setValue(state.toBuilder() .status(AgentTeamTaskStatus.BLOCKED) .phase("blocked") .detail("Blocked: missing dependency.") .percent(Integer.valueOf(PERCENT_TERMINAL)) .updatedAtEpochMs(selectUpdatedAt(state)) .error("missing dependency") .build()); } else if (blocked) { entry.setValue(state.toBuilder() .status(AgentTeamTaskStatus.BLOCKED) .phase("blocked") .detail("Blocked: dependency failed.") .percent(Integer.valueOf(PERCENT_TERMINAL)) .updatedAtEpochMs(selectUpdatedAt(state)) .error("dependency failed") .build()); } else if (allCompleted) { entry.setValue(state.toBuilder() .status(AgentTeamTaskStatus.READY) .phase("ready") .detail("Dependencies satisfied.") .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_READY))) .updatedAtEpochMs(selectUpdatedAt(state)) .error(null) .build()); } else { entry.setValue(state.toBuilder() .status(AgentTeamTaskStatus.PENDING) .phase("pending") .detail("Waiting for dependencies.") .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_PLANNED))) .updatedAtEpochMs(selectUpdatedAt(state)) .error(null) .build()); } } } private String resolveTaskKey(String taskId) { if (taskId == null) { return null; } String trimmed = taskId.trim(); if (trimmed.isEmpty()) { return null; } if (states.containsKey(trimmed)) { return trimmed; } String normalized = normalizeId(trimmed); if (normalized != null && states.containsKey(normalized)) { return normalized; } return null; } private List normalizeDependencies(List dependencies) { if (dependencies == null || dependencies.isEmpty()) { return Collections.emptyList(); } List normalized = new ArrayList<>(); for (String dependency : dependencies) { String id = normalizeId(dependency); if (id != null && !normalized.contains(id)) { normalized.add(id); } } return normalized; } private String normalizeId(String raw) { if (raw == null) { return null; } String normalized = raw.trim().toLowerCase(); if (normalized.isEmpty()) { return null; } normalized = normalized.replaceAll("[^a-z0-9_\\-]", "_"); normalized = normalized.replaceAll("_+", "_"); normalized = normalized.replaceAll("^_+", ""); normalized = normalized.replaceAll("_+$", ""); return normalized.isEmpty() ? null : normalized; } private String normalizeMemberId(String memberId) { if (memberId == null) { return null; } String normalized = memberId.trim(); return normalized.isEmpty() ? null : normalized; } private boolean isSameMember(String currentClaimedBy, String expectedMemberId) { String current = normalizeMemberId(currentClaimedBy); String expected = normalizeMemberId(expectedMemberId); if (expected == null) { return true; } return expected.equals(current); } private AgentTeamTaskState copy(AgentTeamTaskState state) { if (state == null) { return null; } return state.toBuilder().build(); } private int percentOf(AgentTeamTaskState state) { Integer percent = state == null ? null : state.getPercent(); return percent == null ? 0 : Math.max(0, Math.min(100, percent.intValue())); } private long selectUpdatedAt(AgentTeamTaskState state) { if (state == null) { return System.currentTimeMillis(); } return state.getUpdatedAtEpochMs() > 0L ? state.getUpdatedAtEpochMs() : System.currentTimeMillis(); } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (trimToNull(value) != null) { return value.trim(); } } return null; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTaskState.java ================================================ package io.github.lnyocly.ai4j.agent.team; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class AgentTeamTaskState { private String taskId; private AgentTeamTask task; private AgentTeamTaskStatus status; private String claimedBy; private long startTime; private long endTime; private long durationMillis; private long lastHeartbeatTime; private String phase; private String detail; private Integer percent; private long updatedAtEpochMs; private int heartbeatCount; private String output; private String error; public boolean isTerminal() { return status == AgentTeamTaskStatus.COMPLETED || status == AgentTeamTaskStatus.FAILED || status == AgentTeamTaskStatus.BLOCKED; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTaskStatus.java ================================================ package io.github.lnyocly.ai4j.agent.team; public enum AgentTeamTaskStatus { PENDING, READY, IN_PROGRESS, COMPLETED, FAILED, BLOCKED } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/FileAgentTeamMessageBus.java ================================================ package io.github.lnyocly.ai4j.agent.team; import com.alibaba.fastjson2.JSON; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class FileAgentTeamMessageBus implements AgentTeamMessageBus { private final Path file; private final List messages = new ArrayList(); public FileAgentTeamMessageBus(Path file) { if (file == null) { throw new IllegalArgumentException("file is required"); } this.file = file; loadExistingMessages(); } @Override public synchronized void publish(AgentTeamMessage message) { if (message == null) { return; } messages.add(message); append(message); } @Override public synchronized List snapshot() { if (messages.isEmpty()) { return Collections.emptyList(); } return copyMessages(messages); } @Override public synchronized List historyFor(String memberId, int limit) { if (messages.isEmpty()) { return Collections.emptyList(); } int safeLimit = limit <= 0 ? messages.size() : limit; List filtered = new ArrayList(); for (AgentTeamMessage message : messages) { if (memberId == null || memberId.trim().isEmpty()) { filtered.add(message); continue; } if (memberId.equals(message.getToMemberId()) || message.getToMemberId() == null || "*".equals(message.getToMemberId())) { filtered.add(message); } } if (filtered.isEmpty()) { return Collections.emptyList(); } int from = Math.max(0, filtered.size() - safeLimit); return copyMessages(filtered.subList(from, filtered.size())); } @Override public synchronized void clear() { messages.clear(); rewriteAll(); } @Override public synchronized void restore(List restoredMessages) { messages.clear(); if (restoredMessages != null && !restoredMessages.isEmpty()) { messages.addAll(copyMessages(restoredMessages)); } rewriteAll(); } private void loadExistingMessages() { if (!Files.exists(file)) { return; } try { List lines = Files.readAllLines(file, StandardCharsets.UTF_8); for (String line : lines) { if (line == null || line.trim().isEmpty()) { continue; } AgentTeamMessage message = JSON.parseObject(line, AgentTeamMessage.class); if (message != null) { messages.add(message); } } } catch (IOException e) { throw new IllegalStateException("failed to load team mailbox from " + file, e); } } private void append(AgentTeamMessage message) { try { ensureParent(); Files.write(file, Collections.singletonList(JSON.toJSONString(message)), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException e) { throw new IllegalStateException("failed to append team message to " + file, e); } } private void rewriteAll() { try { ensureParent(); List lines = new ArrayList(); for (AgentTeamMessage message : messages) { lines.add(JSON.toJSONString(message)); } Files.write(file, lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); } catch (IOException e) { throw new IllegalStateException("failed to rewrite team mailbox " + file, e); } } private void ensureParent() throws IOException { Path parent = file.getParent(); if (parent != null) { Files.createDirectories(parent); } } private List copyMessages(List source) { if (source == null || source.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(source.size()); for (AgentTeamMessage message : source) { copy.add(message == null ? null : message.toBuilder().build()); } return copy; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/FileAgentTeamStateStore.java ================================================ package io.github.lnyocly.ai4j.agent.team; import com.alibaba.fastjson2.JSON; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; public class FileAgentTeamStateStore implements AgentTeamStateStore { private final Path directory; public FileAgentTeamStateStore(Path directory) { if (directory == null) { throw new IllegalArgumentException("directory is required"); } this.directory = directory; } @Override public synchronized void save(AgentTeamState state) { if (state == null || isBlank(state.getTeamId())) { return; } try { ensureDirectory(); Files.write(fileOf(state.getTeamId()), JSON.toJSONString(state).getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); } catch (IOException e) { throw new IllegalStateException("failed to save team state: " + state.getTeamId(), e); } } @Override public synchronized AgentTeamState load(String teamId) { if (isBlank(teamId)) { return null; } Path file = fileOf(teamId); if (!Files.exists(file)) { return null; } try { byte[] bytes = Files.readAllBytes(file); if (bytes.length == 0) { return null; } return JSON.parseObject(new String(bytes, StandardCharsets.UTF_8), AgentTeamState.class); } catch (IOException e) { throw new IllegalStateException("failed to load team state: " + teamId, e); } } @Override public synchronized List list() { if (!Files.exists(directory)) { return Collections.emptyList(); } try { List states = new ArrayList(); Files.list(directory) .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json")) .forEach(path -> { try { byte[] bytes = Files.readAllBytes(path); if (bytes.length == 0) { return; } AgentTeamState state = JSON.parseObject(new String(bytes, StandardCharsets.UTF_8), AgentTeamState.class); if (state != null) { states.add(state); } } catch (IOException ignored) { } }); Collections.sort(states, new Comparator() { @Override public int compare(AgentTeamState left, AgentTeamState right) { long l = left == null ? 0L : left.getUpdatedAt(); long r = right == null ? 0L : right.getUpdatedAt(); return l == r ? 0 : (l < r ? 1 : -1); } }); return states; } catch (IOException e) { throw new IllegalStateException("failed to list team states in " + directory, e); } } @Override public synchronized boolean delete(String teamId) { if (isBlank(teamId)) { return false; } try { return Files.deleteIfExists(fileOf(teamId)); } catch (IOException e) { throw new IllegalStateException("failed to delete team state: " + teamId, e); } } private void ensureDirectory() throws IOException { Files.createDirectories(directory); } private Path fileOf(String teamId) { return directory.resolve(teamId + ".json"); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/InMemoryAgentTeamMessageBus.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class InMemoryAgentTeamMessageBus implements AgentTeamMessageBus { private final List messages = new ArrayList<>(); @Override public synchronized void publish(AgentTeamMessage message) { if (message == null) { return; } messages.add(message); } @Override public synchronized List snapshot() { if (messages.isEmpty()) { return Collections.emptyList(); } return new ArrayList<>(messages); } @Override public synchronized List historyFor(String memberId, int limit) { if (messages.isEmpty()) { return Collections.emptyList(); } int safeLimit = limit <= 0 ? messages.size() : limit; List filtered = new ArrayList<>(); for (AgentTeamMessage message : messages) { if (memberId == null || memberId.trim().isEmpty()) { filtered.add(message); continue; } if (memberId.equals(message.getToMemberId()) || message.getToMemberId() == null || "*".equals(message.getToMemberId())) { filtered.add(message); } } if (filtered.isEmpty()) { return Collections.emptyList(); } int from = Math.max(0, filtered.size() - safeLimit); return new ArrayList<>(filtered.subList(from, filtered.size())); } @Override public synchronized void clear() { messages.clear(); } @Override public synchronized void restore(List restoredMessages) { messages.clear(); if (restoredMessages != null && !restoredMessages.isEmpty()) { messages.addAll(restoredMessages); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/InMemoryAgentTeamStateStore.java ================================================ package io.github.lnyocly.ai4j.agent.team; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class InMemoryAgentTeamStateStore implements AgentTeamStateStore { private final Map states = new LinkedHashMap(); @Override public synchronized void save(AgentTeamState state) { if (state == null || isBlank(state.getTeamId())) { return; } states.put(state.getTeamId(), copy(state)); } @Override public synchronized AgentTeamState load(String teamId) { AgentTeamState state = states.get(teamId); return state == null ? null : copy(state); } @Override public synchronized List list() { if (states.isEmpty()) { return Collections.emptyList(); } List list = new ArrayList(); for (AgentTeamState state : states.values()) { list.add(copy(state)); } Collections.sort(list, new Comparator() { @Override public int compare(AgentTeamState left, AgentTeamState right) { long l = left == null ? 0L : left.getUpdatedAt(); long r = right == null ? 0L : right.getUpdatedAt(); return l == r ? 0 : (l < r ? 1 : -1); } }); return list; } @Override public synchronized boolean delete(String teamId) { return !isBlank(teamId) && states.remove(teamId) != null; } private AgentTeamState copy(AgentTeamState state) { if (state == null) { return null; } return state.toBuilder() .members(copyMembers(state.getMembers())) .taskStates(copyTaskStates(state.getTaskStates())) .messages(copyMessages(state.getMessages())) .build(); } private List copyMembers(List members) { if (members == null || members.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(members.size()); for (AgentTeamMemberSnapshot member : members) { copy.add(member == null ? null : member.toBuilder().build()); } return copy; } private List copyTaskStates(List taskStates) { if (taskStates == null || taskStates.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(taskStates.size()); for (AgentTeamTaskState taskState : taskStates) { copy.add(taskState == null ? null : taskState.toBuilder().build()); } return copy; } private List copyMessages(List messages) { if (messages == null || messages.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(messages.size()); for (AgentTeamMessage message : messages) { copy.add(message == null ? null : message.toBuilder().build()); } return copy; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/LlmAgentTeamPlanner.java ================================================ package io.github.lnyocly.ai4j.agent.team; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class LlmAgentTeamPlanner implements AgentTeamPlanner { private final Agent plannerAgent; public LlmAgentTeamPlanner(Agent plannerAgent) { if (plannerAgent == null) { throw new IllegalArgumentException("plannerAgent is required"); } this.plannerAgent = plannerAgent; } @Override public AgentTeamPlan plan(String objective, List members, AgentTeamOptions options) throws Exception { List safeMembers = members == null ? Collections.emptyList() : members; String prompt = buildPlannerPrompt(objective, safeMembers); AgentResult plannerResult = plannerAgent.newSession().run(AgentRequest.builder().input(prompt).build()); String planText = plannerResult == null ? null : plannerResult.getOutputText(); List parsedTasks = AgentTeamPlanParser.parseTasks(planText); boolean fallback = false; if (parsedTasks.isEmpty() && options != null && options.isBroadcastOnPlannerFailure()) { parsedTasks = fallbackTasks(objective, safeMembers); fallback = true; } return AgentTeamPlan.builder() .tasks(parsedTasks) .rawPlanText(planText) .fallback(fallback) .build(); } private String buildPlannerPrompt(String objective, List members) { StringBuilder sb = new StringBuilder(); sb.append("You are a team planner.\n"); sb.append("Break the user objective into executable tasks and assign each task to one member.\n"); sb.append("Prefer short tasks that can run independently.\n"); sb.append("Output JSON only. No markdown, no prose.\n"); sb.append("JSON schema:\n"); sb.append("{\"tasks\":[{\"id\":\"t1\",\"memberId\":\"\",\"task\":\"\",\"context\":\"\",\"dependsOn\":[\"\"]}]}\n\n"); sb.append("Available members:\n"); for (AgentTeamMember member : members) { sb.append("- id=").append(member.resolveId()); if (member.getName() != null) { sb.append(", name=").append(member.getName()); } if (member.getDescription() != null) { sb.append(", expertise=").append(member.getDescription()); } sb.append("\n"); } sb.append("\nObjective:\n"); sb.append(objective == null ? "" : objective); return sb.toString(); } private List fallbackTasks(String objective, List members) { if (members == null || members.isEmpty()) { return Collections.emptyList(); } List tasks = new ArrayList<>(); int index = 1; for (AgentTeamMember member : members) { String description = member.getDescription(); String taskText; if (description == null || description.trim().isEmpty()) { taskText = objective; } else { taskText = "Focus on " + description + ". Objective: " + objective; } tasks.add(AgentTeamTask.builder() .id("fallback_" + index) .memberId(member.resolveId()) .task(taskText) .context("planner_fallback") .build()); index++; } return tasks; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/LlmAgentTeamSynthesizer.java ================================================ package io.github.lnyocly.ai4j.agent.team; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import java.util.Collections; import java.util.List; public class LlmAgentTeamSynthesizer implements AgentTeamSynthesizer { private final Agent synthesizerAgent; public LlmAgentTeamSynthesizer(Agent synthesizerAgent) { if (synthesizerAgent == null) { throw new IllegalArgumentException("synthesizerAgent is required"); } this.synthesizerAgent = synthesizerAgent; } @Override public AgentResult synthesize(String objective, AgentTeamPlan plan, List memberResults, AgentTeamOptions options) throws Exception { String prompt = buildSynthesisPrompt(objective, plan, memberResults); return synthesizerAgent.newSession().run(AgentRequest.builder().input(prompt).build()); } private String buildSynthesisPrompt(String objective, AgentTeamPlan plan, List memberResults) { List safeResults = memberResults == null ? Collections.emptyList() : memberResults; StringBuilder sb = new StringBuilder(); sb.append("You are the team lead. Merge member outputs into a final answer.\n"); sb.append("Rules:\n"); sb.append("1) Keep the final answer directly useful for the user objective.\n"); sb.append("2) Resolve conflicts by preferring higher-confidence or concrete evidence.\n"); sb.append("3) If a member failed, continue and mention missing evidence briefly.\n\n"); sb.append("Objective:\n").append(objective == null ? "" : objective).append("\n\n"); if (plan != null) { sb.append("Plan JSON:\n"); sb.append(JSON.toJSONString(plan.getTasks())).append("\n\n"); } sb.append("Member outputs:\n"); for (AgentTeamMemberResult result : safeResults) { sb.append("- memberId=").append(result.getMemberId()); if (result.getMemberName() != null) { sb.append(", name=").append(result.getMemberName()); } if (result.isSuccess()) { sb.append("\n output: ").append(result.getOutput() == null ? "" : result.getOutput()); } else { sb.append("\n error: ").append(result.getError()); } sb.append("\n"); } return sb.toString(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.team.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.team.AgentTeamControl; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.ArrayList; import java.util.List; public class AgentTeamToolExecutor implements ToolExecutor { private final AgentTeamControl control; private final String memberId; private final String defaultTaskId; private final ToolExecutor delegate; public AgentTeamToolExecutor(AgentTeamControl control, String memberId, String defaultTaskId, ToolExecutor delegate) { if (control == null) { throw new IllegalArgumentException("control is required"); } if (memberId == null || memberId.trim().isEmpty()) { throw new IllegalArgumentException("memberId is required"); } this.control = control; this.memberId = memberId.trim(); this.defaultTaskId = defaultTaskId; this.delegate = delegate; } @Override public String execute(AgentToolCall call) throws Exception { if (call == null) { return null; } String toolName = call.getName(); if (!AgentTeamToolRegistry.supports(toolName)) { if (delegate == null) { throw new IllegalStateException("toolExecutor is required for non-team tool: " + toolName); } return delegate.execute(call); } JSONObject args = parseArguments(call.getArguments()); if (AgentTeamToolRegistry.TOOL_SEND_MESSAGE.equals(toolName)) { return handleSendMessage(args); } if (AgentTeamToolRegistry.TOOL_BROADCAST.equals(toolName)) { return handleBroadcast(args); } if (AgentTeamToolRegistry.TOOL_LIST_TASKS.equals(toolName)) { return handleListTasks(); } if (AgentTeamToolRegistry.TOOL_CLAIM_TASK.equals(toolName)) { return handleClaimTask(args); } if (AgentTeamToolRegistry.TOOL_RELEASE_TASK.equals(toolName)) { return handleReleaseTask(args); } if (AgentTeamToolRegistry.TOOL_REASSIGN_TASK.equals(toolName)) { return handleReassignTask(args); } if (AgentTeamToolRegistry.TOOL_HEARTBEAT_TASK.equals(toolName)) { return handleHeartbeatTask(args); } throw new IllegalStateException("unsupported team tool: " + toolName); } private String handleSendMessage(JSONObject args) { String toMemberId = firstString(args, "toMemberId", "to", "memberId"); String content = firstString(args, "content", "message", "text"); String type = firstString(args, "type"); String taskId = resolveTaskId(args, false); if (toMemberId == null) { throw new IllegalArgumentException("toMemberId is required"); } if (type == null) { type = "peer.message"; } if (content == null) { content = ""; } control.sendMessage(memberId, toMemberId, type, taskId, content); JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_SEND_MESSAGE, true); result.put("toMemberId", toMemberId); result.put("taskId", taskId); return result.toJSONString(); } private String handleBroadcast(JSONObject args) { String content = firstString(args, "content", "message", "text"); String type = firstString(args, "type"); String taskId = resolveTaskId(args, false); if (type == null) { type = "peer.broadcast"; } if (content == null) { content = ""; } control.broadcastMessage(memberId, type, taskId, content); JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_BROADCAST, true); result.put("taskId", taskId); return result.toJSONString(); } private String handleListTasks() { List tasks = control.listTaskStates(); JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_LIST_TASKS, true); result.put("tasks", tasks == null ? new ArrayList() : tasks); return result.toJSONString(); } private String handleClaimTask(JSONObject args) { String taskId = resolveTaskId(args, true); boolean ok = control.claimTask(taskId, memberId); JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_CLAIM_TASK, ok); result.put("taskId", taskId); result.put("taskState", findTaskState(taskId)); return result.toJSONString(); } private String handleReleaseTask(JSONObject args) { String taskId = resolveTaskId(args, true); String reason = firstString(args, "reason", "message"); boolean ok = control.releaseTask(taskId, memberId, reason); JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_RELEASE_TASK, ok); result.put("taskId", taskId); result.put("taskState", findTaskState(taskId)); return result.toJSONString(); } private String handleReassignTask(JSONObject args) { String taskId = resolveTaskId(args, true); String toMemberId = firstString(args, "toMemberId", "to", "memberId"); if (toMemberId == null) { throw new IllegalArgumentException("toMemberId is required"); } boolean ok = control.reassignTask(taskId, memberId, toMemberId); JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_REASSIGN_TASK, ok); result.put("taskId", taskId); result.put("toMemberId", toMemberId); result.put("taskState", findTaskState(taskId)); return result.toJSONString(); } private String handleHeartbeatTask(JSONObject args) { String taskId = resolveTaskId(args, true); boolean ok = control.heartbeatTask(taskId, memberId); JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_HEARTBEAT_TASK, ok); result.put("taskId", taskId); result.put("taskState", findTaskState(taskId)); return result.toJSONString(); } private JSONObject parseArguments(String raw) { if (raw == null || raw.trim().isEmpty()) { return new JSONObject(); } try { JSONObject object = JSON.parseObject(raw); return object == null ? new JSONObject() : object; } catch (Exception e) { throw new IllegalArgumentException("invalid team tool arguments: " + raw); } } private String resolveTaskId(JSONObject args, boolean required) { String taskId = firstString(args, "taskId", "id"); if (taskId == null || taskId.trim().isEmpty()) { taskId = defaultTaskId; } if (required && (taskId == null || taskId.trim().isEmpty())) { throw new IllegalArgumentException("taskId is required"); } return taskId; } private AgentTeamTaskState findTaskState(String taskId) { if (taskId == null || taskId.trim().isEmpty()) { return null; } List states = control.listTaskStates(); if (states == null || states.isEmpty()) { return null; } for (AgentTeamTaskState state : states) { if (state == null || state.getTaskId() == null) { continue; } if (taskId.equals(state.getTaskId())) { return state; } } return null; } private String firstString(JSONObject args, String... keys) { if (args == null || keys == null) { return null; } for (String key : keys) { String value = args.getString(key); if (value != null) { String trimmed = value.trim(); if (!trimmed.isEmpty()) { return trimmed; } } } return null; } private JSONObject baseResult(String action, boolean ok) { JSONObject object = new JSONObject(); object.put("action", action); object.put("ok", ok); object.put("memberId", memberId); return object; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolRegistry.java ================================================ package io.github.lnyocly.ai4j.agent.team.tool; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; public class AgentTeamToolRegistry implements AgentToolRegistry { public static final String TOOL_SEND_MESSAGE = "team_send_message"; public static final String TOOL_BROADCAST = "team_broadcast"; public static final String TOOL_LIST_TASKS = "team_list_tasks"; public static final String TOOL_CLAIM_TASK = "team_claim_task"; public static final String TOOL_RELEASE_TASK = "team_release_task"; public static final String TOOL_REASSIGN_TASK = "team_reassign_task"; public static final String TOOL_HEARTBEAT_TASK = "team_heartbeat_task"; private static final Set TOOL_NAMES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( TOOL_SEND_MESSAGE, TOOL_BROADCAST, TOOL_LIST_TASKS, TOOL_CLAIM_TASK, TOOL_RELEASE_TASK, TOOL_REASSIGN_TASK, TOOL_HEARTBEAT_TASK ))); private final List tools; public AgentTeamToolRegistry() { this.tools = buildTools(); } @Override public List getTools() { return new ArrayList<>(tools); } public static boolean supports(String toolName) { return toolName != null && TOOL_NAMES.contains(toolName); } private List buildTools() { List list = new ArrayList<>(); list.add(createSendMessageTool()); list.add(createBroadcastTool()); list.add(createListTasksTool()); list.add(createClaimTaskTool()); list.add(createReleaseTaskTool()); list.add(createReassignTaskTool()); list.add(createHeartbeatTaskTool()); return list; } private Tool createSendMessageTool() { Map props = new LinkedHashMap<>(); props.put("toMemberId", stringProperty("Receiver member id")); props.put("content", stringProperty("Message content")); props.put("type", stringProperty("Optional message type, default peer.message")); props.put("taskId", stringProperty("Optional task id for message threading")); return createTool(TOOL_SEND_MESSAGE, "Send a direct message to a teammate.", props, Arrays.asList("toMemberId", "content")); } private Tool createBroadcastTool() { Map props = new LinkedHashMap<>(); props.put("content", stringProperty("Broadcast message content")); props.put("type", stringProperty("Optional message type, default peer.broadcast")); props.put("taskId", stringProperty("Optional task id for message threading")); return createTool(TOOL_BROADCAST, "Broadcast a message to the whole team.", props, Arrays.asList("content")); } private Tool createListTasksTool() { Map props = new LinkedHashMap<>(); return createTool(TOOL_LIST_TASKS, "List current shared task states.", props, Collections.emptyList()); } private Tool createClaimTaskTool() { Map props = new LinkedHashMap<>(); props.put("taskId", stringProperty("Task id to claim")); return createTool(TOOL_CLAIM_TASK, "Claim a ready task for execution.", props, Arrays.asList("taskId")); } private Tool createReleaseTaskTool() { Map props = new LinkedHashMap<>(); props.put("taskId", stringProperty("Task id to release")); props.put("reason", stringProperty("Optional release reason")); return createTool(TOOL_RELEASE_TASK, "Release a claimed task back to the queue.", props, Arrays.asList("taskId")); } private Tool createReassignTaskTool() { Map props = new LinkedHashMap<>(); props.put("taskId", stringProperty("Task id to reassign")); props.put("toMemberId", stringProperty("Target teammate id")); return createTool(TOOL_REASSIGN_TASK, "Reassign your claimed task to another teammate.", props, Arrays.asList("taskId", "toMemberId")); } private Tool createHeartbeatTaskTool() { Map props = new LinkedHashMap<>(); props.put("taskId", stringProperty("Task id to heartbeat")); return createTool(TOOL_HEARTBEAT_TASK, "Heartbeat for an in-progress task to avoid timeout recovery.", props, Arrays.asList("taskId")); } private Tool createTool(String name, String description, Map properties, List required) { Tool.Function.Parameter parameter = new Tool.Function.Parameter(); parameter.setType("object"); parameter.setProperties(properties); parameter.setRequired(required); Tool.Function function = new Tool.Function(); function.setName(name); function.setDescription(description); function.setParameters(parameter); Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); return tool; } private Tool.Function.Property stringProperty(String description) { Tool.Function.Property property = new Tool.Function.Property(); property.setType("string"); property.setDescription(description); return property; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCall.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentToolCall { private String name; private String arguments; private String callId; private String type; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCallSanitizer.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import java.util.ArrayList; import java.util.List; public final class AgentToolCallSanitizer { private AgentToolCallSanitizer() { } public static List retainExecutableCalls(List calls) { List valid = new ArrayList(); if (calls == null || calls.isEmpty()) { return valid; } for (AgentToolCall call : calls) { if (isExecutable(call)) { valid.add(call); } } return valid; } public static String validationError(AgentToolCall call) { if (call == null) { return "tool call payload is missing"; } if (isBlank(call.getName())) { return "tool name is required"; } if (isBlank(call.getArguments())) { return call.getName().trim() + " arguments are required"; } String toolName = call.getName().trim(); JSONObject arguments = parseObject(call.getArguments()); if (arguments == null) { return toolName + " arguments must be a JSON object"; } if ("bash".equals(toolName)) { return bashValidationError(arguments); } if ("read_file".equals(toolName) && isBlank(arguments.getString("path"))) { return "read_file requires a non-empty path"; } if ("apply_patch".equals(toolName) && isBlank(arguments.getString("patch"))) { return "apply_patch requires a non-empty patch"; } return null; } public static boolean isExecutable(AgentToolCall call) { return validationError(call) == null; } private static boolean isExecutableBashCall(JSONObject arguments) { if (arguments == null) { return false; } String action = firstNonBlank(arguments.getString("action"), "exec"); if ("exec".equals(action) || "start".equals(action)) { return !isBlank(arguments.getString("command")); } if ("status".equals(action) || "logs".equals(action) || "stop".equals(action) || "write".equals(action)) { return !isBlank(arguments.getString("processId")); } if ("list".equals(action)) { return true; } return false; } private static String bashValidationError(JSONObject arguments) { if (arguments == null) { return "bash arguments must be a JSON object"; } String action = firstNonBlank(arguments.getString("action"), "exec"); if ("exec".equals(action) || "start".equals(action)) { return isBlank(arguments.getString("command")) ? "bash " + action + " requires a non-empty command" : null; } if ("status".equals(action) || "logs".equals(action) || "stop".equals(action) || "write".equals(action)) { return isBlank(arguments.getString("processId")) ? "bash " + action + " requires a processId" : null; } if ("list".equals(action)) { return null; } return "unsupported bash action: " + action; } private static JSONObject parseObject(String value) { if (isBlank(value)) { return null; } try { return JSON.parseObject(value); } catch (Exception ignored) { return null; } } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolRegistry.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import java.util.List; public interface AgentToolRegistry { List getTools(); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolResult.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentToolResult { private String name; private String callId; private String output; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/CompositeToolRegistry.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class CompositeToolRegistry implements AgentToolRegistry { private final List registries; public CompositeToolRegistry(List registries) { if (registries == null) { this.registries = Collections.emptyList(); } else { this.registries = new ArrayList<>(registries); } } public CompositeToolRegistry(AgentToolRegistry first, AgentToolRegistry second) { List list = new ArrayList<>(); if (first != null) { list.add(first); } if (second != null) { list.add(second); } this.registries = list; } @Override public List getTools() { List tools = new ArrayList<>(); for (AgentToolRegistry registry : registries) { if (registry == null) { continue; } List items = registry.getTools(); if (items != null && !items.isEmpty()) { tools.addAll(items); } } return tools; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/StaticToolRegistry.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class StaticToolRegistry implements AgentToolRegistry { private final List tools; public StaticToolRegistry(List tools) { if (tools == null) { this.tools = Collections.emptyList(); } else { this.tools = new ArrayList<>(tools); } } @Override public List getTools() { return new ArrayList<>(tools); } public static StaticToolRegistry empty() { return new StaticToolRegistry(Collections.emptyList()); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.tool; public interface ToolExecutor { String execute(AgentToolCall call) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolUtilExecutor.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import io.github.lnyocly.ai4j.tool.ToolUtil; import java.util.Collections; import java.util.HashSet; import java.util.Set; public class ToolUtilExecutor implements ToolExecutor { private final Set allowedToolNames; public ToolUtilExecutor() { this(null); } public ToolUtilExecutor(Set allowedToolNames) { if (allowedToolNames == null) { this.allowedToolNames = null; } else { this.allowedToolNames = Collections.unmodifiableSet(new HashSet<>(allowedToolNames)); } } @Override public String execute(AgentToolCall call) throws Exception { if (call == null) { return null; } if (allowedToolNames != null) { String toolName = call.getName(); if (toolName == null || !allowedToolNames.contains(toolName)) { throw new IllegalArgumentException("Tool not allowed: " + toolName); } } return ToolUtil.invoke(call.getName(), call.getArguments()); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolUtilRegistry.java ================================================ package io.github.lnyocly.ai4j.agent.tool; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.tool.ToolUtil; import java.util.ArrayList; import java.util.List; public class ToolUtilRegistry implements AgentToolRegistry { private final List functionList; private final List mcpServerIds; public ToolUtilRegistry(List functionList, List mcpServerIds) { this.functionList = functionList; this.mcpServerIds = mcpServerIds; } @Override public List getTools() { List tools = ToolUtil.getAllTools(functionList, mcpServerIds); return new ArrayList(tools); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AbstractOpenTelemetryTraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanBuilder; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.Context; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.TimeUnit; abstract class AbstractOpenTelemetryTraceExporter implements TraceExporter { private final Tracer tracer; private final Map pendingSpans = new LinkedHashMap(); private final Map exportedSpans = new LinkedHashMap(); private final Map spanTraceIds = new LinkedHashMap(); private final Map completedTraces = new LinkedHashMap(); protected AbstractOpenTelemetryTraceExporter(OpenTelemetry openTelemetry, String instrumentationName) { this(openTelemetry == null ? null : openTelemetry.getTracer(instrumentationName)); } protected AbstractOpenTelemetryTraceExporter(Tracer tracer) { if (tracer == null) { throw new IllegalArgumentException("tracer is required"); } this.tracer = tracer; } @Override public synchronized void export(TraceSpan traceSpan) { if (traceSpan == null || traceSpan.getSpanId() == null) { return; } pendingSpans.put(traceSpan.getSpanId(), traceSpan); if (traceSpan.getParentSpanId() == null) { completedTraces.put(traceSpan.getTraceId(), Boolean.TRUE); } flushReadySpans(traceSpan.getTraceId()); cleanupIfTraceComplete(traceSpan.getTraceId()); } protected void customizeSpan(Span span, TraceSpan traceSpan) { } private void flushReadySpans(String traceId) { boolean emitted; do { emitted = false; Iterator> iterator = pendingSpans.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); TraceSpan traceSpan = entry.getValue(); if (!sameTrace(traceId, traceSpan.getTraceId())) { continue; } String parentSpanId = traceSpan.getParentSpanId(); if (parentSpanId != null && !exportedSpans.containsKey(parentSpanId)) { continue; } Span parentSpan = parentSpanId == null ? null : exportedSpans.get(parentSpanId); emitSpan(traceSpan, parentSpan); iterator.remove(); emitted = true; } } while (emitted); } private void emitSpan(TraceSpan traceSpan, Span parentSpan) { SpanBuilder builder = tracer.spanBuilder(traceSpan.getName() == null ? "ai4j.trace" : traceSpan.getName()) .setStartTimestamp(traceSpan.getStartTime(), TimeUnit.MILLISECONDS); if (parentSpan != null) { builder.setParent(Context.current().with(parentSpan)); } Span span = builder.startSpan(); try { OpenTelemetryTraceSupport.applyCommonAttributes(span, traceSpan); customizeSpan(span, traceSpan); } finally { span.end(traceSpan.getEndTime() > 0 ? traceSpan.getEndTime() : System.currentTimeMillis(), TimeUnit.MILLISECONDS); } exportedSpans.put(traceSpan.getSpanId(), span); spanTraceIds.put(traceSpan.getSpanId(), traceSpan.getTraceId()); } private void cleanupIfTraceComplete(String traceId) { if (traceId == null || !Boolean.TRUE.equals(completedTraces.get(traceId)) || hasPendingTrace(traceId)) { return; } Iterator> iterator = spanTraceIds.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); if (sameTrace(traceId, entry.getValue())) { exportedSpans.remove(entry.getKey()); iterator.remove(); } } completedTraces.remove(traceId); } private boolean hasPendingTrace(String traceId) { for (TraceSpan traceSpan : pendingSpans.values()) { if (sameTrace(traceId, traceSpan.getTraceId())) { return true; } } return false; } private boolean sameTrace(String left, String right) { if (left == null) { return right == null; } return left.equals(right); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AgentFlowTraceBridge.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowEvent; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest; import io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public class AgentFlowTraceBridge implements AgentFlowTraceListener { private final TraceExporter exporter; private final TraceConfig config; private final Map activeSpans = new LinkedHashMap(); public AgentFlowTraceBridge(TraceExporter exporter) { this(exporter, null); } public AgentFlowTraceBridge(TraceExporter exporter, TraceConfig config) { if (exporter == null) { throw new IllegalArgumentException("exporter is required"); } this.exporter = exporter; this.config = config == null ? TraceConfig.builder().build() : config; } @Override public synchronized void onStart(AgentFlowTraceContext context) { if (context == null || isBlank(context.getExecutionId())) { return; } TraceSpan span = TraceSpan.builder() .traceId(UUID.randomUUID().toString()) .spanId(UUID.randomUUID().toString()) .name(spanName(context)) .type(TraceSpanType.AGENT_FLOW) .startTime(context.getStartedAt() > 0L ? context.getStartedAt() : System.currentTimeMillis()) .attributes(startAttributes(context)) .events(new ArrayList()) .build(); activeSpans.put(context.getExecutionId(), span); } @Override public synchronized void onEvent(AgentFlowTraceContext context, Object event) { TraceSpan span = span(context); if (span == null || event == null) { return; } if (event instanceof AgentFlowChatEvent) { applyChatEvent(span, (AgentFlowChatEvent) event); return; } if (event instanceof AgentFlowWorkflowEvent) { applyWorkflowEvent(span, (AgentFlowWorkflowEvent) event); return; } addSpanEvent(span, "agentflow.event", singletonAttribute("value", safeValue(event))); } @Override public synchronized void onComplete(AgentFlowTraceContext context, Object response) { TraceSpan span = removeSpan(context); if (span == null) { return; } if (response instanceof AgentFlowChatResponse) { applyChatResponse(span, (AgentFlowChatResponse) response); } else if (response instanceof AgentFlowWorkflowResponse) { applyWorkflowResponse(span, (AgentFlowWorkflowResponse) response); } else if (response != null && config.isRecordModelOutput()) { putAttribute(span, "output", safeValue(response)); } finishSpan(span, TraceSpanStatus.OK, null); } @Override public synchronized void onError(AgentFlowTraceContext context, Throwable throwable) { TraceSpan span = removeSpan(context); if (span == null) { return; } finishSpan(span, TraceSpanStatus.ERROR, throwable == null ? null : safeText(throwable.getMessage())); } private TraceSpan span(AgentFlowTraceContext context) { if (context == null || isBlank(context.getExecutionId())) { return null; } return activeSpans.get(context.getExecutionId()); } private TraceSpan removeSpan(AgentFlowTraceContext context) { if (context == null || isBlank(context.getExecutionId())) { return null; } return activeSpans.remove(context.getExecutionId()); } private String spanName(AgentFlowTraceContext context) { String operation = context == null ? null : context.getOperation(); return isBlank(operation) ? "agentflow.run" : "agentflow." + operation; } private Map startAttributes(AgentFlowTraceContext context) { Map attributes = new LinkedHashMap(); if (context == null) { return attributes; } putIfPresent(attributes, "providerType", context.getType() == null ? null : context.getType().name()); putIfPresent(attributes, "operation", context.getOperation()); attributes.put("streaming", Boolean.valueOf(context.isStreaming())); putIfPresent(attributes, "baseUrl", safeText(context.getBaseUrl())); putIfPresent(attributes, "webhookUrl", safeText(context.getWebhookUrl())); putIfPresent(attributes, "botId", safeText(context.getBotId())); putIfPresent(attributes, "configuredWorkflowId", safeText(context.getWorkflowId())); putIfPresent(attributes, "appId", safeText(context.getAppId())); putIfPresent(attributes, "configuredUserId", safeText(context.getConfiguredUserId())); putIfPresent(attributes, "configuredConversationId", safeText(context.getConfiguredConversationId())); applyRequest(attributes, context.getRequest()); return attributes; } private void applyRequest(Map attributes, Object request) { if (attributes == null || request == null || !config.isRecordModelInput()) { return; } if (request instanceof AgentFlowChatRequest) { AgentFlowChatRequest chatRequest = (AgentFlowChatRequest) request; putIfPresent(attributes, "message", safeText(chatRequest.getPrompt())); putIfPresent(attributes, "arguments", safeValue(chatRequest.getInputs())); putIfPresent(attributes, "requestUserId", safeText(chatRequest.getUserId())); putIfPresent(attributes, "requestConversationId", safeText(chatRequest.getConversationId())); putIfPresent(attributes, "requestMetadata", safeValue(chatRequest.getMetadata())); putIfPresent(attributes, "requestExtraBody", safeValue(chatRequest.getExtraBody())); return; } if (request instanceof AgentFlowWorkflowRequest) { AgentFlowWorkflowRequest workflowRequest = (AgentFlowWorkflowRequest) request; putIfPresent(attributes, "arguments", safeValue(workflowRequest.getInputs())); putIfPresent(attributes, "requestUserId", safeText(workflowRequest.getUserId())); putIfPresent(attributes, "requestWorkflowId", safeText(workflowRequest.getWorkflowId())); putIfPresent(attributes, "requestMetadata", safeValue(workflowRequest.getMetadata())); putIfPresent(attributes, "requestExtraBody", safeValue(workflowRequest.getExtraBody())); return; } putIfPresent(attributes, "arguments", safeValue(request)); } private void applyChatEvent(TraceSpan span, AgentFlowChatEvent event) { if (span == null || event == null) { return; } putIfPresent(span.getAttributes(), "conversationId", safeText(event.getConversationId())); putIfPresent(span.getAttributes(), "messageId", safeText(event.getMessageId())); putIfPresent(span.getAttributes(), "taskId", safeText(event.getTaskId())); applyUsage(span, event.getUsage()); Map attributes = new HashMap(); putIfPresent(attributes, "type", safeText(event.getType())); if (config.isRecordModelOutput()) { putIfPresent(attributes, "delta", safeText(event.getContentDelta())); } if (event.isDone()) { attributes.put("done", Boolean.TRUE); } putIfPresent(attributes, "conversationId", safeText(event.getConversationId())); putIfPresent(attributes, "messageId", safeText(event.getMessageId())); putIfPresent(attributes, "taskId", safeText(event.getTaskId())); addSpanEvent(span, "agentflow.chat.event", attributes); } private void applyWorkflowEvent(TraceSpan span, AgentFlowWorkflowEvent event) { if (span == null || event == null) { return; } putIfPresent(span.getAttributes(), "status", safeText(event.getStatus())); putIfPresent(span.getAttributes(), "taskId", safeText(event.getTaskId())); putIfPresent(span.getAttributes(), "workflowRunId", safeText(event.getWorkflowRunId())); applyUsage(span, event.getUsage()); Map attributes = new HashMap(); putIfPresent(attributes, "type", safeText(event.getType())); putIfPresent(attributes, "status", safeText(event.getStatus())); if (config.isRecordModelOutput()) { putIfPresent(attributes, "outputText", safeText(event.getOutputText())); } if (event.isDone()) { attributes.put("done", Boolean.TRUE); } putIfPresent(attributes, "taskId", safeText(event.getTaskId())); putIfPresent(attributes, "workflowRunId", safeText(event.getWorkflowRunId())); addSpanEvent(span, "agentflow.workflow.event", attributes); } private void applyChatResponse(TraceSpan span, AgentFlowChatResponse response) { if (span == null || response == null) { return; } putIfPresent(span.getAttributes(), "conversationId", safeText(response.getConversationId())); putIfPresent(span.getAttributes(), "messageId", safeText(response.getMessageId())); putIfPresent(span.getAttributes(), "taskId", safeText(response.getTaskId())); if (config.isRecordModelOutput()) { putIfPresent(span.getAttributes(), "output", safeText(response.getContent())); putIfPresent(span.getAttributes(), "rawResponse", safeValue(response.getRaw())); } applyUsage(span, response.getUsage()); } private void applyWorkflowResponse(TraceSpan span, AgentFlowWorkflowResponse response) { if (span == null || response == null) { return; } putIfPresent(span.getAttributes(), "status", safeText(response.getStatus())); putIfPresent(span.getAttributes(), "taskId", safeText(response.getTaskId())); putIfPresent(span.getAttributes(), "workflowRunId", safeText(response.getWorkflowRunId())); if (config.isRecordModelOutput()) { putIfPresent(span.getAttributes(), "output", safeText(response.getOutputText())); putIfPresent(span.getAttributes(), "outputs", safeValue(response.getOutputs())); putIfPresent(span.getAttributes(), "rawResponse", safeValue(response.getRaw())); } applyUsage(span, response.getUsage()); } private void applyUsage(TraceSpan span, AgentFlowUsage usage) { if (span == null || usage == null || !config.isRecordMetrics()) { return; } TraceMetrics metrics = span.getMetrics(); if (metrics == null) { metrics = new TraceMetrics(); span.setMetrics(metrics); } if (usage.getInputTokens() != null) { metrics.setPromptTokens(Long.valueOf(usage.getInputTokens().longValue())); } if (usage.getOutputTokens() != null) { metrics.setCompletionTokens(Long.valueOf(usage.getOutputTokens().longValue())); } if (usage.getTotalTokens() != null) { metrics.setTotalTokens(Long.valueOf(usage.getTotalTokens().longValue())); } } private void finishSpan(TraceSpan span, TraceSpanStatus status, String error) { span.setStatus(status == null ? TraceSpanStatus.OK : status); span.setEndTime(System.currentTimeMillis()); span.setError(error); if (config.isRecordMetrics()) { TraceMetrics metrics = span.getMetrics(); if (metrics == null) { metrics = new TraceMetrics(); span.setMetrics(metrics); } long durationMillis = Math.max(span.getEndTime() - span.getStartTime(), 0L); metrics.setDurationMillis(Long.valueOf(durationMillis)); } exporter.export(span); } private void addSpanEvent(TraceSpan span, String name, Map attributes) { if (span == null || isBlank(name)) { return; } List events = span.getEvents(); if (events == null) { events = new ArrayList(); span.setEvents(events); } events.add(TraceSpanEvent.builder() .timestamp(System.currentTimeMillis()) .name(name) .attributes(attributes == null ? new HashMap() : attributes) .build()); } private void putAttribute(TraceSpan span, String key, Object value) { if (span == null || isBlank(key) || value == null) { return; } Map attributes = span.getAttributes(); if (attributes == null) { attributes = new LinkedHashMap(); span.setAttributes(attributes); } attributes.put(key, value); } private void putIfPresent(Map target, String key, Object value) { if (target != null && !isBlank(key) && value != null) { target.put(key, value); } } private Map singletonAttribute(String key, Object value) { Map attributes = new HashMap(); if (!isBlank(key) && value != null) { attributes.put(key, value); } return attributes; } private Object safeValue(Object value) { if (value == null) { return null; } String text = value instanceof String ? (String) value : JSON.toJSONString(value); if (config.getMasker() != null) { text = config.getMasker().mask(text); } int maxLength = config.getMaxFieldLength(); if (maxLength > 0 && text.length() > maxLength) { text = text.substring(0, maxLength) + "..."; } return text; } private String safeText(String value) { Object safe = safeValue(value); return safe == null ? null : String.valueOf(safe); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AgentTraceListener.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public class AgentTraceListener implements AgentListener { private final TraceExporter exporter; private final TraceConfig config; private String traceId; private TraceSpan rootSpan; private final Map stepSpans = new HashMap<>(); private final Map modelSpans = new HashMap<>(); private final Map toolSpans = new HashMap<>(); private final Map toolSpansByStep = new HashMap<>(); private final Map handoffSpans = new HashMap<>(); private final Map teamTaskSpans = new HashMap<>(); public AgentTraceListener(TraceExporter exporter) { this(exporter, null); } public AgentTraceListener(TraceExporter exporter, TraceConfig config) { this.exporter = exporter; this.config = config == null ? TraceConfig.builder().build() : config; } @Override public synchronized void onEvent(AgentEvent event) { if (event == null) { return; } AgentEventType type = event.getType(); if (type == null) { return; } switch (type) { case STEP_START: onStepStart(event); break; case STEP_END: onStepEnd(event); break; case MODEL_REQUEST: onModelRequest(event); break; case MODEL_RESPONSE: onModelResponse(event); break; case MODEL_RETRY: onModelRetry(event); break; case MODEL_REASONING: onModelReasoning(event); break; case TOOL_CALL: onToolCall(event); break; case TOOL_RESULT: onToolResult(event); break; case HANDOFF_START: onHandoffStart(event); break; case HANDOFF_END: onHandoffEnd(event); break; case TEAM_TASK_CREATED: onTeamTaskCreated(event); break; case TEAM_TASK_UPDATED: onTeamTaskUpdated(event); break; case TEAM_MESSAGE: onTeamMessage(event); break; case MEMORY_COMPRESS: onMemoryCompress(event); break; case FINAL_OUTPUT: onFinalOutput(event); break; case ERROR: onError(event); break; default: break; } } private void onStepStart(AgentEvent event) { if (traceId == null) { traceId = UUID.randomUUID().toString(); rootSpan = startSpan("agent.run", TraceSpanType.RUN, null, null); } Integer step = event.getStep(); if (step == null) { return; } TraceSpan stepSpan = startSpan("step:" + step, TraceSpanType.STEP, rootSpan == null ? null : rootSpan.getSpanId(), null); stepSpans.put(step, stepSpan); } private void onStepEnd(AgentEvent event) { Integer step = event.getStep(); if (step == null) { return; } TraceSpan span = stepSpans.remove(step); finishSpan(span, TraceSpanStatus.OK, null); toolSpansByStep.remove(step); if (rootSpan != null && rootSpan.getEndTime() > 0 && stepSpans.isEmpty()) { reset(); } } private void onModelRequest(AgentEvent event) { Integer step = event.getStep(); TraceSpan parent = step == null ? rootSpan : stepSpans.get(step); Map attributes = new HashMap<>(); Object payload = event.getPayload(); if (payload instanceof AgentPrompt) { AgentPrompt prompt = (AgentPrompt) payload; if (prompt.getModel() != null) { attributes.put("model", prompt.getModel()); } if (config.isRecordModelInput()) { attributes.put("systemPrompt", safeValue(prompt.getSystemPrompt())); attributes.put("instructions", safeValue(prompt.getInstructions())); attributes.put("items", safeValue(prompt.getItems())); attributes.put("tools", safeValue(prompt.getTools())); attributes.put("toolChoice", safeValue(prompt.getToolChoice())); attributes.put("parallelToolCalls", safeValue(prompt.getParallelToolCalls())); attributes.put("temperature", safeValue(prompt.getTemperature())); attributes.put("topP", safeValue(prompt.getTopP())); attributes.put("maxOutputTokens", safeValue(prompt.getMaxOutputTokens())); attributes.put("reasoning", safeValue(prompt.getReasoning())); attributes.put("store", safeValue(prompt.getStore())); attributes.put("stream", safeValue(prompt.getStream())); attributes.put("user", safeValue(prompt.getUser())); attributes.put("extraBody", safeValue(prompt.getExtraBody())); } } TraceSpan span = startSpan("model.request", TraceSpanType.MODEL, parent == null ? null : parent.getSpanId(), attributes); if (step != null) { modelSpans.put(step, span); } } private void onModelResponse(AgentEvent event) { Integer step = event.getStep(); TraceSpan span = resolveModelSpan(step); if (span == null) { return; } if (event.getPayload() != null) { enrichModelSpan(span, event.getPayload()); } if (config.isRecordModelOutput()) { if (event.getPayload() != null) { putAttribute(span, "output", safeValue(event.getPayload())); } else if (event.getMessage() != null && !event.getMessage().isEmpty()) { addSpanEvent(span, "model.response.delta", singletonAttribute("delta", safeValue(event.getMessage()))); } } if (event.getPayload() != null) { accumulateModelMetrics(step, span); if (step != null) { modelSpans.remove(step); } finishSpan(span, TraceSpanStatus.OK, null); } } private void onModelRetry(AgentEvent event) { TraceSpan span = resolveModelSpan(event.getStep()); addSpanEvent(span, "model.retry", attributesFromEvent(event)); } private void onModelReasoning(AgentEvent event) { TraceSpan span = resolveModelSpan(event.getStep()); Map attributes = attributesFromEvent(event); if (event.getMessage() != null && !event.getMessage().isEmpty()) { attributes.put("text", safeValue(event.getMessage())); } addSpanEvent(span, "model.reasoning", attributes); } private void onToolCall(AgentEvent event) { Object payload = event.getPayload(); String callId = null; String toolName = event.getMessage(); if (payload instanceof AgentToolCall) { AgentToolCall call = (AgentToolCall) payload; callId = call.getCallId(); toolName = call.getName(); if (config.isRecordToolArgs()) { Map attributes = new HashMap<>(); attributes.put("tool", toolName); attributes.put("callId", callId); attributes.put("arguments", safeValue(call.getArguments())); TraceSpan parent = event.getStep() == null ? rootSpan : stepSpans.get(event.getStep()); TraceSpan span = startSpan("tool:" + (toolName == null ? "unknown" : toolName), TraceSpanType.TOOL, parent == null ? null : parent.getSpanId(), attributes); toolSpans.put(callId == null ? keyForStep(event.getStep(), toolName) : callId, span); if (event.getStep() != null) { toolSpansByStep.put(event.getStep(), span); } return; } } String key = callId == null ? keyForStep(event.getStep(), toolName) : callId; TraceSpan parent = event.getStep() == null ? rootSpan : stepSpans.get(event.getStep()); Map attributes = new HashMap<>(); if (toolName != null) { attributes.put("tool", toolName); } TraceSpan span = startSpan("tool:" + (toolName == null ? "unknown" : toolName), TraceSpanType.TOOL, parent == null ? null : parent.getSpanId(), attributes); toolSpans.put(key, span); if (event.getStep() != null) { toolSpansByStep.put(event.getStep(), span); } } private void onToolResult(AgentEvent event) { Object payload = event.getPayload(); String callId = null; if (payload instanceof AgentToolResult) { callId = ((AgentToolResult) payload).getCallId(); } String key = callId == null ? keyForStep(event.getStep(), event.getMessage()) : callId; TraceSpan span = toolSpans.remove(key); if (span == null && event.getStep() != null) { span = toolSpansByStep.remove(event.getStep()); } TraceSpanStatus status = TraceSpanStatus.OK; String error = null; if (payload instanceof CodeExecutionResult) { CodeExecutionResult result = (CodeExecutionResult) payload; if (!result.isSuccess()) { status = TraceSpanStatus.ERROR; error = result.getError(); } if (config.isRecordToolOutput()) { putAttribute(span, "result", safeValue(result.getResult())); putAttribute(span, "stdout", safeValue(result.getStdout())); putAttribute(span, "error", safeValue(result.getError())); } } else if (payload instanceof AgentToolResult) { AgentToolResult result = (AgentToolResult) payload; if (config.isRecordToolOutput()) { putAttribute(span, "output", safeValue(result.getOutput())); } } else if (config.isRecordToolOutput()) { putAttribute(span, "output", safeValue(event.getMessage())); } finishSpan(span, status, error); } private void onFinalOutput(AgentEvent event) { if (config.isRecordModelOutput()) { putAttribute(rootSpan, "finalOutput", safeValue(event.getMessage())); } finishSpan(rootSpan, TraceSpanStatus.OK, null); } private void onError(AgentEvent event) { finishSpan(rootSpan, TraceSpanStatus.ERROR, event.getMessage()); reset(); } private void onHandoffStart(AgentEvent event) { Map payload = payloadMap(event.getPayload()); String handoffId = firstNonBlank(stringValue(payload, "handoffId"), event.getMessage(), UUID.randomUUID().toString()); TraceSpan parent = resolveHandoffParent(event, payload); TraceSpan span = startSpan("handoff:" + firstNonBlank(stringValue(payload, "subagent"), stringValue(payload, "tool"), "subagent"), TraceSpanType.HANDOFF, parent == null ? null : parent.getSpanId(), safeAttributes(payload)); handoffSpans.put(handoffId, span); } private void onHandoffEnd(AgentEvent event) { Map payload = payloadMap(event.getPayload()); String handoffId = firstNonBlank(stringValue(payload, "handoffId"), event.getMessage()); TraceSpan span = handoffId == null ? null : handoffSpans.remove(handoffId); if (span == null) { TraceSpan parent = resolveHandoffParent(event, payload); span = startSpan("handoff:" + firstNonBlank(stringValue(payload, "subagent"), stringValue(payload, "tool"), "subagent"), TraceSpanType.HANDOFF, parent == null ? null : parent.getSpanId(), safeAttributes(payload)); } else { mergeAttributes(span, safeAttributes(payload)); } addSpanEvent(span, "handoff.end", attributesFromEvent(event)); finishSpan(span, resolveStatus(payload, event.getMessage()), firstNonBlank(stringValue(payload, "error"), event.getMessage())); } private void onTeamTaskCreated(AgentEvent event) { Map payload = payloadMap(event.getPayload()); String taskId = firstNonBlank(stringValue(payload, "taskId"), event.getMessage(), UUID.randomUUID().toString()); TraceSpan span = startSpan("team.task:" + taskId, TraceSpanType.TEAM_TASK, rootSpan == null ? null : rootSpan.getSpanId(), safeAttributes(payload)); teamTaskSpans.put(taskId, span); addSpanEvent(span, "team.task.created", attributesFromEvent(event)); } private void onTeamTaskUpdated(AgentEvent event) { Map payload = payloadMap(event.getPayload()); String taskId = firstNonBlank(stringValue(payload, "taskId"), event.getMessage()); if (taskId == null) { return; } TraceSpan span = teamTaskSpans.get(taskId); if (span == null) { span = startSpan("team.task:" + taskId, TraceSpanType.TEAM_TASK, rootSpan == null ? null : rootSpan.getSpanId(), safeAttributes(payload)); teamTaskSpans.put(taskId, span); } else { mergeAttributes(span, safeAttributes(payload)); } addSpanEvent(span, "team.task.updated", attributesFromEvent(event)); if (isTerminalStatus(stringValue(payload, "status")) || stringValue(payload, "error") != null) { teamTaskSpans.remove(taskId); finishSpan(span, resolveStatus(payload, event.getMessage()), stringValue(payload, "error")); } } private void onTeamMessage(AgentEvent event) { Map payload = payloadMap(event.getPayload()); String taskId = stringValue(payload, "taskId"); TraceSpan span = taskId == null ? rootSpan : teamTaskSpans.get(taskId); addSpanEvent(span == null ? rootSpan : span, "team.message", attributesFromEvent(event)); } private void onMemoryCompress(AgentEvent event) { TraceSpan parent = event.getStep() == null ? rootSpan : stepSpans.get(event.getStep()); TraceSpan span = startSpan("memory.compress", TraceSpanType.MEMORY, parent == null ? null : parent.getSpanId(), attributesFromEvent(event)); finishSpan(span, TraceSpanStatus.OK, null); } private TraceSpan startSpan(String name, TraceSpanType type, String parentId, Map attributes) { TraceSpan span = TraceSpan.builder() .traceId(traceId) .spanId(UUID.randomUUID().toString()) .parentSpanId(parentId) .name(name) .type(type) .status(TraceSpanStatus.OK) .startTime(System.currentTimeMillis()) .attributes(attributes == null ? new HashMap<>() : attributes) .events(new ArrayList()) .metrics(new TraceMetrics()) .build(); return span; } private TraceSpan resolveModelSpan(Integer step) { if (step == null) { return null; } return modelSpans.get(step); } private TraceSpan resolveHandoffParent(AgentEvent event, Map payload) { String callId = stringValue(payload, "callId"); if (callId != null) { TraceSpan span = toolSpans.get(callId); if (span != null) { return span; } } if (event != null && event.getStep() != null) { TraceSpan span = toolSpansByStep.get(event.getStep()); if (span != null) { return span; } span = stepSpans.get(event.getStep()); if (span != null) { return span; } } return rootSpan; } private void enrichModelSpan(TraceSpan span, Object payload) { if (span == null || payload == null) { return; } String responseId = null; String responseModel = null; String finishReason = null; Usage usage = null; if (payload instanceof ChatCompletionResponse) { ChatCompletionResponse response = (ChatCompletionResponse) payload; responseId = response.getId(); responseModel = response.getModel(); usage = response.getUsage(); finishReason = firstChoiceFinishReason(response.getChoices()); putAttribute(span, "systemFingerprint", safeValue(response.getSystemFingerprint())); } else if (payload instanceof Map) { Map response = payloadMap(payload); responseId = stringValue(response, "id"); responseModel = stringValue(response, "model"); finishReason = stringValue(response, "finishReason"); usage = mapToUsage(response.get("usage")); } if (responseId != null) { putAttribute(span, "responseId", responseId); } if (responseModel != null) { putAttribute(span, "responseModel", responseModel); } if (finishReason != null) { putAttribute(span, "finishReason", finishReason); } mergeMetrics(span, metricsFromUsage(usage, resolveModelPricing(span, responseModel))); } private void accumulateModelMetrics(Integer step, TraceSpan modelSpan) { if (modelSpan == null || modelSpan.getMetrics() == null) { return; } mergeUsageMetrics(rootSpan, modelSpan.getMetrics()); if (step != null) { mergeUsageMetrics(stepSpans.get(step), modelSpan.getMetrics()); } } private void mergeMetrics(TraceSpan span, TraceMetrics metrics) { if (span == null || metrics == null) { return; } TraceMetrics target = span.getMetrics(); if (target == null) { target = new TraceMetrics(); span.setMetrics(target); } if (target.getDurationMillis() == null) { target.setDurationMillis(metrics.getDurationMillis()); } target.setPromptTokens(sum(target.getPromptTokens(), metrics.getPromptTokens())); target.setCompletionTokens(sum(target.getCompletionTokens(), metrics.getCompletionTokens())); target.setTotalTokens(sum(target.getTotalTokens(), metrics.getTotalTokens())); target.setInputCost(sum(target.getInputCost(), metrics.getInputCost())); target.setOutputCost(sum(target.getOutputCost(), metrics.getOutputCost())); target.setTotalCost(sum(target.getTotalCost(), metrics.getTotalCost())); if (target.getCurrency() == null) { target.setCurrency(metrics.getCurrency()); } } private void mergeUsageMetrics(TraceSpan span, TraceMetrics source) { if (span == null || source == null) { return; } TraceMetrics usageOnly = TraceMetrics.builder() .promptTokens(source.getPromptTokens()) .completionTokens(source.getCompletionTokens()) .totalTokens(source.getTotalTokens()) .inputCost(source.getInputCost()) .outputCost(source.getOutputCost()) .totalCost(source.getTotalCost()) .currency(source.getCurrency()) .build(); mergeMetrics(span, usageOnly); } private TraceMetrics metricsFromUsage(Usage usage, TracePricing pricing) { if (!config.isRecordMetrics() || usage == null) { return null; } long promptTokens = usage.getPromptTokens(); long completionTokens = usage.getCompletionTokens(); long totalTokens = usage.getTotalTokens(); Double inputCost = null; Double outputCost = null; Double totalCost = null; String currency = null; if (pricing != null) { if (pricing.getInputCostPerMillionTokens() != null) { inputCost = (promptTokens / 1000000D) * pricing.getInputCostPerMillionTokens(); } if (pricing.getOutputCostPerMillionTokens() != null) { outputCost = (completionTokens / 1000000D) * pricing.getOutputCostPerMillionTokens(); } if (inputCost != null || outputCost != null) { totalCost = (inputCost == null ? 0D : inputCost) + (outputCost == null ? 0D : outputCost); } currency = pricing.getCurrency(); } return TraceMetrics.builder() .promptTokens(promptTokens) .completionTokens(completionTokens) .totalTokens(totalTokens) .inputCost(inputCost) .outputCost(outputCost) .totalCost(totalCost) .currency(currency) .build(); } private TracePricing resolveModelPricing(TraceSpan span, String responseModel) { TracePricingResolver resolver = config.getPricingResolver(); if (resolver == null) { return null; } String model = responseModel; if (model == null && span != null && span.getAttributes() != null) { Object value = span.getAttributes().get("model"); if (value != null) { model = String.valueOf(value); } } return model == null ? null : resolver.resolve(model); } private Usage mapToUsage(Object value) { if (value instanceof Usage) { return (Usage) value; } if (!(value instanceof Map)) { return null; } Map usageMap = payloadMap(value); Usage usage = new Usage(); usage.setPromptTokens(longValue(firstNonNull(usageMap.get("prompt_tokens"), usageMap.get("promptTokens"), usageMap.get("input")))); usage.setCompletionTokens(longValue(firstNonNull(usageMap.get("completion_tokens"), usageMap.get("completionTokens"), usageMap.get("output")))); usage.setTotalTokens(longValue(firstNonNull(usageMap.get("total_tokens"), usageMap.get("totalTokens"), usageMap.get("total")))); return usage; } private String firstChoiceFinishReason(List choices) { if (choices == null || choices.isEmpty() || choices.get(0) == null) { return null; } return choices.get(0).getFinishReason(); } private void putAttribute(TraceSpan span, String key, Object value) { if (span == null || key == null) { return; } Map attributes = span.getAttributes(); if (attributes == null) { attributes = new HashMap<>(); span.setAttributes(attributes); } if (value == null) { return; } attributes.put(key, value); } private void mergeAttributes(TraceSpan span, Map attributes) { if (span == null || attributes == null || attributes.isEmpty()) { return; } Map target = span.getAttributes(); if (target == null) { target = new HashMap(); span.setAttributes(target); } target.putAll(attributes); } private void addSpanEvent(TraceSpan span, String name, Map attributes) { if (span == null || name == null) { return; } List events = span.getEvents(); if (events == null) { events = new ArrayList(); span.setEvents(events); } events.add(TraceSpanEvent.builder() .timestamp(System.currentTimeMillis()) .name(name) .attributes(attributes == null ? new HashMap() : attributes) .build()); } private Object safeValue(Object value) { if (value == null) { return null; } String text; if (value instanceof String) { text = (String) value; } else { text = JSON.toJSONString(value); } if (config.getMasker() != null) { text = config.getMasker().mask(text); } int maxLength = config.getMaxFieldLength(); if (maxLength > 0 && text.length() > maxLength) { text = text.substring(0, maxLength) + "..."; } return text; } private Map attributesFromEvent(AgentEvent event) { Map attributes = safeAttributes(payloadMap(event == null ? null : event.getPayload())); if (event != null && event.getMessage() != null && !event.getMessage().isEmpty()) { attributes.put("message", safeValue(event.getMessage())); } if (event != null && event.getStep() != null) { attributes.put("step", event.getStep()); } return attributes; } @SuppressWarnings("unchecked") private Map payloadMap(Object payload) { if (payload instanceof Map) { return (Map) payload; } return new HashMap(); } private Map safeAttributes(Map source) { Map target = new HashMap(); if (source == null || source.isEmpty()) { return target; } for (Map.Entry entry : source.entrySet()) { target.put(entry.getKey(), safeValue(entry.getValue())); } return target; } private Map singletonAttribute(String key, Object value) { Map attributes = new HashMap(); if (key != null && value != null) { attributes.put(key, value); } return attributes; } private String stringValue(Map payload, String key) { if (payload == null || key == null) { return null; } Object value = payload.get(key); return value == null ? null : String.valueOf(value); } private TraceSpanStatus resolveStatus(Map payload, String fallbackMessage) { String status = stringValue(payload, "status"); if (stringValue(payload, "error") != null || (fallbackMessage != null && fallbackMessage.toLowerCase().contains("fail"))) { return TraceSpanStatus.ERROR; } if (status == null) { return TraceSpanStatus.OK; } String normalized = status.trim().toLowerCase(); if ("canceled".equals(normalized) || "cancelled".equals(normalized)) { return TraceSpanStatus.CANCELED; } if ("failed".equals(normalized) || "error".equals(normalized)) { return TraceSpanStatus.ERROR; } return TraceSpanStatus.OK; } private boolean isTerminalStatus(String status) { if (status == null) { return false; } String normalized = status.trim().toLowerCase(); return "completed".equals(normalized) || "failed".equals(normalized) || "blocked".equals(normalized) || "canceled".equals(normalized) || "cancelled".equals(normalized); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value; } } return null; } private Object firstNonNull(Object... values) { if (values == null) { return null; } for (Object value : values) { if (value != null) { return value; } } return null; } private Long sum(Long left, Long right) { if (left == null) { return right; } if (right == null) { return left; } return left + right; } private Double sum(Double left, Double right) { if (left == null) { return right; } if (right == null) { return left; } return left + right; } private long longValue(Object value) { if (value == null) { return 0L; } if (value instanceof Number) { return ((Number) value).longValue(); } try { return Long.parseLong(String.valueOf(value)); } catch (NumberFormatException ex) { return 0L; } } private void finishSpan(TraceSpan span, TraceSpanStatus status, String error) { if (span == null) { return; } span.setStatus(status == null ? TraceSpanStatus.OK : status); span.setEndTime(System.currentTimeMillis()); span.setError(error); if (config.isRecordMetrics()) { TraceMetrics metrics = span.getMetrics(); if (metrics == null) { metrics = new TraceMetrics(); span.setMetrics(metrics); } long durationMillis = span.getEndTime() - span.getStartTime(); metrics.setDurationMillis(durationMillis < 0 ? 0L : durationMillis); } if (exporter != null) { exporter.export(span); } } private String keyForStep(Integer step, String toolName) { if (step == null) { return toolName == null ? "tool" : toolName; } return step + ":" + (toolName == null ? "tool" : toolName); } private void reset() { traceId = null; rootSpan = null; stepSpans.clear(); modelSpans.clear(); toolSpans.clear(); toolSpansByStep.clear(); handoffSpans.clear(); teamTaskSpans.clear(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/CompositeTraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class CompositeTraceExporter implements TraceExporter { private final List exporters; public CompositeTraceExporter(TraceExporter... exporters) { this(exporters == null ? null : Arrays.asList(exporters)); } public CompositeTraceExporter(List exporters) { this.exporters = new ArrayList(); if (exporters == null) { return; } for (TraceExporter exporter : exporters) { if (exporter != null) { this.exporters.add(exporter); } } } @Override public void export(TraceSpan span) { for (TraceExporter exporter : exporters) { exporter.export(span); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/ConsoleTraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import com.alibaba.fastjson2.JSON; public class ConsoleTraceExporter implements TraceExporter { @Override public void export(TraceSpan span) { if (span == null) { return; } System.out.println("TRACE " + JSON.toJSONString(span)); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/InMemoryTraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class InMemoryTraceExporter implements TraceExporter { private final List spans = new ArrayList<>(); @Override public synchronized void export(TraceSpan span) { if (span != null) { spans.add(span); } } public synchronized List getSpans() { return Collections.unmodifiableList(new ArrayList<>(spans)); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/JsonlTraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import com.alibaba.fastjson2.JSON; import java.io.BufferedWriter; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; public class JsonlTraceExporter implements TraceExporter, AutoCloseable { private final Writer writer; private final boolean ownsWriter; public JsonlTraceExporter(String path) { this(path == null ? null : new File(path)); } public JsonlTraceExporter(File file) { this(createWriter(file), true); } public JsonlTraceExporter(Writer writer) { this(writer, false); } private JsonlTraceExporter(Writer writer, boolean ownsWriter) { if (writer == null) { throw new IllegalArgumentException("writer is required"); } this.writer = writer; this.ownsWriter = ownsWriter; } @Override public synchronized void export(TraceSpan span) { if (span == null) { return; } try { writer.write(JSON.toJSONString(span)); writer.write(System.lineSeparator()); writer.flush(); } catch (IOException ex) { throw new IllegalStateException("Failed to export trace span as JSONL", ex); } } @Override public synchronized void close() throws Exception { if (ownsWriter) { writer.close(); } else { writer.flush(); } } private static Writer createWriter(File file) { if (file == null) { throw new IllegalArgumentException("file is required"); } File parent = file.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs() && !parent.exists()) { throw new IllegalStateException("Failed to create trace export directory: " + parent.getAbsolutePath()); } try { return new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, true), StandardCharsets.UTF_8)); } catch (IOException ex) { throw new IllegalStateException("Failed to open trace export file: " + file.getAbsolutePath(), ex); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/LangfuseTraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import com.alibaba.fastjson2.JSON; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.Tracer; import java.util.LinkedHashMap; import java.util.Map; public class LangfuseTraceExporter extends AbstractOpenTelemetryTraceExporter { private final String environment; private final String release; public LangfuseTraceExporter(OpenTelemetry openTelemetry) { this(openTelemetry, null, null); } public LangfuseTraceExporter(OpenTelemetry openTelemetry, String environment, String release) { super(openTelemetry, "io.github.lnyocly.ai4j.agent.trace.langfuse"); this.environment = environment; this.release = release; } public LangfuseTraceExporter(Tracer tracer) { this(tracer, null, null); } public LangfuseTraceExporter(Tracer tracer, String environment, String release) { super(tracer); this.environment = environment; this.release = release; } @Override protected void customizeSpan(Span span, TraceSpan traceSpan) { Map attributes = LangfuseSpanAttributes.project(traceSpan, environment, release); for (Map.Entry entry : attributes.entrySet()) { OpenTelemetryTraceSupport.setAttribute(span, entry.getKey(), entry.getValue()); } } static final class LangfuseSpanAttributes { private LangfuseSpanAttributes() { } static Map project(TraceSpan traceSpan, String environment, String release) { Map projected = new LinkedHashMap(); if (traceSpan == null) { return projected; } if (environment != null && !environment.trim().isEmpty()) { projected.put("langfuse.environment", environment.trim()); } if (release != null && !release.trim().isEmpty()) { projected.put("langfuse.release", release.trim()); } projected.put("langfuse.observation.type", observationType(traceSpan.getType())); projected.put("langfuse.observation.level", observationLevel(traceSpan.getStatus())); if (traceSpan.getError() != null && !traceSpan.getError().trim().isEmpty()) { projected.put("langfuse.observation.status_message", traceSpan.getError().trim()); } if (traceSpan.getParentSpanId() == null && traceSpan.getName() != null) { projected.put("langfuse.trace.name", traceSpan.getName()); } if (traceSpan.getType() == TraceSpanType.MODEL) { applyModelAttributes(projected, traceSpan); } else { applyGenericObservation(projected, traceSpan); } return projected; } private static void applyModelAttributes(Map projected, TraceSpan traceSpan) { Map attributes = traceSpan.getAttributes(); String model = stringValue(attributes, "responseModel"); if (model == null) { model = stringValue(attributes, "model"); } if (model != null) { projected.put("langfuse.observation.model", model); } Map input = pick(attributes, "systemPrompt", "instructions", "items", "tools", "toolChoice", "parallelToolCalls", "temperature", "topP", "maxOutputTokens", "reasoning", "store", "stream", "user", "extraBody"); if (!input.isEmpty()) { projected.put("langfuse.observation.input", JSON.toJSONString(input)); } Object output = attributes == null ? null : firstNonNull( attributes.get("output"), attributes.get("finalOutput")); if (output != null) { projected.put("langfuse.observation.output", String.valueOf(output)); } Map modelParameters = pick(attributes, "temperature", "topP", "maxOutputTokens", "toolChoice", "parallelToolCalls", "reasoning", "store", "stream"); if (!modelParameters.isEmpty()) { projected.put("langfuse.observation.model_parameters", JSON.toJSONString(modelParameters)); } TraceMetrics metrics = traceSpan.getMetrics(); if (metrics != null) { Map usageDetails = new LinkedHashMap(); putIfPresent(usageDetails, "input", metrics.getPromptTokens()); putIfPresent(usageDetails, "output", metrics.getCompletionTokens()); putIfPresent(usageDetails, "total", metrics.getTotalTokens()); putIfPresent(usageDetails, "prompt_tokens", metrics.getPromptTokens()); putIfPresent(usageDetails, "completion_tokens", metrics.getCompletionTokens()); putIfPresent(usageDetails, "total_tokens", metrics.getTotalTokens()); if (!usageDetails.isEmpty()) { projected.put("langfuse.observation.usage_details", JSON.toJSONString(usageDetails)); } Map costDetails = new LinkedHashMap(); putIfPresent(costDetails, "input", metrics.getInputCost()); putIfPresent(costDetails, "output", metrics.getOutputCost()); putIfPresent(costDetails, "total", metrics.getTotalCost()); if (!costDetails.isEmpty()) { projected.put("langfuse.observation.cost_details", JSON.toJSONString(costDetails)); } } Map metadata = metadataAttributes(attributes, "systemPrompt", "instructions", "items", "tools", "toolChoice", "parallelToolCalls", "temperature", "topP", "maxOutputTokens", "reasoning", "store", "stream", "user", "extraBody", "output", "finalOutput", "model", "responseModel"); if (!metadata.isEmpty()) { projected.put("langfuse.observation.metadata", JSON.toJSONString(metadata)); } } private static void applyGenericObservation(Map projected, TraceSpan traceSpan) { Map attributes = traceSpan.getAttributes(); Object input = attributes == null ? null : firstNonNull(attributes.get("arguments"), attributes.get("message")); Object output = attributes == null ? null : firstNonNull( attributes.get("output"), attributes.get("result"), attributes.get("finalOutput")); if (input != null) { projected.put("langfuse.observation.input", String.valueOf(input)); } if (output != null) { projected.put("langfuse.observation.output", String.valueOf(output)); } if (traceSpan.getParentSpanId() == null && attributes != null && attributes.get("finalOutput") != null) { projected.put("langfuse.trace.output", String.valueOf(attributes.get("finalOutput"))); } if (attributes != null && !attributes.isEmpty()) { projected.put("langfuse.observation.metadata", JSON.toJSONString(attributes)); if (traceSpan.getParentSpanId() == null) { projected.put("langfuse.trace.metadata", JSON.toJSONString(attributes)); } } } private static Map metadataAttributes(Map attributes, String... excludedKeys) { Map metadata = new LinkedHashMap(); if (attributes == null || attributes.isEmpty()) { return metadata; } for (Map.Entry entry : attributes.entrySet()) { if (isExcluded(entry.getKey(), excludedKeys)) { continue; } metadata.put(entry.getKey(), entry.getValue()); } return metadata; } private static boolean isExcluded(String key, String... excludedKeys) { if (key == null || excludedKeys == null) { return false; } for (String excludedKey : excludedKeys) { if (key.equals(excludedKey)) { return true; } } return false; } private static Map pick(Map attributes, String... keys) { Map selected = new LinkedHashMap(); if (attributes == null || keys == null) { return selected; } for (String key : keys) { Object value = attributes.get(key); if (value != null) { selected.put(key, value); } } return selected; } private static Object firstNonNull(Object... values) { if (values == null) { return null; } for (Object value : values) { if (value != null) { return value; } } return null; } private static String stringValue(Map attributes, String key) { if (attributes == null || key == null) { return null; } Object value = attributes.get(key); return value == null ? null : String.valueOf(value); } private static void putIfPresent(Map target, String key, Object value) { if (target != null && key != null && value != null) { target.put(key, value); } } private static String observationType(TraceSpanType type) { if (type == null) { return "span"; } switch (type) { case RUN: case HANDOFF: case TEAM_TASK: return "agent"; case MODEL: return "generation"; case TOOL: return "tool"; case AGENT_FLOW: case FLOWGRAM_TASK: return "chain"; default: return "span"; } } private static String observationLevel(TraceSpanStatus status) { if (status == null) { return "DEFAULT"; } switch (status) { case ERROR: return "ERROR"; case CANCELED: return "WARNING"; default: return "DEFAULT"; } } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/OpenTelemetryTraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Tracer; public class OpenTelemetryTraceExporter extends AbstractOpenTelemetryTraceExporter { public OpenTelemetryTraceExporter(OpenTelemetry openTelemetry) { super(openTelemetry, "io.github.lnyocly.ai4j.agent.trace"); } public OpenTelemetryTraceExporter(Tracer tracer) { super(tracer); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/OpenTelemetryTraceSupport.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import com.alibaba.fastjson2.JSON; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; import java.util.Map; final class OpenTelemetryTraceSupport { private OpenTelemetryTraceSupport() { } static void applyCommonAttributes(Span span, TraceSpan traceSpan) { if (span == null || traceSpan == null) { return; } setAttribute(span, "ai4j.trace_id", traceSpan.getTraceId()); setAttribute(span, "ai4j.span_id", traceSpan.getSpanId()); setAttribute(span, "ai4j.parent_span_id", traceSpan.getParentSpanId()); setAttribute(span, "ai4j.span_type", traceSpan.getType() == null ? null : traceSpan.getType().name()); setAttribute(span, "ai4j.span_status", traceSpan.getStatus() == null ? null : traceSpan.getStatus().name()); setAttribute(span, "ai4j.error", traceSpan.getError()); if (traceSpan.getAttributes() != null) { for (Map.Entry entry : traceSpan.getAttributes().entrySet()) { setAttribute(span, "ai4j.attr." + entry.getKey(), entry.getValue()); } } TraceMetrics metrics = traceSpan.getMetrics(); if (metrics != null) { setAttribute(span, "ai4j.metrics.duration_ms", metrics.getDurationMillis()); setAttribute(span, "ai4j.metrics.prompt_tokens", metrics.getPromptTokens()); setAttribute(span, "ai4j.metrics.completion_tokens", metrics.getCompletionTokens()); setAttribute(span, "ai4j.metrics.total_tokens", metrics.getTotalTokens()); setAttribute(span, "ai4j.metrics.input_cost", metrics.getInputCost()); setAttribute(span, "ai4j.metrics.output_cost", metrics.getOutputCost()); setAttribute(span, "ai4j.metrics.total_cost", metrics.getTotalCost()); setAttribute(span, "ai4j.metrics.currency", metrics.getCurrency()); setAttribute(span, "gen_ai.usage.input_tokens", metrics.getPromptTokens()); setAttribute(span, "gen_ai.usage.output_tokens", metrics.getCompletionTokens()); } if (traceSpan.getEvents() != null) { for (TraceSpanEvent event : traceSpan.getEvents()) { if (event == null) { continue; } span.addEvent(event.getName() == null ? "ai4j.event" : event.getName()); if (event.getAttributes() != null) { for (Map.Entry entry : event.getAttributes().entrySet()) { setAttribute(span, "ai4j.event." + safeSegment(event.getName()) + "." + entry.getKey(), entry.getValue()); } } } } if (traceSpan.getStatus() == TraceSpanStatus.ERROR) { span.setStatus(StatusCode.ERROR, traceSpan.getError() == null ? "ai4j trace error" : traceSpan.getError()); } } static void setAttribute(Span span, String key, Object value) { if (span == null || key == null || value == null) { return; } if (value instanceof Boolean) { span.setAttribute(AttributeKey.booleanKey(key), (Boolean) value); return; } if (value instanceof Long) { span.setAttribute(AttributeKey.longKey(key), (Long) value); return; } if (value instanceof Integer) { span.setAttribute(AttributeKey.longKey(key), ((Integer) value).longValue()); return; } if (value instanceof Double) { span.setAttribute(AttributeKey.doubleKey(key), (Double) value); return; } if (value instanceof Float) { span.setAttribute(AttributeKey.doubleKey(key), ((Float) value).doubleValue()); return; } span.setAttribute(AttributeKey.stringKey(key), value instanceof String ? (String) value : JSON.toJSONString(value)); } static String safeSegment(String value) { if (value == null || value.trim().isEmpty()) { return "event"; } return value.trim().replace(' ', '_').toLowerCase(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceConfig.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class TraceConfig { @Builder.Default private boolean recordModelInput = true; @Builder.Default private boolean recordModelOutput = true; @Builder.Default private boolean recordToolArgs = true; @Builder.Default private boolean recordToolOutput = true; @Builder.Default private boolean recordMetrics = true; @Builder.Default private int maxFieldLength = 0; private TraceMasker masker; private TracePricingResolver pricingResolver; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceExporter.java ================================================ package io.github.lnyocly.ai4j.agent.trace; public interface TraceExporter { void export(TraceSpan span); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceMasker.java ================================================ package io.github.lnyocly.ai4j.agent.trace; public interface TraceMasker { String mask(String value); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceMetrics.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class TraceMetrics { private Long durationMillis; private Long promptTokens; private Long completionTokens; private Long totalTokens; private Double inputCost; private Double outputCost; private Double totalCost; private String currency; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TracePricing.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class TracePricing { private Double inputCostPerMillionTokens; private Double outputCostPerMillionTokens; private String currency; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TracePricingResolver.java ================================================ package io.github.lnyocly.ai4j.agent.trace; public interface TracePricingResolver { TracePricing resolve(String model); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpan.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import lombok.Builder; import lombok.Data; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) public class TraceSpan { private String traceId; private String spanId; private String parentSpanId; private String name; private TraceSpanType type; private TraceSpanStatus status; private long startTime; private long endTime; private String error; private Map attributes; private List events; private TraceMetrics metrics; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpanEvent.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class TraceSpanEvent { private long timestamp; private String name; private Map attributes; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpanStatus.java ================================================ package io.github.lnyocly.ai4j.agent.trace; public enum TraceSpanStatus { OK, ERROR, CANCELED } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpanType.java ================================================ package io.github.lnyocly.ai4j.agent.trace; public enum TraceSpanType { RUN, STEP, MODEL, TOOL, HANDOFF, TEAM_TASK, MEMORY, AGENT_FLOW, FLOWGRAM_TASK, FLOWGRAM_NODE } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/util/AgentInputItem.java ================================================ package io.github.lnyocly.ai4j.agent.util; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public final class AgentInputItem { private AgentInputItem() { } public static Map inputText(String text) { Map item = new LinkedHashMap<>(); item.put("type", "input_text"); item.put("text", text); return item; } public static Map inputImageUrl(String url) { Map item = new LinkedHashMap<>(); item.put("type", "input_image"); Map imageUrl = new LinkedHashMap<>(); imageUrl.put("url", url); item.put("image_url", imageUrl); return item; } public static Map userMessage(String text) { return message("user", text); } public static Map userMessage(String text, String... imageUrls) { return message("user", text, imageUrls); } public static Map systemMessage(String text) { return message("system", text); } public static Map message(String role, String text) { Map item = new LinkedHashMap<>(); item.put("type", "message"); item.put("role", role); List> content = new ArrayList<>(); content.add(inputText(text)); item.put("content", content); return item; } public static Map message(String role, String text, String... imageUrls) { Map item = new LinkedHashMap<>(); item.put("type", "message"); item.put("role", role); List> content = new ArrayList<>(); if (text != null) { content.add(inputText(text)); } if (imageUrls != null) { for (String url : imageUrls) { if (url != null && !url.trim().isEmpty()) { content.add(inputImageUrl(url)); } } } item.put("content", content); return item; } public static Map functionCallOutput(String callId, String output) { Map item = new LinkedHashMap<>(); item.put("type", "function_call_output"); item.put("call_id", callId); item.put("output", output); return item; } public static Map assistantToolCallsMessage(String text, List toolCalls) { Map item = new LinkedHashMap<>(); item.put("type", "message"); item.put("role", "assistant"); List> content = new ArrayList<>(); if (text != null && !text.isEmpty()) { content.add(inputText(text)); } if (!content.isEmpty()) { item.put("content", content); } List> serializedCalls = new ArrayList<>(); if (toolCalls != null) { for (AgentToolCall toolCall : toolCalls) { if (toolCall == null) { continue; } Map function = new LinkedHashMap<>(); function.put("name", toolCall.getName()); function.put("arguments", toolCall.getArguments()); Map serializedCall = new LinkedHashMap<>(); serializedCall.put("id", toolCall.getCallId()); serializedCall.put("type", toolCall.getType() == null || toolCall.getType().trim().isEmpty() ? "function" : toolCall.getType()); serializedCall.put("function", function); serializedCalls.add(serializedCall); } } if (!serializedCalls.isEmpty()) { item.put("tool_calls", serializedCalls); } return item; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/util/ResponseUtil.java ================================================ package io.github.lnyocly.ai4j.agent.util; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseContentPart; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseItem; import java.util.ArrayList; import java.util.List; public final class ResponseUtil { private ResponseUtil() { } public static String extractOutputText(Response response) { if (response == null || response.getOutput() == null) { return ""; } StringBuilder builder = new StringBuilder(); for (ResponseItem item : response.getOutput()) { if (item.getContent() == null) { continue; } for (ResponseContentPart part : item.getContent()) { if (part == null) { continue; } if ("output_text".equals(part.getType()) && part.getText() != null) { builder.append(part.getText()); } } } return builder.toString(); } public static List extractToolCalls(Response response) { List calls = new ArrayList<>(); if (response == null || response.getOutput() == null) { return calls; } for (ResponseItem item : response.getOutput()) { if (item == null) { continue; } if (item.getCallId() != null && item.getName() != null && item.getArguments() != null) { calls.add(AgentToolCall.builder() .callId(item.getCallId()) .name(item.getName()) .arguments(item.getArguments()) .type(item.getType()) .build()); } } return calls; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/AgentNode.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.event.AgentListener; public interface AgentNode { AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception; default void executeStream(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception { AgentResult result = execute(context, request); if (listener != null && result != null) { listener.onEvent(context.createResultEvent(result)); } } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/AgentWorkflow.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentListener; public interface AgentWorkflow { AgentResult run(AgentSession session, AgentRequest request) throws Exception; void runStream(AgentSession session, AgentRequest request, AgentListener listener) throws Exception; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/RuntimeAgentNode.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentListener; public class RuntimeAgentNode implements AgentNode, WorkflowResultAware { private final AgentSession session; private AgentResult lastResult; public RuntimeAgentNode(AgentSession session) { this.session = session; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception { lastResult = session.run(request); return lastResult; } @Override public void executeStream(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception { session.runStream(request, listener); } @Override public AgentResult getLastResult() { return lastResult; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/SequentialWorkflow.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentListener; import java.util.ArrayList; import java.util.List; public class SequentialWorkflow implements AgentWorkflow { private final List nodes = new ArrayList<>(); public SequentialWorkflow addNode(AgentNode node) { if (node != null) { nodes.add(node); } return this; } public List getNodes() { return new ArrayList<>(nodes); } @Override public AgentResult run(AgentSession session, AgentRequest request) throws Exception { WorkflowContext context = WorkflowContext.builder().session(session).build(); return executeNodes(context, request, null); } @Override public void runStream(AgentSession session, AgentRequest request, AgentListener listener) throws Exception { WorkflowContext context = WorkflowContext.builder().session(session).build(); executeNodes(context, request, listener); } private AgentResult executeNodes(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception { AgentRequest current = request; AgentResult lastResult = null; for (AgentNode node : nodes) { if (listener == null) { lastResult = node.execute(context, current); } else { node.executeStream(context, current, listener); if (node instanceof WorkflowResultAware) { lastResult = ((WorkflowResultAware) node).getLastResult(); } } if (lastResult != null && lastResult.getOutputText() != null) { current = AgentRequest.builder().input(lastResult.getOutputText()).build(); } } return lastResult; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateCondition.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; public interface StateCondition { boolean matches(WorkflowContext context, AgentRequest request, AgentResult result); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateGraphWorkflow.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentListener; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class StateGraphWorkflow implements AgentWorkflow { private final Map nodes = new LinkedHashMap<>(); private final List transitions = new ArrayList<>(); private final List conditionalEdges = new ArrayList<>(); private String startNodeId; private int maxSteps = 32; public StateGraphWorkflow addNode(String nodeId, AgentNode node) { if (nodeId == null || nodeId.trim().isEmpty() || node == null) { return this; } nodes.put(nodeId, node); return this; } public StateGraphWorkflow start(String nodeId) { this.startNodeId = nodeId; return this; } public StateGraphWorkflow maxSteps(int maxSteps) { if (maxSteps > 0) { this.maxSteps = maxSteps; } return this; } public StateGraphWorkflow addTransition(String from, String to) { return addTransition(from, to, null); } public StateGraphWorkflow addTransition(String from, String to, StateCondition condition) { transitions.add(StateTransition.builder() .from(from) .to(to) .condition(condition) .build()); return this; } public StateGraphWorkflow addEdge(String from, String to) { return addTransition(from, to, null); } public StateGraphWorkflow addConditionalEdges(String from, StateRouter router) { return addConditionalEdges(from, router, null); } public StateGraphWorkflow addConditionalEdges(String from, StateRouter router, Map routeMap) { if (from == null || from.trim().isEmpty() || router == null) { return this; } ConditionalEdges edges = new ConditionalEdges(); edges.from = from; edges.router = router; edges.routeMap = routeMap == null ? null : new LinkedHashMap<>(routeMap); conditionalEdges.add(edges); return this; } @Override public AgentResult run(AgentSession session, AgentRequest request) throws Exception { WorkflowContext context = WorkflowContext.builder().session(session).build(); return executeGraph(context, request, null); } @Override public void runStream(AgentSession session, AgentRequest request, AgentListener listener) throws Exception { WorkflowContext context = WorkflowContext.builder().session(session).build(); executeGraph(context, request, listener); } private AgentResult executeGraph(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception { if (startNodeId == null || startNodeId.trim().isEmpty()) { throw new IllegalStateException("start node is required"); } String currentNodeId = startNodeId; AgentRequest currentRequest = request; AgentResult lastResult = null; int steps = 0; while (currentNodeId != null && steps < maxSteps) { AgentNode node = nodes.get(currentNodeId); if (node == null) { throw new IllegalStateException("node not found: " + currentNodeId); } context.put("currentNodeId", currentNodeId); context.put("currentRequest", currentRequest); if (listener == null) { lastResult = node.execute(context, currentRequest); } else { node.executeStream(context, currentRequest, listener); if (node instanceof WorkflowResultAware) { lastResult = ((WorkflowResultAware) node).getLastResult(); } } context.put("lastResult", lastResult); context.put("lastNodeId", currentNodeId); if (lastResult != null && lastResult.getOutputText() != null) { currentRequest = AgentRequest.builder().input(lastResult.getOutputText()).build(); } currentNodeId = resolveNext(currentNodeId, context, currentRequest, lastResult); steps += 1; } return lastResult; } private String resolveNext(String currentNodeId, WorkflowContext context, AgentRequest request, AgentResult result) { for (ConditionalEdges edges : conditionalEdges) { if (!currentNodeId.equals(edges.from)) { continue; } String route = edges.router == null ? null : edges.router.route(context, request, result); if (route == null) { continue; } if (edges.routeMap != null) { route = edges.routeMap.get(route); } if (route != null) { return route; } } for (StateTransition transition : transitions) { if (!currentNodeId.equals(transition.getFrom())) { continue; } StateCondition condition = transition.getCondition(); if (condition == null || condition.matches(context, request, result)) { return transition.getTo(); } } return null; } private static class ConditionalEdges { private String from; private StateRouter router; private Map routeMap; } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateRouter.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; public interface StateRouter { String route(WorkflowContext context, AgentRequest request, AgentResult result); } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateTransition.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class StateTransition { private String from; private String to; private StateCondition condition; } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/WorkflowAgent.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentListener; public class WorkflowAgent { private final AgentWorkflow workflow; private final AgentSession session; public WorkflowAgent(AgentWorkflow workflow, AgentSession session) { this.workflow = workflow; this.session = session; } public AgentResult run(AgentRequest request) throws Exception { return workflow.run(session, request); } public void runStream(AgentRequest request, AgentListener listener) throws Exception { workflow.runStream(session, request, listener); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/WorkflowContext.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventPublisher; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import lombok.Builder; import lombok.Data; import java.util.HashMap; import java.util.Map; @Data @Builder public class WorkflowContext { private AgentSession session; @Builder.Default private Map state = new HashMap<>(); private AgentEventPublisher eventPublisher; public void put(String key, Object value) { state.put(key, value); } public Object get(String key) { return state.get(key); } public AgentEvent createResultEvent(AgentResult result) { return AgentEvent.builder() .type(AgentEventType.FINAL_OUTPUT) .message(result == null ? null : result.getOutputText()) .payload(result) .build(); } } ================================================ FILE: ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/WorkflowResultAware.java ================================================ package io.github.lnyocly.ai4j.agent.workflow; import io.github.lnyocly.ai4j.agent.AgentResult; public interface WorkflowResultAware { AgentResult getLastResult(); } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentFlowTraceBridgeTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.trace.AgentFlowTraceBridge; import io.github.lnyocly.ai4j.agent.trace.InMemoryTraceExporter; import io.github.lnyocly.ai4j.agent.trace.TraceConfig; import io.github.lnyocly.ai4j.agent.trace.TraceSpan; import io.github.lnyocly.ai4j.agent.trace.TraceSpanStatus; import io.github.lnyocly.ai4j.agent.trace.TraceSpanType; import io.github.lnyocly.ai4j.agentflow.AgentFlowType; import io.github.lnyocly.ai4j.agentflow.AgentFlowUsage; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest; import io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse; import io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext; import org.junit.Assert; import org.junit.Test; import java.util.List; public class AgentFlowTraceBridgeTest { @Test public void test_bridge_exports_agentflow_chat_span() { InMemoryTraceExporter exporter = new InMemoryTraceExporter(); AgentFlowTraceBridge bridge = new AgentFlowTraceBridge(exporter, TraceConfig.builder().build()); AgentFlowTraceContext context = AgentFlowTraceContext.builder() .executionId("exec-1") .type(AgentFlowType.DIFY) .operation("chat") .streaming(true) .startedAt(System.currentTimeMillis()) .baseUrl("https://api.dify.example") .request(AgentFlowChatRequest.builder() .prompt("plan my trip") .build()) .build(); bridge.onStart(context); bridge.onEvent(context, AgentFlowChatEvent.builder() .type("message") .contentDelta("hello") .conversationId("conv-1") .messageId("msg-1") .taskId("task-1") .usage(AgentFlowUsage.builder() .inputTokens(Integer.valueOf(11)) .outputTokens(Integer.valueOf(7)) .totalTokens(Integer.valueOf(18)) .build()) .build()); bridge.onComplete(context, AgentFlowChatResponse.builder() .content("hello world") .conversationId("conv-1") .messageId("msg-1") .taskId("task-1") .usage(AgentFlowUsage.builder() .inputTokens(Integer.valueOf(11)) .outputTokens(Integer.valueOf(7)) .totalTokens(Integer.valueOf(18)) .build()) .build()); List spans = exporter.getSpans(); Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(spans.size())); TraceSpan span = spans.get(0); Assert.assertEquals(TraceSpanType.AGENT_FLOW, span.getType()); Assert.assertEquals(TraceSpanStatus.OK, span.getStatus()); Assert.assertEquals("agentflow.chat", span.getName()); Assert.assertEquals("DIFY", span.getAttributes().get("providerType")); Assert.assertEquals("plan my trip", span.getAttributes().get("message")); Assert.assertEquals("hello world", span.getAttributes().get("output")); Assert.assertEquals("task-1", span.getAttributes().get("taskId")); Assert.assertNotNull(span.getMetrics()); Assert.assertEquals(Long.valueOf(18L), span.getMetrics().getTotalTokens()); Assert.assertTrue(span.getEvents().stream().anyMatch(event -> "agentflow.chat.event".equals(event.getName()))); } @Test public void test_bridge_exports_error_span() { InMemoryTraceExporter exporter = new InMemoryTraceExporter(); AgentFlowTraceBridge bridge = new AgentFlowTraceBridge(exporter); AgentFlowTraceContext context = AgentFlowTraceContext.builder() .executionId("exec-2") .type(AgentFlowType.N8N) .operation("workflow") .streaming(false) .startedAt(System.currentTimeMillis()) .webhookUrl("https://n8n.example/webhook/demo") .build(); bridge.onStart(context); bridge.onError(context, new IllegalStateException("workflow failed")); TraceSpan span = exporter.getSpans().get(0); Assert.assertEquals(TraceSpanStatus.ERROR, span.getStatus()); Assert.assertEquals("workflow failed", span.getError()); Assert.assertEquals("workflow", span.getAttributes().get("operation")); Assert.assertEquals("https://n8n.example/webhook/demo", span.getAttributes().get("webhookUrl")); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentMemoryTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; import io.github.lnyocly.ai4j.agent.memory.WindowedMemoryCompressor; import org.junit.Assert; import org.junit.Test; import java.util.List; public class AgentMemoryTest { @Test public void test_windowed_compression() { InMemoryAgentMemory memory = new InMemoryAgentMemory(new WindowedMemoryCompressor(2)); memory.addUserInput("one"); memory.addUserInput("two"); memory.addUserInput("three"); List items = memory.getItems(); Assert.assertEquals(2, items.size()); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentRuntimeTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.runtime.ReActRuntime; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.junit.Assert; import org.junit.Test; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.List; import java.util.stream.Collectors; public class AgentRuntimeTest { @Test public void test_tool_loop_and_output() throws Exception { Deque queue = new ArrayDeque<>(); queue.add(resultWithToolCall("call_1", "echo", "{}")); queue.add(resultWithText("done")); AgentModelClient modelClient = new QueueModelClient(queue); CountingToolExecutor toolExecutor = new CountingToolExecutor(); AgentContext context = AgentContext.builder() .modelClient(modelClient) .toolExecutor(toolExecutor) .memory(new InMemoryAgentMemory()) .options(AgentOptions.builder().maxSteps(4).build()) .model("test-model") .build(); AgentResult result = new ReActRuntime().run(context, AgentRequest.builder().input("hi").build()); Assert.assertEquals("done", result.getOutputText()); Assert.assertEquals(1, toolExecutor.count); Assert.assertEquals(1, result.getToolCalls().size()); AgentToolCall call = result.getToolCalls().get(0); Assert.assertEquals("echo", call.getName()); Assert.assertEquals("call_1", call.getCallId()); } @Test public void test_stream_run_publishes_reasoning_and_text_before_tool_call() throws Exception { AgentModelClient modelClient = new AgentModelClient() { private int invocation; @Override public AgentModelResult create(AgentPrompt prompt) { throw new UnsupportedOperationException("stream path only"); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { invocation++; if (invocation == 1) { return AgentModelResult.builder() .reasoningText("Need to inspect the tool output first.") .outputText("I will run the tool now.") .toolCalls(Arrays.asList(AgentToolCall.builder() .callId("call_1") .name("echo") .arguments("{}") .type("function_call") .build())) .memoryItems(new ArrayList()) .build(); } return resultWithText("done"); } }; AgentContext context = AgentContext.builder() .modelClient(modelClient) .toolExecutor(new CountingToolExecutor()) .memory(new InMemoryAgentMemory()) .options(AgentOptions.builder().stream(true).maxSteps(4).build()) .model("test-model") .build(); List eventTypes = new ArrayList<>(); List eventMessages = new ArrayList<>(); AgentListener listener = new AgentListener() { @Override public void onEvent(AgentEvent event) { if (event == null || event.getType() == null) { return; } eventTypes.add(event.getType()); eventMessages.add(event.getMessage()); } }; new ReActRuntime().runStream(context, AgentRequest.builder().input("hi").build(), listener); int reasoningIndex = eventTypes.indexOf(AgentEventType.MODEL_REASONING); int responseIndex = eventTypes.indexOf(AgentEventType.MODEL_RESPONSE); int toolCallIndex = eventTypes.indexOf(AgentEventType.TOOL_CALL); Assert.assertTrue("missing reasoning event: " + eventTypes, reasoningIndex >= 0); Assert.assertTrue("missing response event: " + eventTypes, responseIndex >= 0); Assert.assertTrue("missing tool call event: " + eventTypes, toolCallIndex >= 0); Assert.assertTrue("reasoning should arrive before tool call: " + eventTypes, reasoningIndex < toolCallIndex); Assert.assertTrue("text should arrive before tool call: " + eventTypes, responseIndex < toolCallIndex); Assert.assertTrue(eventMessages.stream().filter(msg -> msg != null).collect(Collectors.toList()) .contains("Need to inspect the tool output first.")); Assert.assertTrue(eventMessages.stream().filter(msg -> msg != null).collect(Collectors.toList()) .contains("I will run the tool now.")); } @Test public void test_invalid_tool_call_is_reported_without_execution() throws Exception { Deque queue = new ArrayDeque<>(); queue.add(AgentModelResult.builder() .reasoningText("Need to inspect the shell call.") .toolCalls(Arrays.asList(AgentToolCall.builder() .name("bash") .arguments("{\"action\":\"exec\"}") .build())) .memoryItems(new ArrayList()) .build()); queue.add(resultWithText("done")); CountingToolExecutor toolExecutor = new CountingToolExecutor(); AgentContext context = AgentContext.builder() .modelClient(new QueueModelClient(queue)) .toolExecutor(toolExecutor) .memory(new InMemoryAgentMemory()) .options(AgentOptions.builder().stream(true).maxSteps(4).build()) .model("test-model") .build(); List events = new ArrayList(); AgentListener listener = new AgentListener() { @Override public void onEvent(AgentEvent event) { if (event != null) { events.add(event); } } }; new ReActRuntime().runStream(context, AgentRequest.builder().input("hi").build(), listener); Assert.assertEquals(0, toolExecutor.count); Assert.assertTrue(events.stream().anyMatch(event -> event.getType() == AgentEventType.TOOL_CALL)); Assert.assertTrue(events.stream().anyMatch(event -> { if (event.getType() != AgentEventType.TOOL_RESULT || !(event.getPayload() instanceof io.github.lnyocly.ai4j.agent.tool.AgentToolResult)) { return false; } io.github.lnyocly.ai4j.agent.tool.AgentToolResult result = (io.github.lnyocly.ai4j.agent.tool.AgentToolResult) event.getPayload(); return result.getOutput() != null && result.getOutput().contains("bash exec requires a non-empty command"); })); } @Test public void test_stream_run_publishes_model_retry_event() throws Exception { AgentModelClient modelClient = new AgentModelClient() { @Override public AgentModelResult create(AgentPrompt prompt) { throw new UnsupportedOperationException("stream path only"); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { listener.onRetry("Timed out waiting for first model stream event after 30000 ms", 2, 3, new RuntimeException("Timed out waiting for first model stream event after 30000 ms")); return resultWithText("done"); } }; AgentContext context = AgentContext.builder() .modelClient(modelClient) .toolExecutor(new CountingToolExecutor()) .memory(new InMemoryAgentMemory()) .options(AgentOptions.builder().stream(true).maxSteps(4).build()) .model("test-model") .build(); List events = new ArrayList(); AgentListener listener = new AgentListener() { @Override public void onEvent(AgentEvent event) { if (event != null) { events.add(event); } } }; new ReActRuntime().runStream(context, AgentRequest.builder().input("hi").build(), listener); Assert.assertTrue(events.stream().anyMatch(event -> event.getType() == AgentEventType.MODEL_RETRY)); Assert.assertTrue(events.stream().anyMatch(event -> event.getType() == AgentEventType.MODEL_RETRY && event.getMessage() != null && event.getMessage().contains("Timed out waiting for first model stream event"))); } @Test public void test_zero_max_steps_means_unlimited() throws Exception { Deque queue = new ArrayDeque<>(); queue.add(resultWithToolCall("call_1", "echo", "{}")); queue.add(resultWithToolCall("call_2", "echo", "{}")); queue.add(resultWithText("done")); CountingToolExecutor toolExecutor = new CountingToolExecutor(); AgentContext context = AgentContext.builder() .modelClient(new QueueModelClient(queue)) .toolExecutor(toolExecutor) .memory(new InMemoryAgentMemory()) .options(AgentOptions.builder().maxSteps(0).build()) .model("test-model") .build(); AgentResult result = new ReActRuntime().run(context, AgentRequest.builder().input("hi").build()); Assert.assertEquals("done", result.getOutputText()); Assert.assertEquals(2, toolExecutor.count); Assert.assertEquals(2, result.getToolCalls().size()); } private AgentModelResult resultWithText(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(new ArrayList()) .toolCalls(new ArrayList()) .build(); } private AgentModelResult resultWithToolCall(String callId, String name, String arguments) { List calls = Arrays.asList(AgentToolCall.builder() .callId(callId) .name(name) .arguments(arguments) .type("function_call") .build()); return AgentModelResult.builder() .toolCalls(calls) .memoryItems(new ArrayList()) .build(); } private static class QueueModelClient implements AgentModelClient { private final Deque queue; private QueueModelClient(Deque queue) { this.queue = queue; } @Override public AgentModelResult create(AgentPrompt prompt) { return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } } private static class CountingToolExecutor implements ToolExecutor { private int count = 0; @Override public String execute(AgentToolCall call) { count += 1; return "{\"ok\":true}"; } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamAgentAdapterTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import org.junit.Assert; import org.junit.Test; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.List; public class AgentTeamAgentAdapterTest { @Test public void shouldBuildTeamAsStandardAgentAndRun() throws Exception { ScriptedModelClient memberClient = new ScriptedModelClient(); memberClient.enqueue(textResult("requirements-collected")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("team-final-answer")); Agent teamAgent = Agents.teamAgent(Agents.team() .planner((objective, members, options) -> AgentTeamPlan.builder() .tasks(Arrays.asList( AgentTeamTask.builder() .id("collect") .memberId("researcher") .task("Collect requirements") .build() )) .build()) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder() .id("researcher") .name("Researcher") .agent(newAgent("member", memberClient)) .build())); AgentResult result = teamAgent.run(AgentRequest.builder().input("prepare plan").build()); Assert.assertEquals("team-final-answer", result.getOutputText()); Assert.assertTrue(result.getRawResponse() instanceof io.github.lnyocly.ai4j.agent.team.AgentTeamResult); } @Test public void shouldEmitTeamTaskEventsWhenRunningStream() throws Exception { ScriptedModelClient memberClient = new ScriptedModelClient(); memberClient.enqueue(textResult("collected")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("synthesized")); Agent teamAgent = Agents.team() .planner((objective, members, options) -> AgentTeamPlan.builder() .tasks(Arrays.asList( AgentTeamTask.builder() .id("collect") .memberId("researcher") .task("Collect requirements") .build() )) .build()) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder() .id("researcher") .name("Researcher") .agent(newAgent("member", memberClient)) .build()) .buildAgent(); final List events = new ArrayList(); teamAgent.runStream(AgentRequest.builder().input("run team").build(), new AgentListener() { @Override public void onEvent(AgentEvent event) { events.add(event); } }); Assert.assertNotNull(firstEvent(events, AgentEventType.TEAM_TASK_CREATED)); Assert.assertNotNull(firstEvent(events, AgentEventType.TEAM_TASK_UPDATED)); AgentEvent finalEvent = firstEvent(events, AgentEventType.FINAL_OUTPUT); Assert.assertNotNull(finalEvent); Assert.assertEquals("synthesized", finalEvent.getMessage()); } @Test public void shouldPreserveTeamPersistenceSettingsWhenBuildingStandardAgent() throws Exception { Path storageRoot = Files.createTempDirectory("agent-team-build-agent-persistence"); ScriptedModelClient memberClient = new ScriptedModelClient(); memberClient.enqueue(textResult("delivery-complete")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("persisted-summary")); Agent teamAgent = Agents.team() .teamId("delivery-team") .storageDirectory(storageRoot) .planner((objective, members, options) -> AgentTeamPlan.builder() .tasks(Arrays.asList( AgentTeamTask.builder() .id("deliver") .memberId("builder") .task("Deliver travel package") .build() )) .build()) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder() .id("builder") .name("Builder") .agent(newAgent("member", memberClient)) .build()) .buildAgent(); AgentResult result = teamAgent.run(AgentRequest.builder().input("run delivery").build()); Assert.assertEquals("persisted-summary", result.getOutputText()); AgentTeamState restored = Agents.team() .teamId("delivery-team") .storageDirectory(storageRoot) .planner((objective, members, options) -> AgentTeamPlan.builder().tasks(new ArrayList()).build()) .synthesizerAgent(newAgent("noop-synth", new ScriptedModelClient())) .member(AgentTeamMember.builder() .id("builder") .name("Builder") .agent(newAgent("noop-member", new ScriptedModelClient())) .build()) .build() .loadPersistedState(); Assert.assertNotNull(restored); Assert.assertEquals("delivery-team", restored.getTeamId()); Assert.assertEquals("persisted-summary", restored.getLastOutput()); Assert.assertEquals(1, restored.getTaskStates().size()); } private static AgentEvent firstEvent(List events, AgentEventType type) { if (events == null || type == null) { return null; } for (AgentEvent event : events) { if (event != null && type == event.getType()) { return event; } } return null; } private static Agent newAgent(String model, AgentModelClient client) { return Agents.react() .modelClient(client) .model(model) .build(); } private static AgentModelResult textResult(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(new ArrayList()) .toolCalls(new ArrayList()) .build(); } private static class ScriptedModelClient implements AgentModelClient { private final Deque queue = new ArrayDeque(); private void enqueue(AgentModelResult result) { queue.add(result); } @Override public AgentModelResult create(AgentPrompt prompt) { return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamPersistenceTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.List; public class AgentTeamPersistenceTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldPersistAndRestoreTeamStateFromStorageDirectory() throws Exception { Path root = temporaryFolder.newFolder("agent-team-storage").toPath(); AgentTeam firstTeam = buildTeam(root, "travel-team"); AgentTeamResult result = firstTeam.run("prepare travel delivery package"); Assert.assertEquals("team synthesis", result.getOutput()); Assert.assertEquals("travel-team", result.getTeamId()); Assert.assertEquals(1, result.getTaskStates().size()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(0).getStatus()); AgentTeamState snapshot = firstTeam.snapshotState(); Assert.assertNotNull(snapshot); Assert.assertEquals("travel-team", snapshot.getTeamId()); Assert.assertTrue(snapshot.getMessages().size() >= 2); AgentTeam restoredTeam = buildTeam(root, "travel-team"); AgentTeamState restored = restoredTeam.loadPersistedState(); Assert.assertNotNull(restored); Assert.assertEquals("travel-team", restored.getTeamId()); Assert.assertEquals(1, restoredTeam.listTaskStates().size()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, restoredTeam.listTaskStates().get(0).getStatus()); Assert.assertTrue(restoredTeam.listMessages().size() >= 2); Assert.assertEquals("team synthesis", restored.getLastOutput()); Assert.assertTrue(restoredTeam.clearPersistedState()); Assert.assertTrue(restoredTeam.listMessages().isEmpty()); Assert.assertTrue(restoredTeam.listTaskStates().isEmpty()); AgentTeam afterClear = buildTeam(root, "travel-team"); Assert.assertNull(afterClear.loadPersistedState()); } private AgentTeam buildTeam(Path root, String teamId) { ScriptedModelClient memberClient = new ScriptedModelClient(); memberClient.enqueue(textResult("backend and frontend delivery ready")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("team synthesis")); return Agents.team() .teamId(teamId) .storageDirectory(root) .planner((objective, members, options) -> AgentTeamPlan.builder() .rawPlanText("fixed") .tasks(Arrays.asList( AgentTeamTask.builder() .id("delivery") .memberId("builder") .task("Produce delivery package") .build() )) .build()) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder() .id("builder") .name("Builder") .description("Produces delivery output") .agent(newAgent("member", memberClient)) .build()) .build(); } private Agent newAgent(String model, AgentModelClient client) { return Agents.react() .modelClient(client) .model(model) .build(); } private AgentModelResult textResult(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(new ArrayList()) .toolCalls(new ArrayList()) .build(); } private static class ScriptedModelClient implements AgentModelClient { private final Deque queue = new ArrayDeque(); private void enqueue(AgentModelResult result) { queue.add(result); } @Override public AgentModelResult create(AgentPrompt prompt) { return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamProjectDeliveryExampleTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlanner; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamSynthesizer; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import org.junit.Assert; import org.junit.Test; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * End-to-end AgentTeam example for a software delivery team. * * Team roles: architect, backend, frontend, QA, ops. */ public class AgentTeamProjectDeliveryExampleTest { @Test public void test_project_delivery_team_example() throws Exception { ScriptedModelClient architectClient = new ScriptedModelClient(); architectClient.enqueue(textResult( "Architecture: modular monolith + REST API + MySQL + Redis + JWT + CI/CD checkpoints.")); ScriptedModelClient backendClient = new ScriptedModelClient(); backendClient.enqueue(toolCallResult(Arrays.asList( toolCall("team_send_message", "{\"toMemberId\":\"frontend\",\"type\":\"api.contract\",\"content\":\"Draft API: GET/POST /api/tasks, GET /api/tasks/{id}, PATCH /api/tasks/{id}\"}") ))); backendClient.enqueue(textResult( "Backend: Spring Boot modules, DTO validation, OpenAPI spec, and migration scripts are ready.")); ScriptedModelClient frontendClient = new ScriptedModelClient(); frontendClient.enqueue(toolCallResult(Arrays.asList( toolCall("team_send_message", "{\"toMemberId\":\"backend\",\"type\":\"ui.question\",\"content\":\"Please confirm pagination params page,size and sort format.\"}") ))); frontendClient.enqueue(textResult( "Frontend: React pages for task list/detail/edit, API client integration, and error-state UI done.")); ScriptedModelClient qaClient = new ScriptedModelClient(); qaClient.enqueue(toolCallResult(Arrays.asList( toolCall("team_list_tasks", "{}"), toolCall("team_broadcast", "{\"type\":\"qa.sync\",\"content\":\"Integration testing started. Please freeze API changes.\"}") ))); qaClient.enqueue(textResult( "QA: smoke/regression cases pass; one minor UI edge case documented with workaround.")); ScriptedModelClient opsClient = new ScriptedModelClient(); opsClient.enqueue(textResult( "Ops: Docker image, staging deployment, health checks, dashboards, and rollback playbook prepared.")); AgentTeam team = Agents.team() .planner(projectPlanner()) .synthesizer(projectSynthesizer()) .member(member("architect", "Architect", "system design and API boundaries", architectClient)) .member(member("backend", "Backend", "service implementation and persistence", backendClient)) .member(member("frontend", "Frontend", "UI implementation and API integration", frontendClient)) .member(member("qa", "QA", "test strategy and release quality gate", qaClient)) .member(member("ops", "Ops", "deployment, observability, and production readiness", opsClient)) .options(AgentTeamOptions.builder() .parallelDispatch(true) .maxConcurrency(3) .enableMessageBus(true) .includeMessageHistoryInDispatch(true) .enableMemberTeamTools(true) .build()) .build(); AgentTeamResult result = team.run(AgentRequest.builder() .input("Deliver a production-ready task management web app in one sprint.") .build()); printResult(result); Assert.assertNotNull(result); Assert.assertNotNull(result.getPlan()); Assert.assertEquals(5, result.getPlan().getTasks().size()); Assert.assertEquals(5, result.getMemberResults().size()); Assert.assertTrue(result.getOutput().contains("Project Delivery Summary")); Assert.assertTrue(result.getOutput().contains("[Architect]")); Assert.assertTrue(result.getOutput().contains("[Backend]")); Assert.assertTrue(result.getOutput().contains("[Frontend]")); Assert.assertTrue(result.getOutput().contains("[QA]")); Assert.assertTrue(result.getOutput().contains("[Ops]")); for (AgentTeamTaskState state : result.getTaskStates()) { Assert.assertEquals("task must complete: " + state.getTaskId(), AgentTeamTaskStatus.COMPLETED, state.getStatus()); } Assert.assertTrue(hasMessage(result.getMessages(), "backend", "frontend", "api.contract")); Assert.assertTrue(hasMessage(result.getMessages(), "frontend", "backend", "ui.question")); Assert.assertTrue(hasMessage(result.getMessages(), "qa", "*", "qa.sync")); } private static AgentTeamPlanner projectPlanner() { return (objective, members, options) -> AgentTeamPlan.builder() .rawPlanText("fixed-project-delivery-plan") .tasks(Arrays.asList( AgentTeamTask.builder() .id("architecture") .memberId("architect") .task("Design the target architecture and define API boundaries.") .context("Output architecture notes + API contracts") .build(), AgentTeamTask.builder() .id("backend_impl") .memberId("backend") .task("Implement backend services and persistence schema.") .dependsOn(Arrays.asList("architecture")) .build(), AgentTeamTask.builder() .id("frontend_impl") .memberId("frontend") .task("Implement frontend pages and integrate backend APIs.") .dependsOn(Arrays.asList("architecture")) .build(), AgentTeamTask.builder() .id("qa_validation") .memberId("qa") .task("Run integration/regression tests and publish quality report.") .dependsOn(Arrays.asList("backend_impl", "frontend_impl")) .build(), AgentTeamTask.builder() .id("ops_release") .memberId("ops") .task("Prepare release pipeline, monitoring, and rollback plan.") .dependsOn(Arrays.asList("backend_impl", "frontend_impl")) .build() )) .build(); } private static AgentTeamSynthesizer projectSynthesizer() { return (objective, plan, memberResults, options) -> { Map byMember = new LinkedHashMap<>(); if (memberResults != null) { for (AgentTeamMemberResult item : memberResults) { if (item == null || item.getMemberId() == null) { continue; } byMember.put(item.getMemberId(), item.isSuccess() ? safe(item.getOutput()) : "FAILED: " + safe(item.getError())); } } StringBuilder sb = new StringBuilder(); sb.append("Project Delivery Summary\n"); sb.append("Objective: ").append(safe(objective)).append("\n\n"); sb.append("[Architect]\n").append(safe(byMember.get("architect"))).append("\n\n"); sb.append("[Backend]\n").append(safe(byMember.get("backend"))).append("\n\n"); sb.append("[Frontend]\n").append(safe(byMember.get("frontend"))).append("\n\n"); sb.append("[QA]\n").append(safe(byMember.get("qa"))).append("\n\n"); sb.append("[Ops]\n").append(safe(byMember.get("ops"))).append("\n"); return AgentResult.builder().outputText(sb.toString()).build(); }; } private static AgentTeamMember member(String id, String name, String description, AgentModelClient modelClient) { return AgentTeamMember.builder() .id(id) .name(name) .description(description) .agent(Agents.react().model(modelClient.getClass().getSimpleName()).modelClient(modelClient).build()) .build(); } private static boolean hasMessage(List messages, String from, String to, String type) { if (messages == null || messages.isEmpty()) { return false; } for (AgentTeamMessage msg : messages) { if (msg == null) { continue; } if (equals(msg.getFromMemberId(), from) && equals(msg.getToMemberId(), to) && equals(msg.getType(), type)) { return true; } } return false; } private static boolean equals(String left, String right) { if (left == null) { return right == null; } return left.equals(right); } private static String safe(String value) { return value == null ? "" : value; } private static AgentModelResult textResult(String text) { return AgentModelResult.builder() .outputText(text) .toolCalls(new ArrayList()) .memoryItems(new ArrayList()) .build(); } private static AgentModelResult toolCallResult(List calls) { return AgentModelResult.builder() .outputText("") .toolCalls(calls) .memoryItems(new ArrayList()) .build(); } private static AgentToolCall toolCall(String name, String arguments) { return AgentToolCall.builder() .name(name) .arguments(arguments) .type("function") .callId(name + "_" + System.nanoTime()) .build(); } private static void printResult(AgentTeamResult result) { System.out.println("==== TEAM PLAN ===="); if (result != null && result.getPlan() != null && result.getPlan().getTasks() != null) { for (AgentTeamTask task : result.getPlan().getTasks()) { System.out.println(task.getId() + " -> " + task.getMemberId() + " | dependsOn=" + task.getDependsOn()); } } System.out.println("==== TASK STATES ===="); if (result != null && result.getTaskStates() != null) { for (AgentTeamTaskState state : result.getTaskStates()) { System.out.println(state.getTaskId() + " => " + state.getStatus() + " by " + state.getClaimedBy()); } } System.out.println("==== TEAM MESSAGES ===="); if (result != null && result.getMessages() != null) { for (AgentTeamMessage message : result.getMessages()) { System.out.println("[" + message.getType() + "] " + message.getFromMemberId() + " -> " + message.getToMemberId() + " (task=" + message.getTaskId() + "): " + message.getContent()); } } System.out.println("==== FINAL OUTPUT ===="); System.out.println(result == null ? "" : result.getOutput()); } private static class ScriptedModelClient implements AgentModelClient { private final Deque queue = new ArrayDeque<>(); void enqueue(AgentModelResult result) { queue.add(result); } @Override public AgentModelResult create(AgentPrompt prompt) { return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in this test"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamTaskBoardTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskBoard; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class AgentTeamTaskBoardTest { @Test public void test_claim_release_reassign_and_dependency_flow() { List tasks = new ArrayList<>(); tasks.add(AgentTeamTask.builder().id("collect").memberId("m1").task("collect facts").build()); tasks.add(AgentTeamTask.builder().id("format").memberId("m2").task("format final answer").dependsOn(Arrays.asList("collect")).build()); AgentTeamTaskBoard board = new AgentTeamTaskBoard(tasks); List ready = board.nextReadyTasks(10); Assert.assertEquals(1, ready.size()); Assert.assertEquals("collect", ready.get(0).getTaskId()); Assert.assertTrue(board.claimTask("collect", "m1")); Assert.assertEquals(AgentTeamTaskStatus.IN_PROGRESS, board.getTaskState("collect").getStatus()); Assert.assertEquals("m1", board.getTaskState("collect").getClaimedBy()); Assert.assertEquals("running", board.getTaskState("collect").getPhase()); Assert.assertEquals(Integer.valueOf(15), board.getTaskState("collect").getPercent()); Assert.assertTrue(board.reassignTask("collect", "m1", "m2")); Assert.assertEquals("m2", board.getTaskState("collect").getClaimedBy()); Assert.assertEquals("reassigned", board.getTaskState("collect").getPhase()); Assert.assertTrue(board.releaseTask("collect", "m2", "handoff")); Assert.assertEquals(AgentTeamTaskStatus.READY, board.getTaskState("collect").getStatus()); Assert.assertEquals("ready", board.getTaskState("collect").getPhase()); Assert.assertTrue(board.claimTask("collect", "m2")); Assert.assertTrue(board.heartbeatTask("collect", "m2")); Assert.assertEquals("heartbeat", board.getTaskState("collect").getPhase()); Assert.assertEquals(1, board.getTaskState("collect").getHeartbeatCount()); board.markCompleted("collect", "facts", 12L); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, board.getTaskState("collect").getStatus()); Assert.assertEquals(Integer.valueOf(100), board.getTaskState("collect").getPercent()); Assert.assertEquals(AgentTeamTaskStatus.READY, board.getTaskState("format").getStatus()); Assert.assertTrue(board.claimTask("format", "m2")); board.markCompleted("format", "final", 8L); Assert.assertFalse(board.hasWorkRemaining()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, board.getTaskState("format").getStatus()); } @Test public void test_recover_timed_out_claims() throws Exception { List tasks = new ArrayList<>(); tasks.add(AgentTeamTask.builder().id("t1").memberId("m1").task("run").build()); AgentTeamTaskBoard board = new AgentTeamTaskBoard(tasks); Assert.assertTrue(board.claimTask("t1", "m1")); Thread.sleep(5L); int recovered = board.recoverTimedOutClaims(1L, "timeout"); Assert.assertEquals(1, recovered); Assert.assertEquals(AgentTeamTaskStatus.READY, board.getTaskState("t1").getStatus()); Assert.assertNull(board.getTaskState("t1").getClaimedBy()); Assert.assertEquals("ready", board.getTaskState("t1").getPhase()); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamHook; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.junit.Assert; import org.junit.Test; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; public class AgentTeamTest { @Test public void test_team_plan_delegate_synthesize() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("{\"tasks\":[{\"memberId\":\"researcher\",\"task\":\"Collect weather facts for Beijing\"},{\"memberId\":\"formatter\",\"task\":\"Format output as concise summary\"}]}")); ScriptedModelClient researcherClient = new ScriptedModelClient(); researcherClient.enqueue(textResult("Beijing weather: cloudy, 9C, light wind.")); ScriptedModelClient formatterClient = new ScriptedModelClient(); formatterClient.enqueue(textResult("Summary formatted.")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("Final answer from team.")); Agent planner = newAgent("planner-model", plannerClient); Agent researcher = newAgent("researcher-model", researcherClient); Agent formatter = newAgent("formatter-model", formatterClient); Agent synthesizer = newAgent("synth-model", synthClient); AgentTeam team = Agents.team() .plannerAgent(planner) .synthesizerAgent(synthesizer) .member(AgentTeamMember.builder().id("researcher").name("Researcher").description("collect factual weather details").agent(researcher).build()) .member(AgentTeamMember.builder().id("formatter").name("Formatter").description("format and polish final text").agent(formatter).build()) .options(AgentTeamOptions.builder().parallelDispatch(true).maxConcurrency(2).build()) .build(); AgentTeamResult result = team.run("Give me a weather summary for Beijing."); Assert.assertEquals("Final answer from team.", result.getOutput()); Assert.assertNotNull(result.getPlan()); Assert.assertEquals(2, result.getPlan().getTasks().size()); Assert.assertEquals(2, result.getMemberResults().size()); Assert.assertTrue(result.getMemberResults().get(0).isSuccess()); Assert.assertTrue(result.getMemberResults().get(1).isSuccess()); Assert.assertTrue(researcherClient.prompts.get(0).getItems().toString().contains("Collect weather facts")); Assert.assertTrue(formatterClient.prompts.get(0).getItems().toString().contains("Format output")); } @Test public void test_team_parallel_dispatch() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("{\"tasks\":[{\"memberId\":\"m1\",\"task\":\"task one\"},{\"memberId\":\"m2\",\"task\":\"task two\"}]}")); ConcurrentModelClient sharedMemberClient = new ConcurrentModelClient(220L, "member-done"); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("parallel merged")); Agent planner = newAgent("planner", plannerClient); Agent m1 = newAgent("m1", sharedMemberClient); Agent m2 = newAgent("m2", sharedMemberClient); Agent synth = newAgent("synth", synthClient); AgentTeam team = Agents.team() .plannerAgent(planner) .synthesizerAgent(synth) .member(AgentTeamMember.builder().id("m1").name("M1").agent(m1).build()) .member(AgentTeamMember.builder().id("m2").name("M2").agent(m2).build()) .options(AgentTeamOptions.builder().parallelDispatch(true).maxConcurrency(2).build()) .build(); AgentTeamResult result = team.run("Run tasks in parallel"); Assert.assertEquals("parallel merged", result.getOutput()); Assert.assertTrue("expected parallel member execution", sharedMemberClient.maxConcurrent.get() >= 2); } @Test public void test_team_planner_fallback_broadcast() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("I will delegate tasks shortly.")); ScriptedModelClient m1Client = new ScriptedModelClient(); m1Client.enqueue(textResult("m1 handled objective.")); ScriptedModelClient m2Client = new ScriptedModelClient(); m2Client.enqueue(textResult("m2 handled objective.")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("fallback merged")); Agent planner = newAgent("planner", plannerClient); Agent m1 = newAgent("m1", m1Client); Agent m2 = newAgent("m2", m2Client); Agent synth = newAgent("synth", synthClient); AgentTeam team = Agents.team() .plannerAgent(planner) .synthesizerAgent(synth) .member(AgentTeamMember.builder().id("m1").name("M1").description("first domain").agent(m1).build()) .member(AgentTeamMember.builder().id("m2").name("M2").description("second domain").agent(m2).build()) .options(AgentTeamOptions.builder().broadcastOnPlannerFailure(true).parallelDispatch(false).build()) .build(); AgentTeamResult result = team.run("Prepare combined analysis"); Assert.assertNotNull(result.getPlan()); Assert.assertTrue(result.getPlan().isFallback()); Assert.assertEquals(2, result.getMemberResults().size()); Assert.assertEquals("fallback merged", result.getOutput()); } @Test public void test_team_dependency_and_message_bus() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("{\"tasks\":[{\"id\":\"collect\",\"memberId\":\"m1\",\"task\":\"collect weather facts\"},{\"id\":\"format\",\"memberId\":\"m2\",\"task\":\"format summary\",\"dependsOn\":[\"collect\"]}]}")); ScriptedModelClient m1Client = new ScriptedModelClient(); m1Client.enqueue(textResult("facts ready")); ScriptedModelClient m2Client = new ScriptedModelClient(); m2Client.enqueue(textResult("formatted from facts")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("dependency merged")); AgentTeam team = Agents.team() .plannerAgent(newAgent("planner", plannerClient)) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder().id("m1").name("collector").agent(newAgent("m1", m1Client)).build()) .member(AgentTeamMember.builder().id("m2").name("formatter").agent(newAgent("m2", m2Client)).build()) .options(AgentTeamOptions.builder().parallelDispatch(true).maxConcurrency(2).build()) .build(); AgentTeamResult result = team.run("build a weather brief"); Assert.assertEquals("dependency merged", result.getOutput()); Assert.assertEquals(2, result.getRounds()); Assert.assertEquals(2, result.getTaskStates().size()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(0).getStatus()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(1).getStatus()); Assert.assertTrue(result.getMessages().size() >= 4); } @Test public void test_team_plan_approval_rejected() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("{\"tasks\":[{\"memberId\":\"m1\",\"task\":\"do task\"}]}")); ScriptedModelClient m1Client = new ScriptedModelClient(); m1Client.enqueue(textResult("m1 output")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("unused")); AgentTeam team = Agents.team() .plannerAgent(newAgent("planner", plannerClient)) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder().id("m1").name("m1").agent(newAgent("m1", m1Client)).build()) .options(AgentTeamOptions.builder().requirePlanApproval(true).build()) .planApproval((objective, plan, members, options) -> false) .build(); try { team.run("task"); Assert.fail("expected plan approval rejection"); } catch (IllegalStateException expected) { Assert.assertTrue(expected.getMessage().contains("plan rejected")); } } @Test public void test_team_dynamic_member_registration_and_hooks() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("{\"tasks\":[{\"memberId\":\"m2\",\"task\":\"take over task\"}]}")); ScriptedModelClient m1Client = new ScriptedModelClient(); m1Client.enqueue(textResult("m1 idle")); ScriptedModelClient m2Client = new ScriptedModelClient(); m2Client.enqueue(textResult("m2 handled")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("dynamic merged")); AtomicInteger beforeTaskCount = new AtomicInteger(); AtomicInteger afterTaskCount = new AtomicInteger(); AtomicInteger messageCount = new AtomicInteger(); AgentTeamHook hook = new AgentTeamHook() { @Override public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) { beforeTaskCount.incrementAndGet(); } @Override public void afterTask(String objective, AgentTeamMemberResult result) { afterTaskCount.incrementAndGet(); } @Override public void onMessage(AgentTeamMessage message) { messageCount.incrementAndGet(); } }; AgentTeam team = Agents.team() .plannerAgent(newAgent("planner", plannerClient)) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder().id("m1").name("m1").agent(newAgent("m1", m1Client)).build()) .hook(hook) .build(); team.registerMember(AgentTeamMember.builder().id("m2").name("m2").agent(newAgent("m2", m2Client)).build()); Assert.assertEquals(2, team.listMembers().size()); AgentTeamResult result = team.run("delegate to m2"); Assert.assertEquals("dynamic merged", result.getOutput()); Assert.assertEquals("m2", result.getMemberResults().get(0).getMemberId()); Assert.assertTrue(beforeTaskCount.get() >= 1); Assert.assertTrue(afterTaskCount.get() >= 1); Assert.assertTrue(messageCount.get() >= 1); Assert.assertTrue(team.unregisterMember("m2")); Assert.assertEquals(1, team.listMembers().size()); } @Test public void test_team_message_controls_direct_and_broadcast() { ScriptedModelClient sharedClient = new ScriptedModelClient(); AgentTeam team = Agents.team() .plannerAgent(newAgent("planner", sharedClient)) .synthesizerAgent(newAgent("synth", sharedClient)) .member(AgentTeamMember.builder().id("m1").name("m1").agent(newAgent("m1", sharedClient)).build()) .member(AgentTeamMember.builder().id("m2").name("m2").agent(newAgent("m2", sharedClient)).build()) .build(); team.sendMessage("m1", "m2", "peer.ask", "task-1", "need your evidence"); team.broadcastMessage("m2", "peer.broadcast", null, "shared update"); Assert.assertEquals(2, team.listMessages().size()); Assert.assertEquals(2, team.listMessagesFor("m2", 10).size()); Assert.assertEquals(1, team.listMessagesFor("m1", 10).size()); } @Test public void test_team_list_task_states_after_run() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("{\"tasks\":[{\"id\":\"collect\",\"memberId\":\"m1\",\"task\":\"collect\"}]}")); ScriptedModelClient memberClient = new ScriptedModelClient(); memberClient.enqueue(textResult("collected")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("done")); AgentTeam team = Agents.team() .plannerAgent(newAgent("planner", plannerClient)) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder().id("m1").name("m1").agent(newAgent("m1", memberClient)).build()) .build(); AgentTeamResult result = team.run("run one task"); Assert.assertEquals("done", result.getOutput()); List states = team.listTaskStates(); Assert.assertEquals(1, states.size()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, states.get(0).getStatus()); } @Test public void test_team_member_tools_can_message_and_heartbeat() throws Exception { ScriptedModelClient plannerClient = new ScriptedModelClient(); plannerClient.enqueue(textResult("{\"tasks\":[{\"id\":\"task_1\",\"memberId\":\"m1\",\"task\":\"collect and notify\"}]}")); ScriptedModelClient m1Client = new ScriptedModelClient(); m1Client.enqueue(toolCallResult(Arrays.asList( toolCall("team_send_message", "{\"toMemberId\":\"m2\",\"type\":\"peer.ask\",\"taskId\":\"task_1\",\"content\":\"Please share your notes\"}"), toolCall("team_broadcast", "{\"type\":\"peer.broadcast\",\"content\":\"task_1 is running\"}"), toolCall("team_heartbeat_task", "{\"taskId\":\"task_1\"}"), toolCall("team_list_tasks", "{}") ))); m1Client.enqueue(textResult("m1 finished")); ScriptedModelClient m2Client = new ScriptedModelClient(); m2Client.enqueue(textResult("m2 standby")); ScriptedModelClient synthClient = new ScriptedModelClient(); synthClient.enqueue(textResult("team final")); AgentTeam team = Agents.team() .plannerAgent(newAgent("planner", plannerClient)) .synthesizerAgent(newAgent("synth", synthClient)) .member(AgentTeamMember.builder().id("m1").name("member-1").agent(newAgent("m1", m1Client)).build()) .member(AgentTeamMember.builder().id("m2").name("member-2").agent(newAgent("m2", m2Client)).build()) .build(); AgentTeamResult result = team.run("execute team tools"); Assert.assertEquals("team final", result.getOutput()); Assert.assertTrue(hasToolNamed(m1Client.prompts.get(0).getTools(), "team_send_message")); Assert.assertTrue(hasToolNamed(m1Client.prompts.get(0).getTools(), "team_broadcast")); Assert.assertTrue(hasToolNamed(m1Client.prompts.get(0).getTools(), "team_heartbeat_task")); boolean hasDirect = false; boolean hasBroadcast = false; for (AgentTeamMessage message : result.getMessages()) { if ("peer.ask".equals(message.getType()) && "m1".equals(message.getFromMemberId()) && "m2".equals(message.getToMemberId())) { hasDirect = true; } if ("peer.broadcast".equals(message.getType()) && "m1".equals(message.getFromMemberId()) && "*".equals(message.getToMemberId())) { hasBroadcast = true; } } Assert.assertTrue(hasDirect); Assert.assertTrue(hasBroadcast); } @Test public void test_team_single_lead_agent_defaults_planner_and_synthesizer() throws Exception { ScriptedModelClient leadClient = new ScriptedModelClient(); leadClient.enqueue(textResult("{\"tasks\":[{\"id\":\"collect\",\"memberId\":\"m1\",\"task\":\"collect weather facts\"}]}")); leadClient.enqueue(textResult("lead merged output")); ScriptedModelClient memberClient = new ScriptedModelClient(); memberClient.enqueue(textResult("facts done")); AgentTeam team = Agents.team() .leadAgent(newAgent("lead", leadClient)) .member(AgentTeamMember.builder().id("m1").name("m1").agent(newAgent("m1", memberClient)).build()) .build(); AgentTeamResult result = team.run("build summary with lead agent"); Assert.assertEquals("lead merged output", result.getOutput()); Assert.assertEquals(2, leadClient.prompts.size()); Assert.assertTrue(leadClient.prompts.get(0).getItems().toString().contains("You are a team planner.")); Assert.assertTrue(leadClient.prompts.get(1).getItems().toString().contains("You are the team lead.")); } private static Agent newAgent(String model, AgentModelClient client) { return Agents.react() .modelClient(client) .model(model) .build(); } private static AgentModelResult textResult(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(new ArrayList()) .toolCalls(new ArrayList()) .build(); } private static AgentModelResult toolCallResult(List calls) { return AgentModelResult.builder() .outputText("") .memoryItems(new ArrayList()) .toolCalls(calls) .build(); } private static AgentToolCall toolCall(String name, String arguments) { return AgentToolCall.builder() .name(name) .arguments(arguments) .callId(name + "_" + System.nanoTime()) .type("function") .build(); } private static boolean hasToolNamed(List tools, String name) { if (tools == null || tools.isEmpty() || name == null) { return false; } for (Object toolObj : tools) { if (!(toolObj instanceof Tool)) { continue; } Tool tool = (Tool) toolObj; if (tool.getFunction() != null && name.equals(tool.getFunction().getName())) { return true; } } return false; } private static class ScriptedModelClient implements AgentModelClient { private final Deque queue = new ArrayDeque<>(); private final List prompts = new ArrayList<>(); private void enqueue(AgentModelResult result) { queue.add(result); } @Override public AgentModelResult create(AgentPrompt prompt) { prompts.add(prompt); return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } private static class ConcurrentModelClient implements AgentModelClient { private final long sleepMs; private final String output; private final AtomicInteger active = new AtomicInteger(); private final AtomicInteger maxConcurrent = new AtomicInteger(); private ConcurrentModelClient(long sleepMs, String output) { this.sleepMs = sleepMs; this.output = output; } @Override public AgentModelResult create(AgentPrompt prompt) { int concurrent = active.incrementAndGet(); maxConcurrent.accumulateAndGet(concurrent, Math::max); try { Thread.sleep(sleepMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { active.decrementAndGet(); } return textResult(output); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.agent.support.ZhipuAgentTestSupport; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamHook; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; public class AgentTeamUsageTest extends ZhipuAgentTestSupport { @Test public void test_team_with_deterministic_plan_and_real_members() throws Exception { Agent analyst = createRoleAgent( "你是需求分析师", "输出需求拆解、风险点、验收口径,控制在5条以内。" ); Agent backend = createRoleAgent( "你是后端工程师", "输出接口与数据模型建议,强调可上线性。" ); Agent lead = createRoleAgent( "你是技术负责人", "把成员结果合并成最终执行计划,按模块分段输出。" ); AtomicInteger beforeTaskCount = new AtomicInteger(); AtomicInteger afterTaskCount = new AtomicInteger(); AgentTeam team = Agents.team() .planner((objective, members, options) -> AgentTeamPlan.builder() .rawPlanText("fixed-plan") .tasks(Arrays.asList( AgentTeamTask.builder().id("t1").memberId("analyst").task("拆解目标与风险").build(), AgentTeamTask.builder().id("t2").memberId("backend").task("给出后端实施方案").dependsOn(Arrays.asList("t1")).build() )) .build()) .synthesizerAgent(lead) .member(AgentTeamMember.builder().id("analyst").name("Analyst").description("需求分析").agent(analyst).build()) .member(AgentTeamMember.builder().id("backend").name("Backend").description("后端设计").agent(backend).build()) .options(AgentTeamOptions.builder() .parallelDispatch(true) .maxConcurrency(2) .enableMessageBus(true) .enableMemberTeamTools(false) .includeMessageHistoryInDispatch(true) .requirePlanApproval(true) .maxRounds(8) .build()) .planApproval((objective, plan, members, options) -> plan != null && plan.getTasks() != null && !plan.getTasks().isEmpty()) .hook(new AgentTeamHook() { @Override public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) { beforeTaskCount.incrementAndGet(); } @Override public void afterTask(String objective, AgentTeamMemberResult result) { afterTaskCount.incrementAndGet(); } }) .build(); AgentTeamResult result = runTeamWithRetry(team, "给一个中型 Java 项目的一周迭代交付方案", 3); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutput()); Assert.assertTrue(result.getOutput().trim().length() > 0); Assert.assertEquals(2, result.getTaskStates().size()); Assert.assertTrue("任务未全部完成: " + describeTaskStates(result.getTaskStates()), allTasksCompleted(result.getTaskStates())); Assert.assertTrue(beforeTaskCount.get() >= 2); Assert.assertTrue(afterTaskCount.get() >= 2); } @Test public void test_team_dynamic_member_and_message_bus() throws Exception { Agent lead = createRoleAgent( "你是团队负责人", "先规划任务再总结输出,内容简洁。" ); Agent writer = createRoleAgent( "你是文档工程师", "产出发布说明草稿。" ); Agent reviewer = createRoleAgent( "你是评审工程师", "产出评审意见。" ); AgentTeam team = Agents.team() .leadAgent(lead) .member(AgentTeamMember.builder().id("writer").name("Writer").agent(writer).build()) .options(AgentTeamOptions.builder() .allowDynamicMemberRegistration(true) .enableMessageBus(true) .enableMemberTeamTools(false) .maxRounds(8) .build()) .build(); team.registerMember(AgentTeamMember.builder().id("reviewer").name("Reviewer").agent(reviewer).build()); AgentTeamResult result = callWithProviderGuard(() -> team.run("整理本周发布说明并补充评审意见")); team.sendMessage("writer", "reviewer", "peer.ask", "task-seed", "请重点关注回滚策略"); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutput()); Assert.assertTrue(result.getOutput().trim().length() > 0); Assert.assertTrue(result.getMemberResults().size() > 0); Assert.assertTrue(team.listMembers().size() >= 2); Assert.assertTrue(result.getMessages().size() > 0); boolean hasPeerAsk = false; for (AgentTeamMessage message : team.listMessages()) { if ("peer.ask".equals(message.getType())) { hasPeerAsk = true; break; } } Assert.assertTrue(hasPeerAsk); Assert.assertTrue(team.unregisterMember("reviewer")); } private Agent createRoleAgent(String systemPrompt, String instructions) { return Agents.builder() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt(systemPrompt) .instructions(instructions) .options(AgentOptions.builder().maxSteps(3).build()) .build(); } private AgentTeamResult runTeamWithRetry(AgentTeam team, String objective, int maxAttempts) throws Exception { AgentTeamResult lastResult = null; for (int i = 0; i < maxAttempts; i++) { lastResult = callWithProviderGuard(() -> team.run(objective)); if (lastResult != null && allTasksCompleted(lastResult.getTaskStates())) { return lastResult; } } return lastResult; } private boolean allTasksCompleted(List states) { if (states == null || states.isEmpty()) { return false; } for (AgentTeamTaskState state : states) { if (state == null || state.getStatus() != AgentTeamTaskStatus.COMPLETED) { return false; } } return true; } private String describeTaskStates(List states) { if (states == null || states.isEmpty()) { return "[]"; } StringBuilder sb = new StringBuilder("["); for (int i = 0; i < states.size(); i++) { AgentTeamTaskState state = states.get(i); if (i > 0) { sb.append(", "); } if (state == null) { sb.append("null"); continue; } sb.append(state.getTaskId()).append(":").append(state.getStatus()); if (state.getError() != null && !state.getError().isEmpty()) { sb.append("(error=").append(state.getError()).append(")"); } } sb.append("]"); return sb.toString(); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTraceListenerTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.agent.trace.AgentTraceListener; import io.github.lnyocly.ai4j.agent.trace.InMemoryTraceExporter; import io.github.lnyocly.ai4j.agent.trace.TraceConfig; import io.github.lnyocly.ai4j.agent.trace.TracePricing; import io.github.lnyocly.ai4j.agent.trace.TraceSpan; import io.github.lnyocly.ai4j.agent.trace.TraceSpanType; import io.github.lnyocly.ai4j.agent.trace.TraceSpanStatus; import org.junit.Assert; import org.junit.Test; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class AgentTraceListenerTest { @Test public void test_trace_listener_collects_spans() { InMemoryTraceExporter exporter = new InMemoryTraceExporter(); AgentTraceListener listener = new AgentTraceListener(exporter); listener.onEvent(event(AgentEventType.STEP_START, 0, null, null)); listener.onEvent(event(AgentEventType.MODEL_REQUEST, 0, null, AgentPrompt.builder().model("test-model").build())); listener.onEvent(event(AgentEventType.MODEL_RESPONSE, 0, null, new Object())); AgentToolCall call = AgentToolCall.builder() .name("mockTool") .callId("tool_1") .arguments("{}") .build(); listener.onEvent(event(AgentEventType.TOOL_CALL, 0, call.getName(), call)); AgentToolResult result = AgentToolResult.builder() .name("mockTool") .callId("tool_1") .output("ok") .build(); listener.onEvent(event(AgentEventType.TOOL_RESULT, 0, "ok", result)); listener.onEvent(event(AgentEventType.FINAL_OUTPUT, 0, "done", null)); listener.onEvent(event(AgentEventType.STEP_END, 0, null, null)); List spans = exporter.getSpans(); Assert.assertFalse(spans.isEmpty()); Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.RUN)); Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.STEP)); Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.MODEL)); Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.TOOL)); } @Test public void test_trace_listener_captures_reasoning_handoff_team_and_memory_events() { InMemoryTraceExporter exporter = new InMemoryTraceExporter(); AgentTraceListener listener = new AgentTraceListener(exporter); listener.onEvent(event(AgentEventType.STEP_START, 0, null, null)); listener.onEvent(event(AgentEventType.MODEL_REQUEST, 0, null, AgentPrompt.builder().model("test-model").build())); listener.onEvent(event(AgentEventType.MODEL_REASONING, 0, "thinking", null)); listener.onEvent(event(AgentEventType.MODEL_RETRY, 0, "retry once", retryPayload())); listener.onEvent(event(AgentEventType.MODEL_RESPONSE, 0, null, "raw-response")); AgentToolCall call = AgentToolCall.builder() .name("delegate") .callId("tool_1") .arguments("{}") .build(); listener.onEvent(event(AgentEventType.TOOL_CALL, 0, call.getName(), call)); listener.onEvent(event(AgentEventType.HANDOFF_START, 0, "handoff", handoffPayload("starting", null))); listener.onEvent(event(AgentEventType.HANDOFF_END, 0, "handoff", handoffPayload("completed", null))); listener.onEvent(event(AgentEventType.TOOL_RESULT, 0, "ok", AgentToolResult.builder() .name("delegate") .callId("tool_1") .output("ok") .build())); listener.onEvent(event(AgentEventType.TEAM_TASK_CREATED, 0, "team-created", teamPayload("planned", null))); listener.onEvent(event(AgentEventType.TEAM_MESSAGE, 0, "team-message", messagePayload())); listener.onEvent(event(AgentEventType.TEAM_TASK_UPDATED, 0, "team-updated", teamPayload("completed", null))); listener.onEvent(event(AgentEventType.MEMORY_COMPRESS, 0, "compact", compactPayload())); listener.onEvent(event(AgentEventType.FINAL_OUTPUT, 0, "done", null)); listener.onEvent(event(AgentEventType.STEP_END, 0, null, null)); List spans = exporter.getSpans(); TraceSpan modelSpan = findSpan(spans, TraceSpanType.MODEL); TraceSpan handoffSpan = findSpan(spans, TraceSpanType.HANDOFF); TraceSpan teamSpan = findSpan(spans, TraceSpanType.TEAM_TASK); TraceSpan memorySpan = findSpan(spans, TraceSpanType.MEMORY); Assert.assertNotNull(modelSpan); Assert.assertNotNull(handoffSpan); Assert.assertNotNull(teamSpan); Assert.assertNotNull(memorySpan); Assert.assertEquals(TraceSpanStatus.OK, handoffSpan.getStatus()); Assert.assertTrue(modelSpan.getEvents().stream().anyMatch(event -> "model.reasoning".equals(event.getName()))); Assert.assertTrue(modelSpan.getEvents().stream().anyMatch(event -> "model.retry".equals(event.getName()))); Assert.assertTrue(teamSpan.getEvents().stream().anyMatch(event -> "team.message".equals(event.getName()))); Assert.assertEquals("summary-1", memorySpan.getAttributes().get("summaryId")); } @Test public void test_trace_listener_captures_model_usage_cost_and_duration_metrics() { InMemoryTraceExporter exporter = new InMemoryTraceExporter(); TraceConfig config = TraceConfig.builder() .pricingResolver(model -> TracePricing.builder() .inputCostPerMillionTokens(2.0D) .outputCostPerMillionTokens(8.0D) .currency("USD") .build()) .build(); AgentTraceListener listener = new AgentTraceListener(exporter, config); listener.onEvent(event(AgentEventType.STEP_START, 0, null, null)); listener.onEvent(event(AgentEventType.MODEL_REQUEST, 0, null, AgentPrompt.builder() .model("glm-4.7") .temperature(0.3D) .build())); listener.onEvent(event(AgentEventType.MODEL_RESPONSE, 0, null, modelResponsePayload())); listener.onEvent(event(AgentEventType.FINAL_OUTPUT, 0, "done", null)); listener.onEvent(event(AgentEventType.STEP_END, 0, null, null)); TraceSpan modelSpan = findSpan(exporter.getSpans(), TraceSpanType.MODEL); TraceSpan runSpan = findSpan(exporter.getSpans(), TraceSpanType.RUN); Assert.assertNotNull(modelSpan); Assert.assertNotNull(modelSpan.getMetrics()); Assert.assertEquals(Long.valueOf(120L), modelSpan.getMetrics().getPromptTokens()); Assert.assertEquals(Long.valueOf(45L), modelSpan.getMetrics().getCompletionTokens()); Assert.assertEquals(Long.valueOf(165L), modelSpan.getMetrics().getTotalTokens()); Assert.assertEquals("USD", modelSpan.getMetrics().getCurrency()); Assert.assertEquals("glm-4.7", modelSpan.getAttributes().get("responseModel")); Assert.assertEquals("stop", modelSpan.getAttributes().get("finishReason")); Assert.assertNotNull(modelSpan.getMetrics().getDurationMillis()); Assert.assertTrue(modelSpan.getMetrics().getDurationMillis() >= 0L); Assert.assertNotNull(modelSpan.getMetrics().getTotalCost()); Assert.assertEquals(0.00024D, modelSpan.getMetrics().getInputCost(), 0.0000001D); Assert.assertEquals(0.00036D, modelSpan.getMetrics().getOutputCost(), 0.0000001D); Assert.assertEquals(0.0006D, modelSpan.getMetrics().getTotalCost(), 0.0000001D); Assert.assertNotNull(runSpan); Assert.assertNotNull(runSpan.getMetrics()); Assert.assertEquals(Long.valueOf(165L), runSpan.getMetrics().getTotalTokens()); Assert.assertEquals(0.0006D, runSpan.getMetrics().getTotalCost(), 0.0000001D); } private TraceSpan findSpan(List spans, TraceSpanType type) { for (TraceSpan span : spans) { if (span != null && span.getType() == type) { return span; } } return null; } private Map retryPayload() { Map payload = new LinkedHashMap(); payload.put("attempt", Integer.valueOf(2)); payload.put("maxAttempts", Integer.valueOf(3)); payload.put("reason", "network"); return payload; } private Map handoffPayload(String status, String error) { Map payload = new LinkedHashMap(); payload.put("handoffId", "handoff:tool_1"); payload.put("callId", "tool_1"); payload.put("tool", "delegate"); payload.put("subagent", "coder"); payload.put("status", status); payload.put("error", error); return payload; } private Map teamPayload(String status, String error) { Map payload = new LinkedHashMap(); payload.put("taskId", "task-1"); payload.put("status", status); payload.put("detail", "working"); payload.put("error", error); return payload; } private Map messagePayload() { Map payload = new LinkedHashMap(); payload.put("taskId", "task-1"); payload.put("content", "sync"); return payload; } private Map compactPayload() { Map payload = new LinkedHashMap(); payload.put("summaryId", "summary-1"); payload.put("reason", "token-limit"); return payload; } private Map modelResponsePayload() { Map usage = new LinkedHashMap(); usage.put("prompt_tokens", 120L); usage.put("completion_tokens", 45L); usage.put("total_tokens", 165L); Map payload = new LinkedHashMap(); payload.put("id", "resp_1"); payload.put("model", "glm-4.7"); payload.put("finishReason", "stop"); payload.put("usage", usage); payload.put("outputText", "hello"); return payload; } private AgentEvent event(AgentEventType type, Integer step, String message, Object payload) { return AgentEvent.builder() .type(type) .step(step) .message(message) .payload(payload) .build(); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTraceUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.agent.support.ZhipuAgentTestSupport; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.trace.InMemoryTraceExporter; import io.github.lnyocly.ai4j.agent.trace.TraceConfig; import io.github.lnyocly.ai4j.agent.trace.TraceSpan; import io.github.lnyocly.ai4j.agent.trace.TraceSpanType; import org.junit.Assert; import org.junit.Test; import java.util.List; public class AgentTraceUsageTest extends ZhipuAgentTestSupport { @Test public void test_trace_for_react_runtime_with_real_model() throws Exception { // Trace 基础能力:RUN/STEP/MODEL span 能被正确记录 InMemoryTraceExporter exporter = new InMemoryTraceExporter(); Agent agent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是技术总结助手,输出一句简洁结论。") .traceExporter(exporter) .options(AgentOptions.builder().maxSteps(2).build()) .build(); callWithProviderGuard(() -> { agent.run(AgentRequest.builder().input("说明可观测性对线上稳定性的价值").build()); return null; }); List spans = exporter.getSpans(); Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.RUN)); Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.STEP)); Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.MODEL)); } @Test public void test_trace_mask_and_truncate_config() throws Exception { // Trace 高级配置:字段脱敏 + 长字段截断 InMemoryTraceExporter exporter = new InMemoryTraceExporter(); TraceConfig config = TraceConfig.builder() .maxFieldLength(24) .masker(text -> text.replace("SECRET", "***")) .build(); Agent agent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("SECRET-token-should-be-masked-and-truncated") .traceExporter(exporter) .traceConfig(config) .options(AgentOptions.builder().maxSteps(2).build()) .build(); callWithProviderGuard(() -> { agent.run(AgentRequest.builder().input("输出一句话说明可观测性价值").build()); return null; }); TraceSpan modelSpan = exporter.getSpans().stream() .filter(span -> span.getType() == TraceSpanType.MODEL) .findFirst() .orElse(null); Assert.assertNotNull(modelSpan); Object systemPrompt = modelSpan.getAttributes().get("systemPrompt"); Assert.assertNotNull(systemPrompt); String text = String.valueOf(systemPrompt); Assert.assertTrue(text.contains("***")); Assert.assertTrue(text.endsWith("...")); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentWorkflowTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.workflow.AgentNode; import io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow; import io.github.lnyocly.ai4j.agent.workflow.WorkflowContext; import org.junit.Assert; import org.junit.Test; public class AgentWorkflowTest { @Test public void test_sequential_workflow_passes_output() throws Exception { SequentialWorkflow workflow = new SequentialWorkflow() .addNode(new StaticNode("step1")) .addNode(new EchoNode()); AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder().input("start").build()); Assert.assertEquals("echo:step1", result.getOutputText()); } private static class StaticNode implements AgentNode { private final String output; private StaticNode(String output) { this.output = output; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) { return AgentResult.builder().outputText(output).build(); } } private static class EchoNode implements AgentNode { @Override public AgentResult execute(WorkflowContext context, AgentRequest request) { return AgentResult.builder().outputText("echo:" + request.getInput()).build(); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentWorkflowUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.agent.support.ZhipuAgentTestSupport; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.workflow.RuntimeAgentNode; import io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow; import io.github.lnyocly.ai4j.agent.workflow.StateGraphWorkflow; import io.github.lnyocly.ai4j.agent.workflow.WorkflowAgent; import org.junit.Assert; import org.junit.Test; public class AgentWorkflowUsageTest extends ZhipuAgentTestSupport { @Test public void test_sequential_workflow_with_real_agents() throws Exception { // 顺序编排:第一个节点产出草稿,第二个节点做结构化整理 Agent draftAgent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是方案起草助手。给出3条关键执行步骤。") .options(AgentOptions.builder().maxSteps(2).build()) .build(); Agent formatAgent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是格式化助手。把输入整理成编号清单。") .options(AgentOptions.builder().maxSteps(2).build()) .build(); SequentialWorkflow workflow = new SequentialWorkflow() .addNode(new RuntimeAgentNode(draftAgent.newSession())) .addNode(new RuntimeAgentNode(formatAgent.newSession())); AgentResult result = callWithProviderGuard(() -> workflow.run(new AgentSession(null, null), AgentRequest.builder().input("生成一次数据库迁移发布流程").build())); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); } @Test public void test_state_graph_workflow_with_router() throws Exception { // 状态图编排:先路由,再进入不同节点,最后统一收敛 Agent routerAgent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是路由器。只输出 ROUTE_INCIDENT 或 ROUTE_GENERAL。") .instructions("输入含有故障、报错、事故时输出 ROUTE_INCIDENT,否则输出 ROUTE_GENERAL。") .options(AgentOptions.builder().maxSteps(1).build()) .build(); Agent incidentAgent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是应急响应助手。给出故障处理建议。") .options(AgentOptions.builder().maxSteps(2).build()) .build(); Agent generalAgent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是通用助手。给出普通建议。") .options(AgentOptions.builder().maxSteps(2).build()) .build(); Agent finalAgent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是总结助手。输出最终建议,保持简洁。") .options(AgentOptions.builder().maxSteps(2).build()) .build(); StateGraphWorkflow workflow = new StateGraphWorkflow() .addNode("route", new RuntimeAgentNode(routerAgent.newSession())) .addNode("incident", new RuntimeAgentNode(incidentAgent.newSession())) .addNode("general", new RuntimeAgentNode(generalAgent.newSession())) .addNode("final", new RuntimeAgentNode(finalAgent.newSession())) .start("route") .addConditionalEdges("route", (context, request, result) -> { String output = result == null ? "" : result.getOutputText(); if (output != null && output.contains("ROUTE_INCIDENT")) { return "incident"; } return "general"; }) .addEdge("incident", "final") .addEdge("general", "final"); WorkflowAgent workflowAgent = new WorkflowAgent(workflow, new AgentSession(null, null)); AgentResult result = callWithProviderGuard(() -> workflowAgent.run(AgentRequest.builder().input("线上发生接口报错,用户大量失败").build())); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/ChatModelClientTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage; import io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice; import io.github.lnyocly.ai4j.platform.openai.tool.ToolCall; import io.github.lnyocly.ai4j.service.IChatService; import okhttp3.MediaType; import okhttp3.Protocol; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; public class ChatModelClientTest { @Test public void test_stream_preserves_reasoning_text_and_tool_calls() throws Exception { FakeChatService chatService = new FakeChatService(); ChatModelClient client = new ChatModelClient(chatService); List reasoningDeltas = new ArrayList<>(); List textDeltas = new ArrayList<>(); AgentModelResult result = client.createStream(AgentPrompt.builder() .model("glm-4.7") .build(), new AgentModelStreamListener() { @Override public void onReasoningDelta(String delta) { reasoningDeltas.add(delta); } @Override public void onDeltaText(String delta) { textDeltas.add(delta); } }); Assert.assertEquals(Arrays.asList("Need a tool first."), reasoningDeltas); Assert.assertEquals(Arrays.asList("I will get the current time."), textDeltas); Assert.assertEquals("Need a tool first.", result.getReasoningText()); Assert.assertEquals("I will get the current time.", result.getOutputText()); Assert.assertEquals(1, result.getToolCalls().size()); Assert.assertEquals("get_current_time", result.getToolCalls().get(0).getName()); Assert.assertEquals("{}", result.getToolCalls().get(0).getArguments()); Assert.assertTrue(chatService.lastStreamRequest != null && Boolean.TRUE.equals(chatService.lastStreamRequest.getPassThroughToolCalls())); } @Test public void test_stream_preserves_invalid_coding_tool_calls_for_runtime_validation() throws Exception { FakeInvalidBashChatService chatService = new FakeInvalidBashChatService(); ChatModelClient client = new ChatModelClient(chatService); AgentModelResult result = client.createStream(AgentPrompt.builder() .model("glm-4.7") .build(), new AgentModelStreamListener() { }); Assert.assertEquals("I should inspect the local time.", result.getOutputText()); Assert.assertEquals(1, result.getToolCalls().size()); Assert.assertEquals("bash", result.getToolCalls().get(0).getName()); } @Test public void test_stream_aggregates_minimax_style_fragmented_tool_calls() throws Exception { FakeFragmentedMiniMaxChatService chatService = new FakeFragmentedMiniMaxChatService(); ChatModelClient client = new ChatModelClient(chatService); AgentModelResult result = client.createStream(AgentPrompt.builder() .model("MiniMax-M2.7") .build(), new AgentModelStreamListener() { }); Assert.assertEquals(1, result.getToolCalls().size()); Assert.assertEquals("delegate_plan", result.getToolCalls().get(0).getName()); Assert.assertEquals( "{\"task\": \"Create a short implementation plan for adding a hello endpoint demo app in this empty workspace.\"}", result.getToolCalls().get(0).getArguments() ); } @Test public void test_stream_propagates_stream_execution_options() throws Exception { FakeChatService chatService = new FakeChatService(); ChatModelClient client = new ChatModelClient(chatService); client.createStream(AgentPrompt.builder() .model("glm-4.7") .streamExecution(StreamExecutionOptions.builder() .firstTokenTimeoutMs(1234L) .idleTimeoutMs(5678L) .maxRetries(2) .retryBackoffMs(90L) .build()) .build(), new AgentModelStreamListener() { }); Assert.assertNotNull(chatService.lastStreamRequest); Assert.assertNotNull(chatService.lastStreamRequest.getStreamExecution()); Assert.assertEquals(1234L, chatService.lastStreamRequest.getStreamExecution().getFirstTokenTimeoutMs()); Assert.assertEquals(5678L, chatService.lastStreamRequest.getStreamExecution().getIdleTimeoutMs()); Assert.assertEquals(2, chatService.lastStreamRequest.getStreamExecution().getMaxRetries()); Assert.assertEquals(90L, chatService.lastStreamRequest.getStreamExecution().getRetryBackoffMs()); } @Test public void test_stream_surfaces_http_error_payload_when_sse_failure_has_no_throwable() throws Exception { FakeStreamingErrorChatService chatService = new FakeStreamingErrorChatService(); ChatModelClient client = new ChatModelClient(chatService); final List errors = new ArrayList(); AgentModelResult result = client.createStream(AgentPrompt.builder() .model("glm-4.7") .build(), new AgentModelStreamListener() { @Override public void onError(Throwable t) { errors.add(t == null ? null : t.getMessage()); } }); Assert.assertEquals("", result.getOutputText()); Assert.assertEquals(1, errors.size()); Assert.assertEquals("Invalid API key provided.", errors.get(0)); } @Test public void test_create_stores_assistant_tool_calls_in_memory_items() throws Exception { CapturingChatService chatService = new CapturingChatService(); chatService.responseMessage = ChatMessage.withAssistant( "I should inspect the workspace.", Collections.singletonList(new ToolCall( "call_1", "function", new ToolCall.Function("bash", "{\"action\":\"exec\",\"command\":\"dir\"}") )) ); ChatModelClient client = new ChatModelClient(chatService); AgentModelResult result = client.create(AgentPrompt.builder() .model("glm-4.7") .build()); Assert.assertEquals(1, result.getMemoryItems().size()); Assert.assertTrue(result.getMemoryItems().get(0) instanceof Map); @SuppressWarnings("unchecked") Map item = (Map) result.getMemoryItems().get(0); Assert.assertEquals("message", item.get("type")); Assert.assertEquals("assistant", item.get("role")); Assert.assertTrue(item.containsKey("tool_calls")); } @Test public void test_create_rehydrates_assistant_tool_calls_before_tool_output() throws Exception { CapturingChatService chatService = new CapturingChatService(); ChatModelClient client = new ChatModelClient(chatService); List toolCalls = Collections.singletonList(AgentToolCall.builder() .callId("call_1") .name("bash") .type("function") .arguments("{\"action\":\"exec\",\"command\":\"dir\"}") .build()); client.create(AgentPrompt.builder() .model("glm-4.7") .items(Arrays.asList( AgentInputItem.userMessage("inspect the workspace"), AgentInputItem.assistantToolCallsMessage("I will inspect the workspace.", toolCalls), AgentInputItem.functionCallOutput("call_1", "{\"exitCode\":0}") )) .build()); Assert.assertNotNull(chatService.lastRequest); Assert.assertNotNull(chatService.lastRequest.getMessages()); Assert.assertEquals(3, chatService.lastRequest.getMessages().size()); ChatMessage assistantMessage = chatService.lastRequest.getMessages().get(1); Assert.assertEquals("assistant", assistantMessage.getRole()); Assert.assertEquals("I will inspect the workspace.", assistantMessage.getContent().getText()); Assert.assertNotNull(assistantMessage.getToolCalls()); Assert.assertEquals(1, assistantMessage.getToolCalls().size()); Assert.assertEquals("bash", assistantMessage.getToolCalls().get(0).getFunction().getName()); ChatMessage toolMessage = chatService.lastRequest.getMessages().get(2); Assert.assertEquals("tool", toolMessage.getRole()); Assert.assertEquals("call_1", toolMessage.getToolCallId()); } private static class FakeChatService implements IChatService { private ChatCompletion lastStreamRequest; @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) { lastStreamRequest = chatCompletion; eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"Need a tool first.\"},\"finish_reason\":null}]}"); eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"I will get the current time.\"},\"finish_reason\":null}]}"); eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_current_time\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}"); eventSourceListener.onEvent(null, null, null, "[DONE]"); eventSourceListener.onClosed(null); } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) { throw new UnsupportedOperationException("not used"); } } private static class FakeInvalidBashChatService implements IChatService { @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) { eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"I should inspect the local time.\"},\"finish_reason\":null}]}"); eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"action\\\":\\\"exec\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}"); eventSourceListener.onEvent(null, null, null, "[DONE]"); eventSourceListener.onClosed(null); } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) { throw new UnsupportedOperationException("not used"); } } private static class FakeStreamingErrorChatService implements IChatService { @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) { Request request = new Request.Builder().url("https://example.com/chat").build(); Response response = new Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(401) .message("Unauthorized") .body(ResponseBody.create( MediaType.get("application/json"), "{\"error\":{\"message\":\"Invalid API key provided.\"}}" )) .build(); eventSourceListener.onFailure(null, null, response); } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) { throw new UnsupportedOperationException("not used"); } } private static class CapturingChatService implements IChatService { private ChatCompletion lastRequest; private ChatMessage responseMessage; @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) { lastRequest = chatCompletion; return response(); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) { lastRequest = chatCompletion; return response(); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) { throw new UnsupportedOperationException("not used"); } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) { throw new UnsupportedOperationException("not used"); } private ChatCompletionResponse response() { if (responseMessage == null) { return new ChatCompletionResponse(); } Choice choice = new Choice(); choice.setMessage(responseMessage); ChatCompletionResponse response = new ChatCompletionResponse(); response.setChoices(Collections.singletonList(choice)); return response; } } private static class FakeFragmentedMiniMaxChatService implements IChatService { @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) { eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"delegate_plan\",\"arguments\":\"{\\\"task\\\": \\\"Create a short implementation plan\"}}]},\"finish_reason\":null}]}"); eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"function\":{\"arguments\":\" for adding a hello endpoint demo app in this empty workspace.\"}}]},\"finish_reason\":null}]}"); eventSourceListener.onEvent(null, null, null, "{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}"); eventSourceListener.onEvent(null, null, null, "[DONE]"); eventSourceListener.onClosed(null); } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) { throw new UnsupportedOperationException("not used"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActAgentUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.agent.support.ZhipuAgentTestSupport; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.codeact.CodeActOptions; import io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor; import org.junit.Assert; import org.junit.Assume; import org.junit.Test; import java.net.SocketTimeoutException; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; public class CodeActAgentUsageTest extends ZhipuAgentTestSupport { @Test public void test_codeact_basic_js_execution() throws Exception { // CodeAct 在 JDK8 场景优先使用 Nashorn,保证 JavaScript 执行链路可用 Assume.assumeTrue(isNashornAvailable()); Agent agent = Agents.codeAct() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .maxOutputTokens(512) .codeExecutor(new NashornCodeExecutor()) .systemPrompt("你是代码执行助手。只允许输出 JSON。优先使用 JavaScript 计算并返回最终结果。") .codeActOptions(CodeActOptions.builder().reAct(false).build()) .options(AgentOptions.builder().maxSteps(4).build()) .build(); AgentResult result = runWithRetry(agent, AgentRequest.builder().input("请用JavaScript计算 17*3+5 ,只返回最终数字").build(), 2); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); Assert.assertFalse(result.getOutputText().contains("CODE_ERROR")); } @Test public void test_codeact_react_finalize_flow_without_external_tool() throws Exception { // CodeAct ReAct 模式:先输出代码块,再进入 final 输出 Assume.assumeTrue(isNashornAvailable()); Agent agent = Agents.codeAct() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .maxOutputTokens(512) .codeExecutor(new NashornCodeExecutor()) .systemPrompt("你是代码执行助手。先输出可执行 JavaScript,再输出最终结果。保持简短。") .instructions("用 JavaScript 计算 88 除以 11。") .codeActOptions(CodeActOptions.builder().reAct(true).build()) .options(AgentOptions.builder().maxSteps(4).build()) .build(); AgentResult result = runWithRetry(agent, AgentRequest.builder().input("请先写代码再返回最终结果,不要解释").build(), 2); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); Assert.assertFalse(result.getOutputText().contains("CODE_ERROR")); } private boolean isNashornAvailable() { ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn"); return engine != null; } private AgentResult runWithRetry(Agent agent, AgentRequest request, int maxAttempts) throws Exception { Exception last = null; for (int i = 0; i < maxAttempts; i++) { try { return callWithProviderGuard(() -> agent.run(request)); } catch (Exception ex) { last = ex; if (i + 1 >= maxAttempts || !isRetryable(ex)) { throw ex; } } } throw last == null ? new RuntimeException("CodeAct run failed without exception detail") : last; } private boolean isRetryable(Throwable throwable) { Throwable current = throwable; while (current != null) { if (current instanceof SocketTimeoutException) { return true; } String message = current.getMessage(); if (message != null && message.toLowerCase().contains("timeout")) { return true; } current = current.getCause(); } return false; } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActPythonExecutorTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult; import io.github.lnyocly.ai4j.agent.codeact.GraalVmCodeExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.graalvm.polyglot.Context; import org.junit.Assert; import org.junit.Assume; import org.junit.Test; public class CodeActPythonExecutorTest { @Test public void test_python_executor_with_tool() throws Exception { Assume.assumeTrue("GraalPy is not available", isGraalPyAvailable()); GraalVmCodeExecutor executor = new GraalVmCodeExecutor(); CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder() .language("python") .code("result = callTool(\"echo\", {\"text\": \"hi\"})\n__codeact_result = \"ok:\" + result") .toolExecutor(new ToolExecutor() { @Override public String execute(AgentToolCall call) { return call.getName() + ":" + call.getArguments(); } }) .build()); System.out.println("PY RESULT: " + result); System.out.println("PY ERROR: " + result.getError()); Assert.assertTrue(result.isSuccess()); Assert.assertNotNull(result.getResult()); Assert.assertTrue(result.getResult().contains("ok:")); } private boolean isGraalPyAvailable() { try (Context context = Context.newBuilder("python").build()) { context.eval("python", "1+1"); return true; } catch (Throwable t) { return false; } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActRuntimeTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.codeact.CodeActOptions; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventPublisher; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.concurrent.TimeUnit; public class CodeActRuntimeTest { private AiService aiService; @Before public void init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getProperty("doubao.api.key"); } Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } @Test public void test_codeact_with_tool() throws Exception { Agent agent = Agents.codeAct() .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You are a weather assistant. Use Python only. Always call queryWeather with args {location, type, days}. Use type \"daily\" and days 1. Return a final answer string. If you do not return, set __codeact_result.") .toolRegistry(Arrays.asList("queryWeather"), null) .options(AgentOptions.builder().maxSteps(4).build()) .codeActOptions(CodeActOptions.builder().reAct(true).build()) .eventPublisher(buildEventPublisher()) .build(); AgentResult result = agent.run(AgentRequest.builder() .input("Use Python to query weather for Beijing, Shanghai, and Shenzhen. Call queryWeather with type \"daily\" and days 1 for each city, then return a single summary string.") .build()); System.out.println("CODEACT OUTPUT: " + result.getOutputText()); if (result.getToolCalls() != null && !result.getToolCalls().isEmpty()) { System.out.println("CODEACT CODE: " + result.getToolCalls().get(0).getArguments()); } Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().length() > 0); Assert.assertFalse(result.getOutputText().contains("CODE_ERROR")); Assert.assertTrue(result.getToolResults() != null && !result.getToolResults().isEmpty()); } private AgentEventPublisher buildEventPublisher() { AgentEventPublisher publisher = new AgentEventPublisher(); publisher.addListener(event -> logToolCall(event)); return publisher; } private void logToolCall(AgentEvent event) { if (event == null || event.getType() != AgentEventType.TOOL_CALL) { return; } Object payload = event.getPayload(); if (!(payload instanceof AgentToolCall)) { return; } AgentToolCall call = (AgentToolCall) payload; if (!"code".equals(call.getName())) { return; } System.out.println("CODEACT CODE (pre-exec): " + call.getArguments()); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActRuntimeWithTraceTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.codeact.CodeActOptions; import io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.agent.trace.ConsoleTraceExporter; import io.github.lnyocly.ai4j.agent.trace.TraceConfig; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import javax.script.ScriptEngineManager; import java.util.Arrays; import java.util.concurrent.TimeUnit; public class CodeActRuntimeWithTraceTest { private AiService aiService; @Before public void init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getProperty("doubao.api.key"); } apiKey = "67f0a8ec-10fd-4949-87d1-1e3bf1f77d91"; Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } @Test public void test_codeact_with_trace() throws Exception { Assume.assumeTrue("Nashorn runtime is not available", isNashornAvailable()); Agent agent = Agents.codeAct() .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You are a weather assistant. Use JavaScript only. Always call queryWeather with args {location, type, days}. Use type \"daily\" and days 1. Return a final answer string. If you do not return, set __codeact_result.") .toolRegistry(Arrays.asList("queryWeather"), null) .options(AgentOptions.builder().maxSteps(4).build()) .codeExecutor(new NashornCodeExecutor()) .codeActOptions(CodeActOptions.builder().reAct(true).build()) .traceConfig(TraceConfig.builder().build()) .traceExporter(new ConsoleTraceExporter()) .build(); AgentResult result = agent.run(AgentRequest.builder() .input("Use JavaScript to query weather for Beijing, Shanghai, and Shenzhen. Call queryWeather with type \"daily\" and days 1 for each city, then return a single summary string.") .build()); System.out.println("CODEACT OUTPUT: " + result.getOutputText()); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().length() > 0); Assert.assertFalse(result.getOutputText().contains("CODE_ERROR")); Assert.assertTrue(result.getToolResults() != null && !result.getToolResults().isEmpty()); } private boolean isNashornAvailable() { return new ScriptEngineManager().getEngineByName("nashorn") != null; } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/DoubaoAgentTeamBestPracticeTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamHook; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * Best-practice Agent Teams demo using Doubao: * - deterministic dependency plan to make orchestration stable in tests * - Doubao-powered teammates + synthesizer * - message bus + hooks + plan approval enabled */ public class DoubaoAgentTeamBestPracticeTest { private static final String MODEL = (System.getenv("ZHIPU_MODEL") == null || System.getenv("ZHIPU_MODEL").isEmpty()) ? "GLM-4.5-Flash" : System.getenv("ZHIPU_MODEL"); private AiService aiService; @Before public void init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ZHIPU_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = "1cbd1960cdc7e9144ded698a9763569b.seHlVxdOq3eTnY9m"; } Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); ZhipuConfig zhipuConfig = new ZhipuConfig(); zhipuConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setZhipuConfig(zhipuConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } @Test public void test_doubao_agent_team_best_practice() throws Exception { Agent northWeatherAgent = Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU))) .model(MODEL) .temperature(0.7) .systemPrompt("You are a weather data specialist. You must call queryWeather before answering.") .instructions("Use queryWeather(location, type=daily, days=1). Return concise JSON only.") .toolRegistry(Collections.singletonList("queryWeather"), null) .options(AgentOptions.builder().maxSteps(3).build()) .build(); Agent southWeatherAgent = Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU))) .model(MODEL) .temperature(0.7) .systemPrompt("You are a weather data specialist. You must call queryWeather before answering.") .instructions("Use queryWeather(location, type=daily, days=1). Return concise JSON only.") .toolRegistry(Collections.singletonList("queryWeather"), null) .options(AgentOptions.builder().maxSteps(3).build()) .build(); Agent analystAgent = Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU))) .model(MODEL) .temperature(0.7) .systemPrompt("You are a weather risk analyst. Compare city weather and provide travel guidance.") .instructions("Use teammate outputs. Produce compact bullet points.") .options(AgentOptions.builder().maxSteps(2).build()) .build(); Agent synthesizerAgent = Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU))) .model(MODEL) .temperature(0.7) .systemPrompt("You are the team lead. Merge teammate results into final structured JSON.") .instructions("Output JSON with fields: summary, cityComparisons, travelAdvice.") .options(AgentOptions.builder().maxSteps(2).build()) .build(); final AtomicInteger beforeTaskCount = new AtomicInteger(); final AtomicInteger afterTaskCount = new AtomicInteger(); final AtomicInteger messageCount = new AtomicInteger(); AgentTeam team = Agents.team() .planner((objective, members, options) -> AgentTeamPlan.builder() .rawPlanText("deterministic_best_practice_plan") .tasks(Arrays.asList( AgentTeamTask.builder() .id("north_weather") .memberId("north_weather") .task("Get a 1-day daily weather forecast for Beijing and return compact JSON.") .context("city=Beijing") .build(), AgentTeamTask.builder() .id("south_weather") .memberId("south_weather") .task("Get a 1-day daily weather forecast for Shenzhen and return compact JSON.") .context("city=Shenzhen") .build(), AgentTeamTask.builder() .id("risk_analysis") .memberId("analyst") .task("Compare both weather reports and provide practical travel suggestions.") .dependsOn(Arrays.asList("north_weather", "south_weather")) .build() )) .build()) .synthesizerAgent(synthesizerAgent) .member(AgentTeamMember.builder() .id("north_weather") .name("North Weather") .description("Fetches weather for northern city") .agent(northWeatherAgent) .build()) .member(AgentTeamMember.builder() .id("south_weather") .name("South Weather") .description("Fetches weather for southern city") .agent(southWeatherAgent) .build()) .member(AgentTeamMember.builder() .id("analyst") .name("Analyst") .description("Compares reports and produces advice") .agent(analystAgent) .build()) .options(AgentTeamOptions.builder() .parallelDispatch(true) .maxConcurrency(3) .enableMessageBus(true) .includeMessageHistoryInDispatch(true) .messageHistoryLimit(50) .requirePlanApproval(true) .maxRounds(12) .build()) .planApproval((objective, plan, members, options) -> { if (plan == null || plan.getTasks() == null || plan.getTasks().isEmpty()) { return false; } Set validMembers = new HashSet(); for (AgentTeamMember member : members) { validMembers.add(member.resolveId()); } for (AgentTeamTask task : plan.getTasks()) { if (!validMembers.contains(task.getMemberId())) { return false; } } return true; }) .hook(new AgentTeamHook() { @Override public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) { beforeTaskCount.incrementAndGet(); System.out.println("TEAM TASK START: " + task.getId() + " -> " + member.resolveId()); } @Override public void afterTask(String objective, AgentTeamMemberResult result) { afterTaskCount.incrementAndGet(); System.out.println("TEAM TASK END: " + result.getTaskId() + " status=" + result.getTaskStatus()); } @Override public void onMessage(AgentTeamMessage message) { messageCount.incrementAndGet(); System.out.println("TEAM MSG: [" + message.getType() + "] " + message.getFromMemberId() + " -> " + message.getToMemberId() + " | " + message.getContent()); } }) .build(); team.publishMessage(AgentTeamMessage.builder() .id("seed-message") .fromMemberId("lead") .toMemberId("*") .type("run.context") .content("Use factual weather data. Keep output concise and actionable.") .createdAt(System.currentTimeMillis()) .build()); AgentTeamResult result = team.run("Create a two-city weather briefing for Beijing and Shenzhen."); System.out.println("TEAM ROUNDS: " + result.getRounds()); System.out.println("TEAM TASK STATES: " + result.getTaskStates()); System.out.println("TEAM OUTPUT: " + result.getOutput()); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutput()); Assert.assertTrue(result.getOutput().length() > 0); Assert.assertTrue(result.getRounds() >= 2); Assert.assertNotNull(result.getTaskStates()); Assert.assertEquals(3, result.getTaskStates().size()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(0).getStatus()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(1).getStatus()); Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(2).getStatus()); Assert.assertTrue(beforeTaskCount.get() >= 3); Assert.assertTrue(afterTaskCount.get() >= 3); Assert.assertTrue(messageCount.get() > 0); Assert.assertNotNull(result.getMessages()); Assert.assertTrue(result.getMessages().size() > 0); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/DoubaoAgentWorkflowTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.agent.workflow.RuntimeAgentNode; import io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow; import io.github.lnyocly.ai4j.agent.workflow.WorkflowAgent; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; public class DoubaoAgentWorkflowTest { private AiService aiService; @Before public void init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } @Test public void test_doubao_agent_workflow() throws Exception { Agent agent = Agents.react() .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .options(AgentOptions.builder().maxSteps(2).build()) .build(); SequentialWorkflow workflow = new SequentialWorkflow() .addNode(new RuntimeAgentNode(agent.newSession())); WorkflowAgent runner = new WorkflowAgent(workflow, agent.newSession()); AgentResult result = runner.run(AgentRequest.builder() .input("Explain the Responses API in one sentence") .build()); System.out.println("agent output: " + result.getOutputText()); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().length() > 0); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/DoubaoProjectTeamAgentTeamsTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamHook; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; /** * 使用豆包真实大模型的 Agent Teams 集成测试。 * 场景:模拟完整研发小组(架构/后端/前端/测试/运维)协作完成项目交付规划。 */ public class DoubaoProjectTeamAgentTeamsTest { private static final String DEFAULT_API_KEY = "1cbd1960cdc7e9144ded698a9763569b.seHlVxdOq3eTnY9m"; private static final String MODEL = (System.getenv("ZHIPU_MODEL") == null || System.getenv("ZHIPU_MODEL").isEmpty()) ? "GLM-4.5-Flash" : System.getenv("ZHIPU_MODEL"); private AiService aiService; @Before public void init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ZHIPU_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = DEFAULT_API_KEY; } ZhipuConfig zhipuConfig = new ZhipuConfig(); zhipuConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setZhipuConfig(zhipuConfig); HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(); loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); // ????????????????????CI/??????????????? OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } @Test public void test_project_delivery_team_with_doubao() throws Exception { // 1) 构建不同职责的 Agent(同一模型,不同 systemPrompt/instructions) Agent architect = createRoleAgent( "You are the software architect.", "Define architecture boundaries, API contract shape, and key risks in concise bullet points."); Agent backend = createRoleAgent( "You are the backend engineer.", "Create backend implementation plan with modules, API endpoints, and schema changes. " + "Use team_send_message to notify frontend about API contract changes when needed."); Agent frontend = createRoleAgent( "You are the frontend engineer.", "Create frontend implementation plan with pages/components/state/API integration. " + "Use team_send_message to ask backend for contract clarification when needed."); Agent qa = createRoleAgent( "You are the QA engineer.", "Build a concise quality plan: smoke/regression/e2e, acceptance criteria, and release gate. " + "Use team_list_tasks if needed."); Agent ops = createRoleAgent( "You are the DevOps engineer.", "Provide deployment, monitoring, alerting, rollback strategy, and production readiness checklist."); Agent lead = createRoleAgent( "You are the team lead.", "You are responsible for both planning and final synthesis. " + "First break objective into executable tasks and assign to member ids: architect, backend, frontend, qa, ops. " + "Then merge member outputs into one sprint delivery plan with sections: architecture, backend, frontend, qa, ops, milestones, risks."); // 2) 构建 Team:固定规划任务 DAG,成员并发执行,最后由 lead 汇总 AgentTeam team = Agents.team() .leadAgent(lead) .member(AgentTeamMember.builder().id("architect").name("Architect").description("architecture").agent(architect).build()) .member(AgentTeamMember.builder().id("backend").name("Backend").description("backend").agent(backend).build()) .member(AgentTeamMember.builder().id("frontend").name("Frontend").description("frontend").agent(frontend).build()) .member(AgentTeamMember.builder().id("qa").name("QA").description("quality").agent(qa).build()) .member(AgentTeamMember.builder().id("ops").name("Ops").description("operations").agent(ops).build()) .options(AgentTeamOptions.builder() .parallelDispatch(true) .maxConcurrency(3) .enableMessageBus(true) .includeMessageHistoryInDispatch(true) .messageHistoryLimit(50) .enableMemberTeamTools(false) .maxRounds(16) .build()) .hook(new AgentTeamHook() { @Override public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) { System.out.println("TEAM TASK START: " + task.getId() + " -> " + member.resolveId()); } @Override public void afterTask(String objective, AgentTeamMemberResult result) { System.out.println("TEAM TASK END: " + result.getTaskId() + " | " + result.getTaskStatus()); } @Override public void onMessage(AgentTeamMessage message) { System.out.println("TEAM MSG: [" + message.getType() + "] " + message.getFromMemberId() + " -> " + message.getToMemberId() + " | " + message.getContent()); } }) .build(); // 3) 执行团队协作任务 AgentTeamResult result = team.run("Build a production-ready task management web application in one sprint."); System.out.println("TEAM ROUNDS: " + result.getRounds()); System.out.println("TEAM OUTPUT:\n" + result.getOutput()); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutput()); Assert.assertTrue(result.getOutput().length() > 120); Assert.assertNotNull(result.getTaskStates()); Assert.assertTrue(result.getTaskStates().size() > 0); Assert.assertNotNull(result.getMemberResults()); Assert.assertTrue(result.getMemberResults().size() > 0); Assert.assertTrue(result.getRounds() >= 1); int completedCount = 0; for (AgentTeamTaskState state : result.getTaskStates()) { if (state != null && state.getStatus() == AgentTeamTaskStatus.COMPLETED) { completedCount++; } } Assert.assertTrue("expected at least one completed task", completedCount > 0); Assert.assertNotNull(result.getMessages()); Assert.assertTrue(result.getMessages().size() > 0); } // 统一创建 ReAct Agent,便于按角色切换 systemPrompt 与 instructions private Agent createRoleAgent(String systemPrompt, String instructions) { return Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU))) .model(MODEL) .temperature(0.7) .systemPrompt(systemPrompt) .instructions(instructions) .options(AgentOptions.builder().maxSteps(4).build()) .build(); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/FileAgentTeamStateStoreTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamState; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.Arrays; import java.util.List; public class FileAgentTeamStateStoreTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldSaveLoadListAndDeleteState() throws Exception { Path root = temporaryFolder.newFolder("team-state-store").toPath(); FileAgentTeamStateStore store = new FileAgentTeamStateStore(root); AgentTeamState state = AgentTeamState.builder() .teamId("travel-team") .objective("ship travel demo") .lastOutput("done") .lastRounds(3) .updatedAt(123L) .build(); store.save(state); AgentTeamState loaded = store.load("travel-team"); Assert.assertNotNull(loaded); Assert.assertEquals("ship travel demo", loaded.getObjective()); Assert.assertEquals("done", loaded.getLastOutput()); List listed = store.list(); Assert.assertEquals(1, listed.size()); Assert.assertEquals("travel-team", listed.get(0).getTeamId()); Assert.assertTrue(store.delete("travel-team")); Assert.assertNull(store.load("travel-team")); } @Test public void shouldPersistMailboxAcrossInstances() throws Exception { Path file = temporaryFolder.newFile("team-mailbox.jsonl").toPath(); FileAgentTeamMessageBus first = new FileAgentTeamMessageBus(file); first.publish(AgentTeamMessage.builder() .id("m1") .fromMemberId("architect") .toMemberId("backend") .type("peer.message") .taskId("architecture") .content("API schema ready") .createdAt(1L) .build()); first.publish(AgentTeamMessage.builder() .id("m2") .fromMemberId("backend") .toMemberId("*") .type("peer.broadcast") .taskId("backend") .content("openapi updated") .createdAt(2L) .build()); FileAgentTeamMessageBus second = new FileAgentTeamMessageBus(file); Assert.assertEquals(2, second.snapshot().size()); Assert.assertEquals(2, second.historyFor("backend", 10).size()); second.restore(Arrays.asList( AgentTeamMessage.builder() .id("m3") .fromMemberId("qa") .toMemberId("lead") .type("task.result") .taskId("qa") .content("qa plan ready") .createdAt(3L) .build() )); FileAgentTeamMessageBus third = new FileAgentTeamMessageBus(file); Assert.assertEquals(1, third.snapshot().size()); Assert.assertEquals("m3", third.snapshot().get(0).getId()); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/HandoffPolicyTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.subagent.HandoffFailureAction; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry; import io.github.lnyocly.ai4j.agent.subagent.SubAgentToolExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; public class HandoffPolicyTest { @Test public void testAllowedToolsPolicyDeniesUnexpectedSubagent() throws Exception { SubAgentDefinition definition = SubAgentDefinition.builder() .name("writer") .toolName("delegate_writer") .description("Write output") .agent(staticOutputAgent("writer-ok")) .build(); Agent manager = io.github.lnyocly.ai4j.agent.Agents.react() .modelClient(queueModelClient( toolCallResult("call_1", "delegate_writer", "{\"task\":\"x\"}") )) .model("manager") .subAgent(definition) .handoffPolicy(HandoffPolicy.builder() .allowedTools(Collections.singleton("delegate_other")) .build()) .build(); try { manager.run(io.github.lnyocly.ai4j.agent.AgentRequest.builder().input("go").build()); Assert.fail("Expected handoff denied"); } catch (IllegalStateException ex) { Assert.assertTrue(ex.getMessage().contains("allowedTools")); } } @Test public void testDenyCanFallbackToPrimaryExecutor() throws Exception { SubAgentRegistry registry = new NoopSubAgentRegistry("delegate_writer"); ToolExecutor fallback = call -> "fallback-ok"; SubAgentToolExecutor executor = new SubAgentToolExecutor( registry, fallback, HandoffPolicy.builder() .allowedTools(Collections.singleton("delegate_other")) .onDenied(HandoffFailureAction.FALLBACK_TO_PRIMARY) .build() ); String output = executor.execute(AgentToolCall.builder().name("delegate_writer").arguments("{}").build()); Assert.assertEquals("fallback-ok", output); } @Test public void testRetryRecoversTransientSubagentFailure() throws Exception { AtomicInteger attempts = new AtomicInteger(); SubAgentRegistry flakyRegistry = new SubAgentRegistry() { @Override public List getTools() { return Collections.emptyList(); } @Override public boolean supports(String toolName) { return "delegate_flaky".equals(toolName); } @Override public String execute(AgentToolCall call) { int current = attempts.incrementAndGet(); if (current == 1) { throw new RuntimeException("temporary error"); } return "ok-after-retry"; } }; SubAgentToolExecutor executor = new SubAgentToolExecutor( flakyRegistry, null, HandoffPolicy.builder().maxRetries(1).build() ); String output = executor.execute(AgentToolCall.builder().name("delegate_flaky").arguments("{}").build()); Assert.assertEquals("ok-after-retry", output); Assert.assertEquals(2, attempts.get()); } @Test public void testTimeoutCanFallbackToPrimaryExecutor() throws Exception { SubAgentRegistry slowRegistry = new SubAgentRegistry() { @Override public List getTools() { return Collections.emptyList(); } @Override public boolean supports(String toolName) { return "delegate_slow".equals(toolName); } @Override public String execute(AgentToolCall call) { try { Thread.sleep(80L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } return "slow-output"; } }; ToolExecutor fallback = call -> "timeout-fallback"; SubAgentToolExecutor executor = new SubAgentToolExecutor( slowRegistry, fallback, HandoffPolicy.builder() .timeoutMillis(10L) .onError(HandoffFailureAction.FALLBACK_TO_PRIMARY) .build() ); String output = executor.execute(AgentToolCall.builder().name("delegate_slow").arguments("{}").build()); Assert.assertEquals("timeout-fallback", output); } @Test public void testInputFilterCanRewriteDelegatedArguments() throws Exception { AtomicReference capturedArgs = new AtomicReference<>(); SubAgentRegistry registry = new SubAgentRegistry() { @Override public List getTools() { return Collections.emptyList(); } @Override public boolean supports(String toolName) { return "delegate_filtered".equals(toolName); } @Override public String execute(AgentToolCall call) { capturedArgs.set(call.getArguments()); return "filtered-ok"; } }; SubAgentToolExecutor executor = new SubAgentToolExecutor( registry, null, HandoffPolicy.builder() .inputFilter(call -> AgentToolCall.builder() .name(call.getName()) .callId(call.getCallId()) .arguments("{\"task\":\"filtered\"}") .build()) .build() ); String output = executor.execute(AgentToolCall.builder() .name("delegate_filtered") .callId("f1") .arguments("{\"task\":\"original\"}") .build()); Assert.assertEquals("filtered-ok", output); Assert.assertEquals("{\"task\":\"filtered\"}", capturedArgs.get()); } @Test public void testNestedHandoffBlockedByMaxDepth() throws Exception { SubAgentDefinition leaf = SubAgentDefinition.builder() .name("leaf") .toolName("delegate_leaf") .description("Leaf") .agent(staticOutputAgent("leaf-done")) .build(); Agent child = io.github.lnyocly.ai4j.agent.Agents.react() .modelClient(queueModelClient( toolCallResult("child_1", "delegate_leaf", "{\"task\":\"nested\"}"), textResult("child-final") )) .model("child") .subAgent(leaf) .handoffPolicy(HandoffPolicy.builder().maxDepth(1).build()) .build(); SubAgentDefinition childDefinition = SubAgentDefinition.builder() .name("child") .toolName("delegate_child") .description("Child") .agent(child) .build(); Agent parent = io.github.lnyocly.ai4j.agent.Agents.react() .modelClient(queueModelClient( toolCallResult("parent_1", "delegate_child", "{\"task\":\"outer\"}") )) .model("parent") .subAgent(childDefinition) .handoffPolicy(HandoffPolicy.builder().maxDepth(1).build()) .build(); try { parent.run(io.github.lnyocly.ai4j.agent.AgentRequest.builder().input("start").build()); Assert.fail("Expected max depth violation"); } catch (IllegalStateException ex) { Assert.assertTrue(ex.getMessage().contains("maxDepth")); } } private Agent staticOutputAgent(String output) { return io.github.lnyocly.ai4j.agent.Agents.react() .modelClient(queueModelClient(textResult(output))) .model("static-model") .build(); } private QueueModelClient queueModelClient(io.github.lnyocly.ai4j.agent.model.AgentModelResult... results) { return new QueueModelClient(Arrays.asList(results)); } private io.github.lnyocly.ai4j.agent.model.AgentModelResult textResult(String text) { return io.github.lnyocly.ai4j.agent.model.AgentModelResult.builder() .outputText(text) .toolCalls(new ArrayList<>()) .memoryItems(new ArrayList<>()) .build(); } private io.github.lnyocly.ai4j.agent.model.AgentModelResult toolCallResult(String callId, String toolName, String args) { return io.github.lnyocly.ai4j.agent.model.AgentModelResult.builder() .toolCalls(Arrays.asList(io.github.lnyocly.ai4j.agent.tool.AgentToolCall.builder() .callId(callId) .name(toolName) .arguments(args) .type("function_call") .build())) .memoryItems(new ArrayList<>()) .build(); } private static class QueueModelClient implements io.github.lnyocly.ai4j.agent.model.AgentModelClient { private final java.util.Deque queue; private QueueModelClient(List results) { this.queue = new java.util.ArrayDeque<>(results); } @Override public io.github.lnyocly.ai4j.agent.model.AgentModelResult create(io.github.lnyocly.ai4j.agent.model.AgentPrompt prompt) { return queue.isEmpty() ? io.github.lnyocly.ai4j.agent.model.AgentModelResult.builder().build() : queue.poll(); } @Override public io.github.lnyocly.ai4j.agent.model.AgentModelResult createStream(io.github.lnyocly.ai4j.agent.model.AgentPrompt prompt, io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } private static class NoopSubAgentRegistry implements SubAgentRegistry { private final String toolName; private NoopSubAgentRegistry(String toolName) { this.toolName = toolName; } @Override public List getTools() { return Collections.emptyList(); } @Override public boolean supports(String toolName) { return this.toolName.equals(toolName); } @Override public String execute(AgentToolCall call) { return "subagent-output"; } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/MinimaxAgentTeamTravelUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.config.MinimaxConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; public class MinimaxAgentTeamTravelUsageTest { private static final String DEFAULT_MODEL = "MiniMax-M2.7"; private ChatModelClient modelClient; private String model; @Before public void setupMinimaxClient() { String apiKey = readValue("MINIMAX_API_KEY", "minimax.api.key"); Assume.assumeTrue("Skip because MiniMax API key is not configured", !isBlank(apiKey)); model = readValue("MINIMAX_MODEL", "minimax.model"); if (isBlank(model)) { model = DEFAULT_MODEL; } Configuration configuration = new Configuration(); MinimaxConfig minimaxConfig = new MinimaxConfig(); minimaxConfig.setApiKey(apiKey); configuration.setMinimaxConfig(minimaxConfig); configuration.setOkHttpClient(createHttpClient()); AiService aiService = new AiService(configuration); modelClient = new ChatModelClient(aiService.getChatService(PlatformType.MINIMAX)); } @Test public void test_travel_demo_delivery_team_with_minimax() throws Exception { AgentTeam team = Agents.team() .planner((objective, members, options) -> AgentTeamPlan.builder() .rawPlanText("travel-demo-fixed-plan") .tasks(Arrays.asList( AgentTeamTask.builder() .id("product") .memberId("product") .task("Define the MVP for a travel planning demo app, target users, user stories, and acceptance criteria.") .context("Output should include scope, features, non-goals, and release criteria. Objective: " + safe(objective)) .build(), AgentTeamTask.builder() .id("architecture") .memberId("architect") .task("Design the system architecture, backend/frontend boundaries, and delivery sequence for the travel demo app.") .context("Base the design on the product scope. Produce module boundaries, deployment shape, and API contract outline.") .dependsOn(Arrays.asList("product")) .build(), AgentTeamTask.builder() .id("backend") .memberId("backend") .task("Design backend APIs, data model, and implementation plan for itinerary planning, destination search, and booking summary.") .context("Produce concrete REST endpoints, request/response examples, and persistence model.") .dependsOn(Arrays.asList("product", "architecture")) .build(), AgentTeamTask.builder() .id("frontend") .memberId("frontend") .task("Design frontend pages, key components, and interaction flow for the travel demo app.") .context("Cover the landing page, destination discovery, itinerary builder, and booking summary flow.") .dependsOn(Arrays.asList("product", "architecture")) .build(), AgentTeamTask.builder() .id("qa") .memberId("qa") .task("Create the QA strategy, core test matrix, and release gate for the travel demo app.") .context("Base validation on the PRD, architecture, backend API plan, and frontend flow.") .dependsOn(Arrays.asList("product", "backend", "frontend")) .build() )) .build()) .synthesizer((objective, plan, memberResults, options) -> AgentResult.builder() .outputText(renderSummary(objective, memberResults)) .build()) .member(member( "product", "Product Manager", "Owns product scope, user value, and acceptance criteria.", "You are the product manager for a travel demo application.\n" + "Deliver a concise but concrete PRD-style output with: target users, user stories, MVP scope, non-goals, and release acceptance criteria.\n" + "Keep the result structured and implementation-ready." )) .member(member( "architect", "Architecture Analyst", "Owns system boundaries, technical design, and delivery sequencing.", "You are the architecture analyst for a travel demo application.\n" + "Deliver the technical architecture, core modules, backend/frontend boundaries, data flow, and implementation milestones.\n" + "Reference the product scope and write for engineers." )) .member(member( "backend", "Backend Engineer", "Owns APIs, domain model, and backend delivery plan.", "You are the backend engineer for a travel demo application.\n" + "Produce concrete REST APIs, data models, service modules, and implementation notes for itinerary planning, destination search, and booking summary.\n" + "Prefer production-like API shape and clear request/response examples." )) .member(member( "frontend", "Frontend Engineer", "Owns user flows, pages, and component structure.", "You are the frontend engineer for a travel demo application.\n" + "Produce the page map, component breakdown, UI states, and API integration points for the main flows.\n" + "Focus on a realistic MVP that a React web app could implement." )) .member(member( "qa", "QA Engineer", "Owns verification strategy and release quality gate.", "You are the QA engineer for a travel demo application.\n" + "Produce the test strategy, scenario matrix, risk-based priorities, and release criteria.\n" + "Cover happy path, validation, API failure, and regression scope." )) .options(AgentTeamOptions.builder() .parallelDispatch(true) .maxConcurrency(3) .enableMessageBus(true) .includeMessageHistoryInDispatch(true) .enableMemberTeamTools(true) .maxRounds(12) .build()) .build(); AgentTeamResult result = callWithProviderGuard(new ThrowingSupplier() { @Override public AgentTeamResult get() throws Exception { return team.run(AgentRequest.builder() .input("Create a concrete delivery package for a travel planning demo app that helps users discover destinations, build itineraries, and review booking summaries.") .build()); } }); printResult(result); Assert.assertNotNull(result); Assert.assertNotNull(result.getPlan()); Assert.assertEquals(5, result.getPlan().getTasks().size()); Assert.assertEquals(5, result.getTaskStates().size()); Assert.assertTrue("Team tasks did not all complete: " + describeTaskStates(result.getTaskStates()), allTasksCompleted(result.getTaskStates())); Assert.assertTrue(result.getMemberResults().size() >= 5); Assert.assertNotNull(result.getOutput()); Assert.assertTrue(result.getOutput().contains("[Product]")); Assert.assertTrue(result.getOutput().contains("[Architect]")); Assert.assertTrue(result.getOutput().contains("[Backend]")); Assert.assertTrue(result.getOutput().contains("[Frontend]")); Assert.assertTrue(result.getOutput().contains("[QA]")); for (AgentTeamMemberResult memberResult : result.getMemberResults()) { Assert.assertNotNull(memberResult); Assert.assertTrue("member result should succeed: " + memberResult.getMemberId(), memberResult.isSuccess()); Assert.assertTrue("member output should not be blank: " + memberResult.getMemberId(), !isBlank(memberResult.getOutput())); } } private AgentTeamMember member(String id, String name, String description, String systemPrompt) { return AgentTeamMember.builder() .id(id) .name(name) .description(description) .agent(Agents.builder() .modelClient(modelClient) .model(model) .temperature(0.4) .systemPrompt(systemPrompt) .options(AgentOptions.builder() .maxSteps(4) .stream(false) .build()) .build()) .build(); } private OkHttpClient createHttpClient() { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BASIC); return new OkHttpClient.Builder() .addInterceptor(logging) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .build(); } private String renderSummary(String objective, List memberResults) { Map outputs = new LinkedHashMap(); if (memberResults != null) { for (AgentTeamMemberResult item : memberResults) { if (item == null || isBlank(item.getMemberId())) { continue; } outputs.put(item.getMemberId(), item.isSuccess() ? safe(item.getOutput()) : "FAILED: " + safe(item.getError())); } } StringBuilder builder = new StringBuilder(); builder.append("Travel Demo Team Summary\n"); builder.append("Objective: ").append(safe(objective)).append("\n\n"); appendSection(builder, "Product", outputs.get("product")); appendSection(builder, "Architect", outputs.get("architect")); appendSection(builder, "Backend", outputs.get("backend")); appendSection(builder, "Frontend", outputs.get("frontend")); appendSection(builder, "QA", outputs.get("qa")); return builder.toString().trim(); } private void appendSection(StringBuilder builder, String title, String output) { builder.append('[').append(title).append("]\n"); builder.append(safe(output)).append("\n\n"); } private boolean allTasksCompleted(List states) { if (states == null || states.isEmpty()) { return false; } for (AgentTeamTaskState state : states) { if (state == null || state.getStatus() != AgentTeamTaskStatus.COMPLETED) { return false; } } return true; } private String describeTaskStates(List states) { if (states == null || states.isEmpty()) { return "[]"; } StringBuilder sb = new StringBuilder("["); for (int i = 0; i < states.size(); i++) { AgentTeamTaskState state = states.get(i); if (i > 0) { sb.append(", "); } if (state == null) { sb.append("null"); continue; } sb.append(state.getTaskId()).append(":").append(state.getStatus()); if (!isBlank(state.getClaimedBy())) { sb.append("@").append(state.getClaimedBy()); } if (!isBlank(state.getError())) { sb.append("(error=").append(state.getError()).append(")"); } } sb.append("]"); return sb.toString(); } private void printResult(AgentTeamResult result) { System.out.println("==== TEAM PLAN ===="); if (result != null && result.getPlan() != null && result.getPlan().getTasks() != null) { for (AgentTeamTask task : result.getPlan().getTasks()) { System.out.println(task.getId() + " -> " + task.getMemberId() + " | dependsOn=" + task.getDependsOn()); } } System.out.println("==== TASK STATES ===="); if (result != null && result.getTaskStates() != null) { for (AgentTeamTaskState state : result.getTaskStates()) { System.out.println(state.getTaskId() + " => " + state.getStatus() + " by " + state.getClaimedBy()); } } System.out.println("==== TEAM MESSAGES ===="); if (result != null && result.getMessages() != null) { for (int i = 0; i < result.getMessages().size(); i++) { System.out.println(result.getMessages().get(i)); } } System.out.println("==== MEMBER OUTPUTS ===="); if (result != null && result.getMemberResults() != null) { for (AgentTeamMemberResult memberResult : result.getMemberResults()) { System.out.println("[" + memberResult.getMemberId() + "] success=" + memberResult.isSuccess()); System.out.println(safe(memberResult.getOutput())); System.out.println(); } } System.out.println("==== FINAL OUTPUT ===="); System.out.println(result == null ? "" : result.getOutput()); } private T callWithProviderGuard(ThrowingSupplier supplier) throws Exception { try { return supplier.get(); } catch (Exception ex) { skipIfProviderUnavailable(ex); throw ex; } } private void skipIfProviderUnavailable(Throwable throwable) { if (isProviderUnavailable(throwable)) { Assume.assumeTrue("Skip due provider limit/unavailable: " + extractRootMessage(throwable), false); } } private boolean isProviderUnavailable(Throwable throwable) { Throwable current = throwable; while (current != null) { String message = current.getMessage(); if (!isBlank(message)) { String lower = message.toLowerCase(); if (lower.contains("timeout") || lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("quota") || lower.contains("tool arguments must be a json object") || message.contains("频次") || message.contains("限流") || message.contains("额度") || message.contains("配额") || message.contains("账户已达到")) { return true; } } current = current.getCause(); } return false; } private String extractRootMessage(Throwable throwable) { Throwable current = throwable; Throwable last = throwable; while (current != null) { last = current; current = current.getCause(); } return last == null || isBlank(last.getMessage()) ? "unknown error" : last.getMessage(); } private String readValue(String envKey, String propertyKey) { String value = envKey == null ? null : System.getenv(envKey); if (isBlank(value) && propertyKey != null) { value = System.getProperty(propertyKey); } return value; } private String safe(String value) { return isBlank(value) ? "" : value.trim(); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } @FunctionalInterface private interface ThrowingSupplier { T get() throws Exception; } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/NashornCodeExecutorTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult; import io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.junit.Assert; import org.junit.Assume; import org.junit.Test; import javax.script.ScriptEngineManager; import java.util.Arrays; public class NashornCodeExecutorTest { @Test public void test_nashorn_executor_with_tool_alias() throws Exception { Assume.assumeTrue("Nashorn is not available", isNashornAvailable()); NashornCodeExecutor executor = new NashornCodeExecutor(); CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder() .language("js") .toolNames(Arrays.asList("echo")) .code("var value = echo({text: 'hi'}); __codeact_result = 'ok:' + value;") .toolExecutor(new ToolExecutor() { @Override public String execute(AgentToolCall call) { return call.getName() + ":" + call.getArguments(); } }) .build()); Assert.assertTrue(result.isSuccess()); Assert.assertNotNull(result.getResult()); Assert.assertTrue(result.getResult().contains("ok:echo:")); } @Test public void test_nashorn_parses_json_tool_result() throws Exception { Assume.assumeTrue("Nashorn is not available", isNashornAvailable()); NashornCodeExecutor executor = new NashornCodeExecutor(); CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder() .language("js") .toolNames(Arrays.asList("queryWeather")) .code("var data = queryWeather({location:'Beijing',type:'daily',days:1}); __codeact_result = data.results[0].daily[0].text_day;") .toolExecutor(new ToolExecutor() { @Override public String execute(AgentToolCall call) { return "\"{\\\"results\\\":[{\\\"daily\\\":[{\\\"text_day\\\":\\\"Sunny\\\"}]}]}\""; } }) .build()); Assert.assertTrue(result.isSuccess()); Assert.assertEquals("Sunny", result.getResult()); } @Test public void test_nashorn_uses_return_value_when_codeact_result_not_set() throws Exception { Assume.assumeTrue("Nashorn is not available", isNashornAvailable()); NashornCodeExecutor executor = new NashornCodeExecutor(); CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder() .language("js") .code("return 'done';") .build()); Assert.assertTrue(result.isSuccess()); Assert.assertEquals("done", result.getResult()); } @Test public void test_nashorn_rejects_python_language() throws Exception { NashornCodeExecutor executor = new NashornCodeExecutor(); CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder() .language("python") .code("print('hello')") .build()); Assert.assertFalse(result.isSuccess()); Assert.assertTrue(result.getError().contains("only javascript is enabled")); } private boolean isNashornAvailable() { return new ScriptEngineManager().getEngineByName("nashorn") != null; } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/ReActAgentUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.agent.support.ZhipuAgentTestSupport; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.event.AgentEventPublisher; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.List; public class ReActAgentUsageTest extends ZhipuAgentTestSupport { @Test public void test_react_agent_basic_run_with_real_model() throws Exception { // ReAct 基础能力:真实模型完成多步推理并返回文本 Agent agent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是架构顾问。先给结论,再给3条执行建议。") .options(AgentOptions.builder().maxSteps(3).build()) .build(); AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input("我们要把单体应用拆到微服务,先从哪里开始?").build())); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); } @Test public void test_react_agent_parallel_tool_calls_and_event_lifecycle() throws Exception { // 监听运行事件,验证 ReAct 生命周期事件可观测 List events = new ArrayList(); AgentEventPublisher publisher = new AgentEventPublisher(); publisher.addListener(event -> events.add(event.getType())); Agent agent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .parallelToolCalls(true) .systemPrompt("你是技术评审助手,输出精炼结论。") .instructions("围绕可测试性和可维护性给建议。") .eventPublisher(publisher) .options(AgentOptions.builder().maxSteps(3).build()) .build(); AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input("请点评当前项目的测试策略并给改进建议").build())); Assert.assertNotNull(result); Assert.assertTrue(events.contains(AgentEventType.STEP_START)); Assert.assertTrue(events.contains(AgentEventType.MODEL_REQUEST)); Assert.assertTrue(events.contains(AgentEventType.MODEL_RESPONSE)); Assert.assertTrue(events.contains(AgentEventType.FINAL_OUTPUT)); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/ResponsesModelClientTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.listener.ResponseSseListener; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.platform.openai.response.entity.Response; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse; import io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest; import io.github.lnyocly.ai4j.service.IResponsesService; import org.junit.Assert; import org.junit.Test; public class ResponsesModelClientTest { @Test public void test_stream_propagates_stream_execution_options() throws Exception { CapturingResponsesService responsesService = new CapturingResponsesService(); ResponsesModelClient client = new ResponsesModelClient(responsesService); client.createStream(AgentPrompt.builder() .model("gpt-5-mini") .streamExecution(StreamExecutionOptions.builder() .firstTokenTimeoutMs(4321L) .idleTimeoutMs(8765L) .maxRetries(3) .retryBackoffMs(120L) .build()) .build(), new AgentModelStreamListener() { }); Assert.assertNotNull(responsesService.lastRequest); Assert.assertNotNull(responsesService.lastRequest.getStreamExecution()); Assert.assertEquals(4321L, responsesService.lastRequest.getStreamExecution().getFirstTokenTimeoutMs()); Assert.assertEquals(8765L, responsesService.lastRequest.getStreamExecution().getIdleTimeoutMs()); Assert.assertEquals(3, responsesService.lastRequest.getStreamExecution().getMaxRetries()); Assert.assertEquals(120L, responsesService.lastRequest.getStreamExecution().getRetryBackoffMs()); } private static final class CapturingResponsesService implements IResponsesService { private ResponseRequest lastRequest; @Override public Response create(String baseUrl, String apiKey, ResponseRequest request) { throw new UnsupportedOperationException("not used"); } @Override public Response create(ResponseRequest request) { throw new UnsupportedOperationException("not used"); } @Override public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) { lastRequest = request; } @Override public void createStream(ResponseRequest request, ResponseSseListener listener) { lastRequest = request; } @Override public Response retrieve(String baseUrl, String apiKey, String responseId) { throw new UnsupportedOperationException("not used"); } @Override public Response retrieve(String responseId) { throw new UnsupportedOperationException("not used"); } @Override public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) { throw new UnsupportedOperationException("not used"); } @Override public ResponseDeleteResponse delete(String responseId) { throw new UnsupportedOperationException("not used"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/StateGraphWorkflowTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.agent.workflow.AgentNode; import io.github.lnyocly.ai4j.agent.workflow.StateGraphWorkflow; import io.github.lnyocly.ai4j.agent.workflow.WorkflowContext; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.TimeUnit; public class StateGraphWorkflowTest { private AiService aiService; @Before public void init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getProperty("doubao.api.key"); } Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } @Test public void test_branching_route() throws Exception { StateGraphWorkflow workflow = new StateGraphWorkflow() .addNode("start", new StaticNode("cold")) .addNode("cold", new StaticNode("take_coat")) .addNode("warm", new StaticNode("t_shirt")) .start("start") .addConditionalEdges("start", (context, request, result) -> { return "cold".equals(result.getOutputText()) ? "cold" : "warm"; }); AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder().input("go").build()); Assert.assertEquals("take_coat", result.getOutputText()); } @Test public void test_loop_route() throws Exception { AtomicInteger counter = new AtomicInteger(0); StateGraphWorkflow workflow = new StateGraphWorkflow() .addNode("loop", new CounterNode(counter)) .addNode("done", new StaticNode("done")) .start("loop") .maxSteps(10) .addConditionalEdges("loop", (context, request, result) -> { Object value = context.get("count"); if (value instanceof Integer && ((Integer) value) < 3) { return "loop"; } return "done"; }); AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder().input("start").build()); Assert.assertEquals("done", result.getOutputText()); Assert.assertEquals(3, counter.get()); } @Test public void test_state_graph_with_agents() throws Exception { Agent routerAgent = Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You are a router. Output only ROUTE_WEATHER or ROUTE_GENERIC.") .instructions("If the user asks about weather or temperature, output ROUTE_WEATHER. Otherwise output ROUTE_GENERIC.") .options(AgentOptions.builder().maxSteps(1).build()) .build(); Agent weatherAgent = Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You are a weather assistant. Always call queryWeather before answering.") .instructions("Use queryWeather with the user's location, type=now, days=1.") .toolRegistry(Arrays.asList("queryWeather"), null) .options(AgentOptions.builder().maxSteps(2).build()) .build(); Agent genericAgent = Agents.react() .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You answer general questions in one short sentence.") .options(AgentOptions.builder().maxSteps(1).build()) .build(); Agent formatAgent = Agents.react() .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You format responses into JSON.") .instructions("Return JSON with fields: route, answer.") .options(AgentOptions.builder().maxSteps(1).build()) .build(); StateGraphWorkflow workflow = new StateGraphWorkflow() .addNode("decide", new NamedNode("Decide", new RoutingAgentNode(routerAgent.newSession()))) .addNode("weather", new NamedNode("Weather", new RuntimeAgentNode(weatherAgent.newSession()))) .addNode("generic", new NamedNode("Generic", new RuntimeAgentNode(genericAgent.newSession()))) .addNode("format", new NamedNode("Format", new FormatAgentNode(formatAgent.newSession()))) .start("decide") .addConditionalEdges("decide", (context, request, result) -> String.valueOf(context.get("route"))) .addEdge("weather", "format") .addEdge("generic", "format"); AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder() .input("What is the weather in Beijing today?") .build()); System.out.println("FINAL OUTPUT: " + result.getOutputText()); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().contains("\"route\"")); } private static class StaticNode implements AgentNode { private final String output; private StaticNode(String output) { this.output = output; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) { return AgentResult.builder().outputText(output).build(); } } private static class CounterNode implements AgentNode { private final AtomicInteger counter; private CounterNode(AtomicInteger counter) { this.counter = counter; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) { int current = counter.incrementAndGet(); context.put("count", current); return AgentResult.builder().outputText("count=" + current).build(); } } private static class NamedNode implements AgentNode { private final String name; private final AgentNode delegate; private NamedNode(String name, AgentNode delegate) { this.name = name; this.delegate = delegate; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception { System.out.println("NODE START: " + name); try { AgentResult result = delegate.execute(context, request); System.out.println("NODE END: " + name + " | status=OK"); return result; } catch (Exception e) { System.out.println("NODE END: " + name + " | status=ERROR"); throw e; } } } private static class RoutingAgentNode implements AgentNode { private final AgentSession session; private RoutingAgentNode(AgentSession session) { this.session = session; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception { AgentResult result = session.run(request); String output = result == null ? null : result.getOutputText(); if (output != null && output.contains("ROUTE_WEATHER")) { context.put("route", "weather"); } else { context.put("route", "generic"); } return AgentResult.builder() .outputText(request == null || request.getInput() == null ? null : String.valueOf(request.getInput())) .rawResponse(result == null ? null : result.getRawResponse()) .build(); } } private static class RuntimeAgentNode implements AgentNode { private final AgentSession session; private RuntimeAgentNode(AgentSession session) { this.session = session; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception { return session.run(request); } } private static class FormatAgentNode implements AgentNode { private final AgentSession session; private FormatAgentNode(AgentSession session) { this.session = session; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception { String route = context.get("route") == null ? "unknown" : String.valueOf(context.get("route")); String input = request == null || request.getInput() == null ? "" : String.valueOf(request.getInput()); AgentRequest formatted = AgentRequest.builder() .input("route=" + route + "\nanswer=" + input) .build(); return session.run(formatted); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/SubAgentParallelFallbackTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.subagent.HandoffFailureAction; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.junit.Assert; import org.junit.Test; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; public class SubAgentParallelFallbackTest { @Test public void test_parallel_subagents_with_fallback_policy() throws Exception { ConcurrencyTracker tracker = new ConcurrencyTracker(); Agent weatherSubAgent = Agents.react() .modelClient(new TimedSubAgentModelClient(tracker, 180L, "weather-ok", false)) .model("weather-model") .build(); Agent formatSubAgent = Agents.react() .modelClient(new TimedSubAgentModelClient(tracker, 180L, null, true)) .model("format-model") .build(); SubAgentDefinition weatherDefinition = SubAgentDefinition.builder() .name("weather-agent") .toolName("delegate_weather") .description("Collect weather data") .agent(weatherSubAgent) .build(); SubAgentDefinition formatDefinition = SubAgentDefinition.builder() .name("format-agent") .toolName("delegate_format") .description("Format weather result") .agent(formatSubAgent) .build(); ScriptedModelClient managerClient = new ScriptedModelClient(); managerClient.enqueue(toolCallsResult(Arrays.asList( AgentToolCall.builder().callId("c1").name("delegate_weather").arguments("{\"task\":\"Beijing\"}").type("function_call").build(), AgentToolCall.builder().callId("c2").name("delegate_format").arguments("{\"task\":\"format\"}").type("function_call").build() ))); managerClient.enqueue(textResult("manager-complete")); ToolExecutor fallbackExecutor = call -> "{\"fallback\":true,\"tool\":\"" + call.getName() + "\"}"; Agent manager = Agents.react() .modelClient(managerClient) .model("manager-model") .parallelToolCalls(true) .subAgents(Arrays.asList(weatherDefinition, formatDefinition)) .handoffPolicy(HandoffPolicy.builder() .onError(HandoffFailureAction.FALLBACK_TO_PRIMARY) .maxRetries(0) .build()) .toolExecutor(fallbackExecutor) .build(); AgentResult result = manager.run(AgentRequest.builder().input("run").build()); Assert.assertEquals("manager-complete", result.getOutputText()); Assert.assertEquals(2, result.getToolCalls().size()); Assert.assertEquals(2, result.getToolResults().size()); Map resultByCallId = toResultMap(result.getToolResults()); Assert.assertTrue(resultByCallId.get("c1").contains("weather-ok")); Assert.assertTrue(resultByCallId.get("c2").contains("\"fallback\":true")); Assert.assertTrue("expected parallel subagent execution", tracker.maxConcurrent.get() >= 2); } private static Map toResultMap(List toolResults) { Map map = new HashMap<>(); if (toolResults == null) { return map; } for (AgentToolResult result : toolResults) { map.put(result.getCallId(), result.getOutput()); } return map; } private static AgentModelResult textResult(String text) { return AgentModelResult.builder() .outputText(text) .toolCalls(new ArrayList<>()) .memoryItems(new ArrayList<>()) .build(); } private static AgentModelResult toolCallsResult(List calls) { return AgentModelResult.builder() .toolCalls(calls) .memoryItems(new ArrayList<>()) .build(); } private static class ScriptedModelClient implements AgentModelClient { private final Deque queue = new ArrayDeque<>(); private void enqueue(AgentModelResult result) { queue.add(result); } @Override public AgentModelResult create(AgentPrompt prompt) { return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } private static class TimedSubAgentModelClient implements AgentModelClient { private final ConcurrencyTracker tracker; private final long sleepMs; private final String output; private final boolean shouldFail; private TimedSubAgentModelClient(ConcurrencyTracker tracker, long sleepMs, String output, boolean shouldFail) { this.tracker = tracker; this.sleepMs = sleepMs; this.output = output; this.shouldFail = shouldFail; } @Override public AgentModelResult create(AgentPrompt prompt) { int active = tracker.active.incrementAndGet(); tracker.maxConcurrent.accumulateAndGet(active, Math::max); try { Thread.sleep(sleepMs); if (shouldFail) { throw new RuntimeException("subagent-failure"); } return textResult(output); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { tracker.active.decrementAndGet(); } } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } private static class ConcurrencyTracker { private final AtomicInteger active = new AtomicInteger(); private final AtomicInteger maxConcurrent = new AtomicInteger(); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/SubAgentRuntimeTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventPublisher; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.junit.Assert; import org.junit.Test; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; public class SubAgentRuntimeTest { @Test public void testSubagentToolDelegationAndExposure() throws Exception { ScriptedModelClient reviewerClient = new ScriptedModelClient(); reviewerClient.enqueue(resultWithText("subagent-review-ready")); Agent reviewer = Agents.react() .modelClient(reviewerClient) .model("reviewer-model") .build(); String subToolName = "delegate_code_review"; SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder() .name("code-reviewer") .description("Review code quality and risks") .toolName(subToolName) .agent(reviewer) .build(); ScriptedModelClient parentClient = new ScriptedModelClient(); parentClient.enqueue(resultWithToolCall("call_1", subToolName, "{\"task\":\"Review the auth flow\"}")); parentClient.enqueue(resultWithText("main-final-answer")); Agent parent = Agents.react() .modelClient(parentClient) .model("manager-model") .subAgent(reviewerSubAgent) .build(); AgentResult result = parent.run(AgentRequest.builder().input("analyze").build()); Assert.assertEquals("main-final-answer", result.getOutputText()); Assert.assertEquals(1, result.getToolResults().size()); Assert.assertTrue(result.getToolResults().get(0).getOutput().contains("subagent-review-ready")); AgentPrompt firstPrompt = parentClient.prompts.get(0); Assert.assertTrue(hasTool(firstPrompt.getTools(), subToolName)); } @Test public void testParallelSubagentExecutionForCodexStyleDelegation() throws Exception { ConcurrentModelClient sharedSubClient = new ConcurrentModelClient(250L, "subagent-output"); Agent weatherSubAgent = Agents.react() .modelClient(sharedSubClient) .model("sub-model") .build(); Agent formatSubAgent = Agents.react() .modelClient(sharedSubClient) .model("sub-model") .build(); SubAgentDefinition weather = SubAgentDefinition.builder() .name("weather") .toolName("delegate_weather") .description("Collect weather details") .agent(weatherSubAgent) .build(); SubAgentDefinition format = SubAgentDefinition.builder() .name("format") .toolName("delegate_format") .description("Format weather report") .agent(formatSubAgent) .build(); ScriptedModelClient parentClient = new ScriptedModelClient(); parentClient.enqueue(resultWithToolCalls(Arrays.asList( AgentToolCall.builder().callId("c_weather").name("delegate_weather").arguments("{\"task\":\"Beijing\"}").build(), AgentToolCall.builder().callId("c_format").name("delegate_format").arguments("{\"task\":\"Shanghai\"}").build() ))); parentClient.enqueue(resultWithText("done")); Agent parent = Agents.react() .modelClient(parentClient) .model("manager-model") .parallelToolCalls(true) .subAgents(Arrays.asList(weather, format)) .build(); AgentResult result = parent.run(AgentRequest.builder().input("parallel").build()); Assert.assertEquals("done", result.getOutputText()); Assert.assertTrue("expected parallel subagent execution", sharedSubClient.maxConcurrent.get() >= 2); } @Test public void testSubagentHandoffEventsArePublished() throws Exception { ScriptedModelClient reviewerClient = new ScriptedModelClient(); reviewerClient.enqueue(resultWithText("subagent-review-ready")); Agent reviewer = Agents.react() .modelClient(reviewerClient) .model("reviewer-model") .build(); SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder() .name("code-reviewer") .description("Review code quality and risks") .toolName("delegate_code_review") .agent(reviewer) .build(); ScriptedModelClient parentClient = new ScriptedModelClient(); parentClient.enqueue(resultWithToolCall("call_1", "delegate_code_review", "{\"task\":\"Review the auth flow\"}")); parentClient.enqueue(resultWithText("main-final-answer")); final List events = new ArrayList(); AgentEventPublisher publisher = new AgentEventPublisher(); publisher.addListener(new AgentListener() { @Override public void onEvent(AgentEvent event) { events.add(event); } }); Agent parent = Agents.react() .modelClient(parentClient) .model("manager-model") .subAgent(reviewerSubAgent) .eventPublisher(publisher) .build(); AgentResult result = parent.run(AgentRequest.builder().input("analyze").build()); Assert.assertEquals("main-final-answer", result.getOutputText()); AgentEvent startEvent = firstEvent(events, AgentEventType.HANDOFF_START); AgentEvent endEvent = firstEvent(events, AgentEventType.HANDOFF_END); Assert.assertNotNull(startEvent); Assert.assertNotNull(endEvent); @SuppressWarnings("unchecked") Map startPayload = (Map) startEvent.getPayload(); @SuppressWarnings("unchecked") Map endPayload = (Map) endEvent.getPayload(); Assert.assertEquals("code-reviewer", startPayload.get("subagent")); Assert.assertEquals("delegate_code_review", startPayload.get("tool")); Assert.assertEquals("starting", startPayload.get("status")); Assert.assertEquals("completed", endPayload.get("status")); Assert.assertEquals("subagent-review-ready", endPayload.get("output")); } @Test public void testTeamSubagentPublishesTeamTaskEventsToParent() throws Exception { ScriptedModelClient teamMemberClient = new ScriptedModelClient(); teamMemberClient.enqueue(resultWithText("team-member-ready")); ScriptedModelClient teamSynthClient = new ScriptedModelClient(); teamSynthClient.enqueue(resultWithText("team-subagent-final")); Agent teamSubagent = Agents.team() .planner((objective, members, options) -> AgentTeamPlan.builder() .tasks(Arrays.asList( AgentTeamTask.builder() .id("review-task") .memberId("reviewer") .task("Review the patch") .build() )) .build()) .synthesizerAgent(Agents.react() .modelClient(teamSynthClient) .model("team-synth") .build()) .member(AgentTeamMember.builder() .id("reviewer") .name("Reviewer") .agent(Agents.react() .modelClient(teamMemberClient) .model("team-member") .build()) .build()) .buildAgent(); SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder() .name("team-reviewer") .description("Review code quality with a small team") .toolName("delegate_team_review") .agent(teamSubagent) .build(); ScriptedModelClient parentClient = new ScriptedModelClient(); parentClient.enqueue(resultWithToolCall("call_team", "delegate_team_review", "{\"task\":\"Review the auth flow\"}")); parentClient.enqueue(resultWithText("main-final-answer")); final List events = new ArrayList(); AgentEventPublisher publisher = new AgentEventPublisher(); publisher.addListener(new AgentListener() { @Override public void onEvent(AgentEvent event) { events.add(event); } }); Agent parent = Agents.react() .modelClient(parentClient) .model("manager-model") .subAgent(reviewerSubAgent) .eventPublisher(publisher) .build(); AgentResult result = parent.run(AgentRequest.builder().input("analyze").build()); Assert.assertEquals("main-final-answer", result.getOutputText()); Assert.assertNotNull(firstEvent(events, AgentEventType.TEAM_TASK_CREATED)); AgentEvent updated = firstEventByStatus(events, AgentEventType.TEAM_TASK_UPDATED, "completed"); Assert.assertNotNull(updated); @SuppressWarnings("unchecked") Map updatedPayload = (Map) updated.getPayload(); Assert.assertEquals("completed", updatedPayload.get("status")); Assert.assertEquals("team-member-ready", updatedPayload.get("output")); } private static AgentEvent firstEvent(List events, AgentEventType type) { if (events == null || type == null) { return null; } for (AgentEvent event : events) { if (event != null && type == event.getType()) { return event; } } return null; } private static AgentEvent firstEventByStatus(List events, AgentEventType type, String status) { if (events == null || type == null) { return null; } for (AgentEvent event : events) { if (event == null || type != event.getType() || !(event.getPayload() instanceof Map)) { continue; } @SuppressWarnings("unchecked") Map payload = (Map) event.getPayload(); Object currentStatus = payload.get("status"); if (status == null ? currentStatus == null : status.equals(String.valueOf(currentStatus))) { return event; } } return null; } private static boolean hasTool(List tools, String name) { if (tools == null) { return false; } for (Object tool : tools) { if (tool instanceof Tool) { Tool.Function fn = ((Tool) tool).getFunction(); if (fn != null && name.equals(fn.getName())) { return true; } } } return false; } private static AgentModelResult resultWithText(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(new ArrayList()) .toolCalls(new ArrayList()) .build(); } private static AgentModelResult resultWithToolCall(String callId, String toolName, String args) { return resultWithToolCalls(Arrays.asList( AgentToolCall.builder() .callId(callId) .name(toolName) .arguments(args) .type("function_call") .build() )); } private static AgentModelResult resultWithToolCalls(List calls) { return AgentModelResult.builder() .toolCalls(calls) .memoryItems(new ArrayList()) .build(); } private static class ScriptedModelClient implements AgentModelClient { private final Deque queue = new ArrayDeque<>(); private final List prompts = new ArrayList<>(); private void enqueue(AgentModelResult result) { queue.add(result); } @Override public AgentModelResult create(AgentPrompt prompt) { prompts.add(prompt); return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } private static class ConcurrentModelClient implements AgentModelClient { private final long sleepMs; private final String output; private final AtomicInteger active = new AtomicInteger(); private final AtomicInteger maxConcurrent = new AtomicInteger(); private ConcurrentModelClient(long sleepMs, String output) { this.sleepMs = sleepMs; this.output = output; } @Override public AgentModelResult create(AgentPrompt prompt) { int concurrent = active.incrementAndGet(); maxConcurrent.accumulateAndGet(concurrent, Math::max); try { Thread.sleep(sleepMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { active.decrementAndGet(); } return resultWithText(output); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { throw new UnsupportedOperationException("stream not used in test"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/SubAgentUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.agent.support.ZhipuAgentTestSupport; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.subagent.HandoffFailureAction; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import org.junit.Assert; import org.junit.Test; import java.util.Collections; public class SubAgentUsageTest extends ZhipuAgentTestSupport { @Test public void test_subagent_delegation_with_real_llm() throws Exception { // 子代理配置示例:在 Chat 模式下先验证“可挂载 + 可运行”的主流程 Agent reviewer = Agents.builder() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是代码审查专家。输出3条高风险问题。") .options(AgentOptions.builder().maxSteps(2).build()) .build(); SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder() .name("reviewer") .description("负责代码风险审查") .toolName("delegate_code_review") .agent(reviewer) .build(); Agent lead = Agents.builder() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .toolChoice("none") .systemPrompt("你是团队负责人。直接输出审查结论,不要调用任何工具。") .subAgent(reviewerSubAgent) .options(AgentOptions.builder().maxSteps(3).build()) .build(); AgentResult result = callWithProviderGuard(() -> lead.run(AgentRequest.builder().input("审查一个登录模块,重点关注安全风险").build())); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); } @Test public void test_handoff_policy_denied_with_fallback_executor() throws Exception { // HandoffPolicy 配置示例:展示 allow/deny 与 fallback 的配置方式 Agent subAgent = Agents.builder() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是子代理") .options(AgentOptions.builder().maxSteps(2).build()) .build(); SubAgentDefinition definition = SubAgentDefinition.builder() .name("planner") .description("负责规划") .toolName("delegate_planning") .agent(subAgent) .build(); Agent parent = Agents.builder() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .toolChoice("none") .toolExecutor(call -> "{\"fallback\":true,\"tool\":\"" + call.getName() + "\"}") .subAgent(definition) .handoffPolicy(HandoffPolicy.builder() .allowedTools(Collections.singleton("delegate_other")) .onDenied(HandoffFailureAction.FALLBACK_TO_PRIMARY) .build()) .systemPrompt("你是主代理。直接输出简短发布建议,不要调用任何工具。") .options(AgentOptions.builder().maxSteps(3).build()) .build(); AgentResult result = callWithProviderGuard(() -> parent.run(AgentRequest.builder().input("给一个两天发布窗口的变更建议").build())); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/ToolUtilExecutorRestrictionTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolUtilExecutor; import org.junit.Test; import java.util.Collections; public class ToolUtilExecutorRestrictionTest { @Test(expected = IllegalArgumentException.class) public void testRejectsUnknownTool() throws Exception { ToolUtilExecutor executor = new ToolUtilExecutor(Collections.singleton("allowed_tool")); AgentToolCall call = AgentToolCall.builder() .name("not_allowed") .arguments("{}") .build(); executor.execute(call); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/UniversalAgentUsageTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.agent.support.ZhipuAgentTestSupport; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.Agents; import org.junit.Assert; import org.junit.Test; import java.util.HashMap; import java.util.Map; public class UniversalAgentUsageTest extends ZhipuAgentTestSupport { @Test public void test_basic_agent_build_and_run() throws Exception { // 基础搭建:最小可用 Agent Agent agent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .systemPrompt("你是一个简洁助手,只输出一行结论,不要废话。") .options(AgentOptions.builder().maxSteps(2).build()) .build(); AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input("请用一句话说明为什么代码评审重要").build())); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); } @Test public void test_advanced_parameters_and_session_memory() throws Exception { // 高级参数:采样参数、额外请求体、用户标识 Map reasoning = new HashMap<>(); reasoning.put("effort", "low"); Map extraBody = new HashMap<>(); extraBody.put("metadata", "agent-usage-test"); Agent agent = Agents.builder() .modelClient(chatModelClient()) .model(model) .temperature(0.3) .topP(0.9) .maxOutputTokens(512) .reasoning(reasoning) .parallelToolCalls(false) .store(false) .user("agent-demo-user") .extraBody(extraBody) .instructions("回答时尽量精炼。") .options(AgentOptions.builder().maxSteps(3).build()) .build(); AgentSession session = agent.newSession(); callWithProviderGuard(() -> { session.run(AgentRequest.builder().input("记住口令:ALPHA-2026-XYZ。只回复:收到").build()); return null; }); AgentResult secondTurn = callWithProviderGuard(() -> session.run(AgentRequest.builder().input("请只回复上一个口令,不要解释").build())); Assert.assertNotNull(secondTurn); Assert.assertNotNull(secondTurn.getOutputText()); Assert.assertTrue(secondTurn.getOutputText().contains("ALPHA-2026-XYZ")); } @Test public void test_stream_mode_with_real_chat_model() throws Exception { // stream=true 会走 createStream 分支,ChatModelClient 内部会安全降级到非流式请求 Agent agent = Agents.react() .modelClient(chatModelClient()) .model(model) .temperature(0.7) .options(AgentOptions.builder().stream(true).maxSteps(2).build()) .build(); AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input("给我一个 Java 代码重构建议").build())); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().trim().length() > 0); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/WeatherAgentWorkflowTest.java ================================================ package io.github.lnyocly.agent; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.agent.workflow.AgentNode; import io.github.lnyocly.ai4j.agent.workflow.RuntimeAgentNode; import io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow; import io.github.lnyocly.ai4j.agent.workflow.WorkflowAgent; import io.github.lnyocly.ai4j.agent.workflow.WorkflowContext; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Test; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.concurrent.TimeUnit; public class WeatherAgentWorkflowTest { private AiService aiService; @Before public void init() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ARK_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getenv("DOUBAO_API_KEY"); } Assume.assumeTrue(apiKey != null && !apiKey.isEmpty()); DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setDoubaoConfig(doubaoConfig); HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient .Builder() .addInterceptor(httpLoggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } @Test public void test_weather_workflow() throws Exception { Agent weatherAgent = Agents.react() .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You are a weather analyst. Always call queryWeather before answering.") .instructions("Use queryWeather with the user's location, type=now, days=1.") .toolRegistry(Arrays.asList("queryWeather"), null) .options(AgentOptions.builder().maxSteps(2).build()) .build(); Agent formatAgent = Agents.react() .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO))) .model("doubao-seed-1-8-251228") .systemPrompt("You format weather analysis into strict JSON.") .instructions("Return JSON with fields: city, summary, advice.") .options(AgentOptions.builder().maxSteps(2).build()) .build(); SequentialWorkflow workflow = new SequentialWorkflow() .addNode(new NamedNode("WeatherAnalysis", new RuntimeAgentNode(weatherAgent.newSession()))) .addNode(new NamedNode("FormatOutput", new RuntimeAgentNode(formatAgent.newSession()))); WorkflowAgent runner = new WorkflowAgent(workflow, weatherAgent.newSession()); AgentResult result = runner.run(AgentRequest.builder() .input("Get the current weather in Beijing and provide advice.") .build()); System.out.println("FINAL OUTPUT: " + result.getOutputText()); Assert.assertNotNull(result); Assert.assertNotNull(result.getOutputText()); Assert.assertTrue(result.getOutputText().length() > 0); } private static class NamedNode implements AgentNode { private final String name; private final AgentNode delegate; private NamedNode(String name, AgentNode delegate) { this.name = name; this.delegate = delegate; } @Override public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception { System.out.println("NODE START: " + name); try { AgentResult result = delegate.execute(context, request); System.out.println("NODE END: " + name + " | status=OK"); return result; } catch (Exception e) { System.out.println("NODE END: " + name + " | status=ERROR"); throw e; } } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/agent/support/ZhipuAgentTestSupport.java ================================================ package io.github.lnyocly.agent.support; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.network.OkHttpUtil; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assume; import org.junit.Before; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.concurrent.TimeUnit; public abstract class ZhipuAgentTestSupport { protected static final String DEFAULT_API_KEY = "1cbd1960cdc7e9144ded698a9763569b.seHlVxdOq3eTnY9m"; protected static final String DEFAULT_MODEL = "GLM-4.5-Flash"; protected AiService aiService; protected String model; @Before public void setupZhipuAiService() throws NoSuchAlgorithmException, KeyManagementException { String apiKey = System.getenv("ZHIPU_API_KEY"); if (apiKey == null || apiKey.isEmpty()) { apiKey = System.getProperty("zhipu.api.key"); } if (apiKey == null || apiKey.isEmpty()) { apiKey = DEFAULT_API_KEY; } model = System.getenv("ZHIPU_MODEL"); if (model == null || model.isEmpty()) { model = System.getProperty("zhipu.model"); } if (model == null || model.isEmpty()) { model = DEFAULT_MODEL; } ZhipuConfig zhipuConfig = new ZhipuConfig(); zhipuConfig.setApiKey(apiKey); Configuration configuration = new Configuration(); configuration.setZhipuConfig(zhipuConfig); HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(); loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509) .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier()) .build(); configuration.setOkHttpClient(okHttpClient); aiService = new AiService(configuration); } protected ChatModelClient chatModelClient() { return new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU)); } protected T callWithProviderGuard(ThrowingSupplier supplier) throws Exception { try { return supplier.get(); } catch (Exception ex) { skipIfProviderUnavailable(ex); throw ex; } } protected void skipIfProviderUnavailable(Throwable throwable) { if (isProviderUnavailable(throwable)) { String reason = extractRootMessage(throwable); Assume.assumeTrue("Skip due provider limit/unavailable: " + reason, false); } } private boolean isProviderUnavailable(Throwable throwable) { Throwable current = throwable; while (current != null) { String message = current.getMessage(); if (message != null) { String lower = message.toLowerCase(); if (lower.contains("timeout") || lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("quota") || lower.contains("inference limit") || message.contains("频次") || message.contains("限流") || message.contains("额度") || message.contains("配额") || message.contains("模型服务已暂停") || message.contains("账户已达到")) { return true; } } current = current.getCause(); } return false; } private String extractRootMessage(Throwable throwable) { Throwable current = throwable; Throwable last = throwable; while (current != null) { last = current; current = current.getCause(); } String message = last == null ? null : last.getMessage(); return message == null || message.trim().isEmpty() ? "unknown error" : message; } @FunctionalInterface protected interface ThrowingSupplier { T get() throws Exception; } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeServiceTest.java ================================================ package io.github.lnyocly.ai4j.agent.flowgram; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramEdgeSchema; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunInput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramWorkflowSchema; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class FlowGramRuntimeServiceTest { @Test public void shouldValidateWorkflowShape() { FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner()); try { FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder() .nodes(Collections.singletonList(node("llm_0", "LLM", llmData(ref("start_0", "prompt"))))) .edges(Collections.emptyList()) .build(); FlowGramTaskValidateOutput result = service.validateTask(FlowGramTaskRunInput.builder() .schema(JSON.toJSONString(schema)) .inputs(mapOf("prompt", "hello")) .build()); assertFalse(result.isValid()); assertTrue(result.getErrors().contains("FlowGram workflow must contain exactly one Start node")); assertTrue(result.getErrors().contains("FlowGram workflow must contain at least one End node")); } finally { service.close(); } } @Test public void shouldRunSimpleStartLlmEndWorkflow() throws Exception { FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner()); try { FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startData()), node("llm_0", "LLM", llmData(ref("start_0", "prompt"))), node("end_0", "End", endData(ref("llm_0", "result"))) )) .edges(Arrays.asList( edge("start_0", "llm_0"), edge("llm_0", "end_0") )) .build(); FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder() .schema(JSON.toJSONString(schema)) .inputs(mapOf("prompt", "hello-flowgram")) .build()); FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID()); FlowGramTaskReportOutput report = service.getTaskReport(runOutput.getTaskID()); assertEquals("success", result.getStatus()); assertEquals("echo:hello-flowgram", result.getResult().get("result")); assertEquals("hello-flowgram", report.getInputs().get("prompt")); assertEquals("echo:hello-flowgram", report.getOutputs().get("result")); assertEquals("hello-flowgram", report.getNodes().get("start_0").getInputs().get("prompt")); assertEquals("echo:hello-flowgram", report.getNodes().get("llm_0").getOutputs().get("result")); assertTrue(report.getNodes().get("end_0").isTerminated()); } finally { service.close(); } } @Test public void shouldRouteConditionBranch() throws Exception { FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner()); try { FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startNumberData()), node("condition_0", "Condition", conditionData()), node("end_pass", "End", endData(constant("passed"))), node("end_fail", "End", endData(constant("failed"))) )) .edges(Arrays.asList( edge("start_0", "condition_0"), edge("condition_0", "end_pass", "pass"), edge("condition_0", "end_fail", "fail") )) .build(); FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder() .schema(JSON.toJSONString(schema)) .inputs(mapOf("score", 88)) .build()); FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID()); assertEquals("passed", result.getResult().get("result")); } finally { service.close(); } } @Test public void shouldAggregateLoopOutputs() throws Exception { FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner()); try { FlowGramNodeSchema loopNode = node("loop_0", "Loop", loopData()); loopNode.setBlocks(Collections.singletonList( node("llm_1", "LLM", llmData(ref("loop_0_locals", "item"))) )); FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startCitiesData()), loopNode, node("end_0", "End", endArrayData(ref("loop_0", "suggestions"))) )) .edges(Arrays.asList( edge("start_0", "loop_0"), edge("loop_0", "end_0") )) .build(); FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder() .schema(JSON.toJSONString(schema)) .inputs(mapOf("cities", Arrays.asList("beijing", "shanghai"))) .build()); FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID()); @SuppressWarnings("unchecked") List suggestions = (List) result.getResult().get("result"); assertEquals(Arrays.asList("echo:beijing", "echo:shanghai"), suggestions); } finally { service.close(); } } @Test public void shouldCancelTask() throws Exception { FlowGramRuntimeService service = new FlowGramRuntimeService(new SlowRunner()); try { FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startData()), node("llm_0", "LLM", llmData(ref("start_0", "prompt"))), node("end_0", "End", endData(ref("llm_0", "result"))) )) .edges(Arrays.asList( edge("start_0", "llm_0"), edge("llm_0", "end_0") )) .build(); FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder() .schema(JSON.toJSONString(schema)) .inputs(mapOf("prompt", "cancel-me")) .build()); waitForProcessing(service, runOutput.getTaskID(), "llm_0"); assertTrue(service.cancelTask(runOutput.getTaskID()).isSuccess()); FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID()); assertEquals("canceled", result.getStatus()); } finally { service.close(); } } @Test public void shouldExposeWorkflowFailureReasonInResultAndReport() throws Exception { FlowGramRuntimeService service = new FlowGramRuntimeService(new FailingRunner()); try { FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startData()), node("llm_0", "LLM", llmData(ref("start_0", "prompt"))), node("end_0", "End", endData(ref("llm_0", "result"))) )) .edges(Arrays.asList( edge("start_0", "llm_0"), edge("llm_0", "end_0") )) .build(); FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder() .schema(JSON.toJSONString(schema)) .inputs(mapOf("prompt", "boom")) .build()); FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID()); FlowGramTaskReportOutput report = service.getTaskReport(runOutput.getTaskID()); assertEquals("failed", result.getStatus()); assertEquals("runner-boom", result.getError()); assertEquals("failed", report.getWorkflow().getStatus()); assertEquals("runner-boom", report.getWorkflow().getError()); assertEquals("runner-boom", report.getNodes().get("llm_0").getError()); assertTrue(report.getWorkflow().getStartTime() != null); assertTrue(report.getWorkflow().getEndTime() != null); } finally { service.close(); } } @Test public void shouldPublishRuntimeListenerEventsForSuccessfulTask() throws Exception { final List events = Collections.synchronizedList(new ArrayList()); FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner()) .registerListener(new FlowGramRuntimeListener() { @Override public void onEvent(FlowGramRuntimeEvent event) { events.add(event); } }); try { FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startData()), node("llm_0", "LLM", llmData(ref("start_0", "prompt"))), node("end_0", "End", endData(ref("llm_0", "result"))) )) .edges(Arrays.asList( edge("start_0", "llm_0"), edge("llm_0", "end_0") )) .build(); FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder() .schema(JSON.toJSONString(schema)) .inputs(mapOf("prompt", "listener-test")) .build()); FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID()); awaitEvent(events, FlowGramRuntimeEvent.Type.TASK_FINISHED); assertEquals("success", result.getStatus()); List eventTypes = new ArrayList(); for (FlowGramRuntimeEvent event : snapshot(events)) { eventTypes.add(event.getType()); } assertEquals(Arrays.asList( FlowGramRuntimeEvent.Type.TASK_STARTED, FlowGramRuntimeEvent.Type.NODE_STARTED, FlowGramRuntimeEvent.Type.NODE_FINISHED, FlowGramRuntimeEvent.Type.NODE_STARTED, FlowGramRuntimeEvent.Type.NODE_FINISHED, FlowGramRuntimeEvent.Type.NODE_STARTED, FlowGramRuntimeEvent.Type.NODE_FINISHED, FlowGramRuntimeEvent.Type.TASK_FINISHED ), eventTypes); } finally { service.close(); } } @Test public void shouldRunAi4jBackedLlmNodeRunner() throws Exception { Ai4jFlowGramLlmNodeRunner runner = new Ai4jFlowGramLlmNodeRunner(new AgentModelClient() { @Override public AgentModelResult create(AgentPrompt prompt) { return AgentModelResult.builder() .outputText("ai4j-ok") .rawResponse(new MinimaxChatCompletionResponse( "resp-1", "chat.completion", 1L, "test-model", null, new Usage(12L, 8L, 20L))) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { return create(prompt); } }); Map result = runner.run(node("llm_0", "LLM", llmData(ref("start_0", "prompt"))), mapOf("modelName", "test-model", "prompt", "hello")); assertEquals("ai4j-ok", result.get("result")); assertEquals("ai4j-ok", result.get("outputText")); @SuppressWarnings("unchecked") Map metrics = (Map) result.get("metrics"); assertEquals(12L, metrics.get("promptTokens")); assertEquals(8L, metrics.get("completionTokens")); assertEquals(20L, metrics.get("totalTokens")); } private static FlowGramTaskResultOutput awaitResult(FlowGramRuntimeService service, String taskId) throws Exception { long deadline = System.currentTimeMillis() + 5000L; while (System.currentTimeMillis() < deadline) { FlowGramTaskResultOutput result = service.getTaskResult(taskId); if (result != null && result.isTerminated()) { return result; } Thread.sleep(20L); } throw new AssertionError("Timed out waiting for task result"); } private static void waitForProcessing(FlowGramRuntimeService service, String taskId, String nodeId) throws Exception { long deadline = System.currentTimeMillis() + 5000L; while (System.currentTimeMillis() < deadline) { if ("processing".equals(service.getTaskReport(taskId).getNodes().get(nodeId).getStatus())) { return; } Thread.sleep(10L); } throw new AssertionError("Timed out waiting for node to enter processing state"); } private static void awaitEvent(List events, FlowGramRuntimeEvent.Type type) throws Exception { long deadline = System.currentTimeMillis() + 5000L; while (System.currentTimeMillis() < deadline) { for (FlowGramRuntimeEvent event : snapshot(events)) { if (event != null && type == event.getType()) { return; } } Thread.sleep(10L); } throw new AssertionError("Timed out waiting for event " + type); } private static List snapshot(List events) { synchronized (events) { return new ArrayList(events); } } private static FlowGramNodeSchema node(String id, String type, Map data) { return FlowGramNodeSchema.builder() .id(id) .type(type) .name(id) .data(data) .build(); } private static FlowGramEdgeSchema edge(String source, String target) { return FlowGramEdgeSchema.builder() .sourceNodeID(source) .targetNodeID(target) .build(); } private static FlowGramEdgeSchema edge(String source, String target, String sourcePortId) { return FlowGramEdgeSchema.builder() .sourceNodeID(source) .targetNodeID(target) .sourcePortID(sourcePortId) .build(); } private static Map startData() { return mapOf("outputs", objectSchema(required("prompt"), property("prompt", stringSchema()))); } private static Map startNumberData() { return mapOf("outputs", objectSchema(required("score"), property("score", numberSchema()))); } private static Map startCitiesData() { return mapOf("outputs", objectSchema(required("cities"), property("cities", mapOf("type", "array")))); } private static Map llmData(Map promptValue) { return mapOf( "inputs", objectSchema( required("modelName", "prompt"), property("modelName", mapOf("type", "string", "default", "demo-model")), property("prompt", stringSchema()) ), "outputs", objectSchema(required("result"), property("result", stringSchema())), "inputsValues", mapOf( "modelName", constant("demo-model"), "prompt", promptValue ) ); } private static Map endData(Map resultValue) { return mapOf( "inputs", objectSchema(required("result"), property("result", stringSchema())), "inputsValues", mapOf("result", resultValue) ); } private static Map endArrayData(Map resultValue) { return mapOf( "inputs", objectSchema(required("result"), property("result", mapOf("type", "array"))), "inputsValues", mapOf("result", resultValue) ); } private static Map conditionData() { List conditions = new ArrayList(); conditions.add(mapOf("key", "pass", "leftKey", "score", "operator", ">=", "value", 60)); conditions.add(mapOf("key", "fail", "operator", "default")); return mapOf( "inputs", objectSchema(required("score"), property("score", numberSchema())), "inputsValues", mapOf("score", ref("start_0", "score")), "conditions", conditions ); } private static Map loopData() { return mapOf( "inputs", objectSchema(required("loopFor"), property("loopFor", mapOf("type", "array"))), "outputs", objectSchema(required("suggestions"), property("suggestions", mapOf("type", "array"))), "inputsValues", mapOf("loopFor", ref("start_0", "cities")), "loopOutputs", mapOf("suggestions", ref("llm_1", "result")) ); } private static Map stringSchema() { return mapOf("type", "string"); } private static Map numberSchema() { return mapOf("type", "number"); } private static Map objectSchema(List required, Map... properties) { Map object = new LinkedHashMap(); object.put("type", "object"); object.put("required", required); Map props = new LinkedHashMap(); if (properties != null) { for (Map property : properties) { props.putAll(property); } } object.put("properties", props); return object; } private static Map property(String name, Map schema) { return mapOf(name, schema); } private static List required(String... names) { return Arrays.asList(names); } private static Map ref(String... path) { return mapOf("type", "ref", "content", Arrays.asList(path)); } private static Map constant(Object content) { return mapOf("type", "constant", "content", content); } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } private static final class EchoRunner implements FlowGramLlmNodeRunner { @Override public Map run(FlowGramNodeSchema node, Map inputs) { return mapOf("result", "echo:" + inputs.get("prompt")); } } private static final class SlowRunner implements FlowGramLlmNodeRunner { @Override public Map run(FlowGramNodeSchema node, Map inputs) throws Exception { for (int i = 0; i < 100; i++) { Thread.sleep(20L); } return mapOf("result", "slow:" + inputs.get("prompt")); } } private static final class FailingRunner implements FlowGramLlmNodeRunner { @Override public Map run(FlowGramNodeSchema node, Map inputs) { throw new IllegalStateException("runner-boom"); } } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemoryTest.java ================================================ package io.github.lnyocly.ai4j.agent.memory; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class JdbcAgentMemoryTest { @Test public void shouldPersistAgentMemoryAcrossInstances() { String jdbcUrl = jdbcUrl("persist"); JdbcAgentMemory first = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId("agent-1") .build()); first.addUserInput("hello"); first.addOutputItems(Collections.singletonList(AgentInputItem.message("assistant", "hi"))); first.addToolOutput("call-1", "{\"ok\":true}"); JdbcAgentMemory second = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder() .jdbcUrl(jdbcUrl) .sessionId("agent-1") .build()); List items = second.getItems(); assertEquals(3, items.size()); assertEquals("message", ((Map) items.get(0)).get("type")); assertEquals("function_call_output", ((Map) items.get(2)).get("type")); } @Test public void shouldIncludeSummaryInMergedItems() { JdbcAgentMemory memory = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder() .jdbcUrl(jdbcUrl("summary")) .sessionId("agent-2") .build()); memory.addUserInput("hello"); memory.setSummary("previous summary"); List items = memory.getItems(); assertEquals(2, items.size()); assertEquals("system", ((Map) items.get(0)).get("role")); assertEquals("previous summary", ((Map) ((List) ((Map) items.get(0)).get("content")).get(0)).get("text")); } @Test public void shouldSupportSnapshotRestoreAndClear() { JdbcAgentMemory memory = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder() .jdbcUrl(jdbcUrl("snapshot")) .sessionId("agent-3") .build()); memory.addUserInput("hello"); memory.addOutputItems(Arrays.asList( AgentInputItem.message("assistant", "hi"), AgentInputItem.functionCallOutput("call-1", "{\"ok\":true}") )); MemorySnapshot snapshot = memory.snapshot(); memory.clear(); assertTrue(memory.getItems().isEmpty()); assertNull(memory.getSummary()); memory.restore(snapshot); assertEquals(3, memory.getItems().size()); } private String jdbcUrl(String suffix) { return "jdbc:h2:mem:ai4j_agent_memory_" + suffix + ";MODE=MYSQL;DB_CLOSE_DELAY=-1"; } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/model/ChatModelClientTest.java ================================================ package io.github.lnyocly.ai4j.agent.model; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.service.IChatService; import org.junit.Assert; import org.junit.Test; import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; public class ChatModelClientTest { @Test public void createShouldEnableToolCallPassThroughForAgentTools() throws Exception { final AtomicReference captured = new AtomicReference(); IChatService chatService = new IChatService() { @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) { captured.set(chatCompletion); return new ChatCompletionResponse(); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) { captured.set(chatCompletion); return new ChatCompletionResponse(); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) { } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) { } }; ChatModelClient client = new ChatModelClient(chatService); client.create(AgentPrompt.builder() .model("MiniMax-M2.1") .tools(Collections.singletonList(testTool("read_file"))) .build()); Assert.assertNotNull(captured.get()); Assert.assertEquals(Boolean.TRUE, captured.get().getPassThroughToolCalls()); } private Tool testTool(String name) { Tool.Function function = new Tool.Function(); function.setName(name); function.setDescription("test tool"); return new Tool("function", function); } } ================================================ FILE: ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/trace/LangfuseTraceExporterTest.java ================================================ package io.github.lnyocly.ai4j.agent.trace; import org.junit.Assert; import org.junit.Test; import java.util.LinkedHashMap; import java.util.Map; public class LangfuseTraceExporterTest { @Test public void test_langfuse_projection_for_model_span() { Map attributes = new LinkedHashMap(); attributes.put("model", "glm-4.7"); attributes.put("systemPrompt", "system"); attributes.put("items", "[{\"role\":\"user\",\"content\":\"hi\"}]"); attributes.put("output", "hello"); attributes.put("temperature", 0.2D); attributes.put("finishReason", "stop"); TraceSpan span = TraceSpan.builder() .traceId("trace_1") .spanId("span_1") .name("model.request") .type(TraceSpanType.MODEL) .status(TraceSpanStatus.OK) .startTime(System.currentTimeMillis()) .endTime(System.currentTimeMillis() + 30L) .attributes(attributes) .metrics(TraceMetrics.builder() .durationMillis(30L) .promptTokens(100L) .completionTokens(25L) .totalTokens(125L) .inputCost(0.0002D) .outputCost(0.0005D) .totalCost(0.0007D) .currency("USD") .build()) .build(); Map projected = LangfuseTraceExporter.LangfuseSpanAttributes.project(span, "prod", "1.0.0"); Assert.assertEquals("prod", projected.get("langfuse.environment")); Assert.assertEquals("1.0.0", projected.get("langfuse.release")); Assert.assertEquals("generation", projected.get("langfuse.observation.type")); Assert.assertEquals("DEFAULT", projected.get("langfuse.observation.level")); Assert.assertEquals("glm-4.7", projected.get("langfuse.observation.model")); Assert.assertTrue(String.valueOf(projected.get("langfuse.observation.input")).contains("systemPrompt")); Assert.assertTrue(String.valueOf(projected.get("langfuse.observation.output")).contains("hello")); Assert.assertTrue(String.valueOf(projected.get("langfuse.observation.model_parameters")).contains("temperature")); Assert.assertTrue(String.valueOf(projected.get("langfuse.observation.usage_details")).contains("prompt_tokens")); Assert.assertTrue(String.valueOf(projected.get("langfuse.observation.cost_details")).contains("\"total\"")); } } ================================================ FILE: ai4j-bom/pom.xml ================================================ 4.0.0 io.github.lnyo-cly ai4j-sdk 2.3.0 ai4j-bom pom ai4j-bom ai4j 多模块版本对齐 BOM。 Bill of materials for aligning ai4j module versions. The Apache License, Version 2.0 https://www.apache.org/licenses/LICENSE-2.0.txt GitHub https://github.com/LnYo-Cly/ai4j/issues https://github.com/LnYo-Cly/ai4j LnYo-Cly LnYo-Cly lnyocly@gmail.com https://github.com/LnYo-Cly/ai4j +8 https://github.com/LnYo-Cly/ai4j scm:git:https://github.com/LnYo-Cly/ai4j.git scm:git:https://github.com/LnYo-Cly/ai4j.git io.github.lnyo-cly ai4j-agent ${project.version} io.github.lnyo-cly ai4j ${project.version} io.github.lnyo-cly ai4j-coding ${project.version} io.github.lnyo-cly ai4j-spring-boot-starter ${project.version} io.github.lnyo-cly ai4j-flowgram-spring-boot-starter ${project.version} io.github.lnyo-cly ai4j-cli ${project.version} release org.codehaus.mojo flatten-maven-plugin ${flatten-maven-plugin.version} bom flatten process-resources flatten flatten-clean clean clean org.apache.maven.plugins maven-gpg-plugin 1.6 D:\Develop\DevelopEnv\GnuPG\bin\gpg.exe cly sign-artifacts verify sign org.sonatype.central central-publishing-maven-plugin 0.4.0 true LnYo-Cly true ================================================ FILE: ai4j-cli/pom.xml ================================================ 4.0.0 io.github.lnyo-cly ai4j-sdk 2.3.0 ai4j-cli jar ai4j-cli ai4j CLI、TUI 与 ACP 宿主模块,用于 coding agent 与工作区自动化。 CLI, TUI, and ACP host for ai4j coding agents and workspace automation. The Apache License, Version 2.0 https://www.apache.org/licenses/LICENSE-2.0.txt GitHub https://github.com/LnYo-Cly/ai4j/issues https://github.com/LnYo-Cly/ai4j LnYo-Cly LnYo-Cly lnyocly@gmail.com https://github.com/LnYo-Cly/ai4j +8 https://github.com/LnYo-Cly/ai4j scm:git:https://github.com/LnYo-Cly/ai4j.git scm:git:https://github.com/LnYo-Cly/ai4j.git io.github.lnyo-cly ai4j ${project.version} io.github.lnyo-cly ai4j-coding ${project.version} com.alibaba.fastjson2 fastjson2 2.0.43 org.jline jline 3.30.0 org.projectlombok lombok 1.18.30 net.java.dev.jna jna 5.14.0 junit junit 4.13.2 test org.apache.maven.plugins maven-compiler-plugin 3.11.0 ${java.version} ${java.version} ${project.build.sourceEncoding} org.apache.maven.plugins maven-jar-plugin 3.4.2 io.github.lnyocly.ai4j.cli.Ai4jCliMain org.apache.maven.plugins maven-assembly-plugin 3.7.1 io.github.lnyocly.ai4j.cli.Ai4jCliMain jar-with-dependencies true bundle-cli package single release org.codehaus.mojo flatten-maven-plugin ${flatten-maven-plugin.version} ossrh flatten process-resources flatten flatten-clean clean clean org.apache.maven.plugins maven-source-plugin 3.3.1 attach-sources jar-no-fork org.apache.maven.plugins maven-javadoc-plugin 3.6.3 attach-javadocs jar none false Author a Author: Description a Description: Date a Date: org.apache.maven.plugins maven-gpg-plugin 1.6 D:\Develop\DevelopEnv\GnuPG\bin\gpg.exe cly sign-artifacts verify sign org.sonatype.central central-publishing-maven-plugin 0.4.0 true LnYo-Cly true ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/Ai4jCli.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.acp.AcpCommand; import io.github.lnyocly.ai4j.cli.command.CodeCommand; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory; import io.github.lnyocly.ai4j.tui.JlineTerminalIO; import io.github.lnyocly.ai4j.tui.StreamsTerminalIO; import io.github.lnyocly.ai4j.tui.TerminalIO; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Properties; public class Ai4jCli { private final CodingCliAgentFactory agentFactory; private final Path currentDirectory; public Ai4jCli() { this(new DefaultCodingCliAgentFactory(), Paths.get(".").toAbsolutePath().normalize()); } Ai4jCli(CodingCliAgentFactory agentFactory, Path currentDirectory) { this.agentFactory = agentFactory; this.currentDirectory = currentDirectory; } public int run(String[] args, InputStream in, OutputStream out, OutputStream err) { return run(args, in, out, err, System.getenv(), System.getProperties()); } int run(String[] args, InputStream in, OutputStream out, OutputStream err, Map env, Properties properties) { TerminalIO terminal = createTerminal(in, out, err); try { List arguments = args == null ? Collections.emptyList() : Arrays.asList(args); CodeCommand codeCommand = new CodeCommand( agentFactory, env, properties, currentDirectory ); AcpCommand acpCommand = new AcpCommand(env, properties, currentDirectory); if (arguments.isEmpty()) { return codeCommand.run(Collections.emptyList(), terminal); } String first = arguments.get(0); if ("help".equalsIgnoreCase(first) || "-h".equals(first) || "--help".equals(first)) { printHelp(terminal); return 0; } if ("code".equalsIgnoreCase(first)) { return codeCommand.run(arguments.subList(1, arguments.size()), terminal); } if ("tui".equalsIgnoreCase(first)) { return codeCommand.run(asTuiArguments(arguments.subList(1, arguments.size())), terminal); } if ("acp".equalsIgnoreCase(first)) { closeQuietly(terminal); return acpCommand.run(arguments.subList(1, arguments.size()), in, out, err); } if (first.startsWith("--")) { return codeCommand.run(arguments, terminal); } terminal.errorln("Unknown command: " + first); printHelp(terminal); return 2; } finally { closeQuietly(terminal); } } private void printHelp(TerminalIO terminal) { terminal.println("ai4j-cli"); terminal.println(" Minimal coding-agent CLI entry. The first release focuses on the code command.\n"); terminal.println("Usage:"); terminal.println(" ai4j-cli code --model [options]"); terminal.println(" ai4j-cli tui --model [options]"); terminal.println(" ai4j-cli acp --model [options]"); terminal.println(" ai4j-cli --model [options] # handled as the code command by default\n"); terminal.println("Commands:"); terminal.println(" code Start a coding session in one-shot or interactive REPL mode\n"); terminal.println(" tui Start the same coding session with a richer text UI shell\n"); terminal.println(" acp Start the coding session as an ACP stdio server\n"); terminal.println("Examples:"); terminal.println(" ai4j-cli code --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace ."); terminal.println(" ai4j-cli tui --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace ."); terminal.println(" ai4j-cli acp --provider openai --protocol responses --model gpt-5-mini --workspace ."); terminal.println(" ai4j-cli code --provider openai --protocol responses --model gpt-5-mini --prompt \"Investigate why tests fail in this workspace\""); terminal.println(" ai4j-cli code --provider openai --base-url https://api.deepseek.com --protocol chat --model deepseek-chat\n"); terminal.println("Tip:"); terminal.println(" Run `ai4j-cli code --help` for the full code command reference."); } private List asTuiArguments(List arguments) { List tuiArguments = new ArrayList(); tuiArguments.add("--ui"); tuiArguments.add(CliUiMode.TUI.getValue()); if (arguments != null) { tuiArguments.addAll(arguments); } return tuiArguments; } private TerminalIO createTerminal(InputStream in, OutputStream out, OutputStream err) { if (in == System.in && out == System.out && err == System.err) { try { return JlineTerminalIO.openSystem(err); } catch (Exception ignored) { } } return new StreamsTerminalIO(in, out, err); } private void closeQuietly(TerminalIO terminal) { if (terminal == null) { return; } try { terminal.close(); } catch (Exception ignored) { } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/Ai4jCliMain.java ================================================ package io.github.lnyocly.ai4j.cli; public final class Ai4jCliMain { private Ai4jCliMain() { } public static void main(String[] args) { configureLogging(args); int exitCode = new Ai4jCli().run(args, System.in, System.out, System.err); if (exitCode != 0) { System.exit(exitCode); } } private static void configureLogging(String[] args) { boolean verbose = hasVerboseFlag(args); setIfAbsent("org.slf4j.simpleLogger.defaultLogLevel", verbose ? "debug" : "warn"); setIfAbsent("org.slf4j.simpleLogger.showThreadName", verbose ? "true" : "false"); setIfAbsent("org.slf4j.simpleLogger.showDateTime", "false"); setIfAbsent("org.slf4j.simpleLogger.showLogName", verbose ? "true" : "false"); setIfAbsent("org.slf4j.simpleLogger.showShortLogName", verbose ? "true" : "false"); } private static boolean hasVerboseFlag(String[] args) { if (args == null) { return false; } for (String arg : args) { if ("--verbose".equals(arg)) { return true; } } return false; } private static void setIfAbsent(String key, String value) { if (System.getProperty(key) == null) { System.setProperty(key, value); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/ApprovalMode.java ================================================ package io.github.lnyocly.ai4j.cli; import java.util.Locale; public enum ApprovalMode { AUTO("auto"), SAFE("safe"), MANUAL("manual"); private final String value; ApprovalMode(String value) { this.value = value; } public String getValue() { return value; } public static ApprovalMode parse(String raw) { if (raw == null || raw.trim().isEmpty()) { return AUTO; } String normalized = raw.trim().toLowerCase(Locale.ROOT); for (ApprovalMode mode : values()) { if (mode.value.equals(normalized)) { return mode; } } throw new IllegalArgumentException("Unsupported approval mode: " + raw); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/CliProtocol.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.service.PlatformType; import java.util.Locale; public enum CliProtocol { CHAT("chat"), RESPONSES("responses"); private final String value; CliProtocol(String value) { this.value = value; } public String getValue() { return value; } public static CliProtocol parse(String value) { String normalized = normalize(value); for (CliProtocol protocol : values()) { if (protocol.value.equals(normalized)) { return protocol; } } throw new IllegalArgumentException("Unsupported protocol: " + value + ". Expected: chat, responses"); } public static CliProtocol resolveConfigured(String value, PlatformType provider, String baseUrl) { String normalized = normalize(value); if (normalized == null || "auto".equals(normalized)) { return defaultProtocol(provider, baseUrl); } return parse(normalized); } public static CliProtocol defaultProtocol(PlatformType provider, String baseUrl) { if (provider == PlatformType.OPENAI) { String normalizedBaseUrl = normalize(baseUrl); if (normalizedBaseUrl == null || normalizedBaseUrl.contains("api.openai.com")) { return RESPONSES; } return CHAT; } if (provider == PlatformType.DOUBAO || provider == PlatformType.DASHSCOPE) { return RESPONSES; } return CHAT; } private static String normalize(String value) { if (value == null) { return null; } String normalized = value.trim(); if (normalized.isEmpty()) { return null; } return normalized.toLowerCase(Locale.ROOT); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/CliUiMode.java ================================================ package io.github.lnyocly.ai4j.cli; public enum CliUiMode { CLI("cli"), TUI("tui"); private final String value; CliUiMode(String value) { this.value = value; } public String getValue() { return value; } public static CliUiMode parse(String value) { if (value == null || value.trim().isEmpty()) { return CLI; } String normalized = value.trim().toLowerCase(); for (CliUiMode mode : values()) { if (mode.value.equals(normalized)) { return mode; } } throw new IllegalArgumentException("Unsupported ui mode: " + value + ". Expected: cli, tui"); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/SlashCommandController.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry; import io.github.lnyocly.ai4j.cli.command.CustomCommandTemplate; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.tui.TuiConfigManager; import org.jline.keymap.KeyMap; import org.jline.reader.Buffer; import org.jline.reader.Candidate; import org.jline.reader.Completer; import org.jline.reader.LineReader; import org.jline.reader.ParsedLine; import org.jline.reader.Reference; import org.jline.reader.Widget; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.function.Supplier; public final class SlashCommandController implements Completer { private static final Runnable NOOP_STATUS_REFRESH = new Runnable() { @Override public void run() { } }; private static final List BUILT_IN_COMMANDS = Arrays.asList( new SlashCommandSpec("/help", "Show help", false), new SlashCommandSpec("/status", "Show current session status", false), new SlashCommandSpec("/session", "Show current session metadata", false), new SlashCommandSpec("/theme", "Show or switch the active theme", true), new SlashCommandSpec("/save", "Persist the current session state", false), new SlashCommandSpec("/providers", "List saved provider profiles", false), new SlashCommandSpec("/provider", "Show or switch the active provider profile", true), new SlashCommandSpec("/model", "Show or switch the active model override", true), new SlashCommandSpec("/experimental", "Show or switch experimental runtime feature flags", true), new SlashCommandSpec("/skills", "List or inspect discovered coding skills", true), new SlashCommandSpec("/agents", "List or inspect available coding agents", true), new SlashCommandSpec("/commands", "List available custom commands", false), new SlashCommandSpec("/palette", "Alias of /commands", false), new SlashCommandSpec("/cmd", "Run a custom command template", true), new SlashCommandSpec("/sessions", "List saved sessions", false), new SlashCommandSpec("/history", "Show session lineage", true), new SlashCommandSpec("/tree", "Show the current session tree", true), new SlashCommandSpec("/events", "Show the latest session ledger events", true), new SlashCommandSpec("/replay", "Replay recent turns grouped from the event ledger", true), new SlashCommandSpec("/team", "Show the current agent team board", false), new SlashCommandSpec("/compacts", "Show recent compact history", true), new SlashCommandSpec("/stream", "Show or switch model request streaming", true), new SlashCommandSpec("/mcp", "Show or manage MCP services", true), new SlashCommandSpec("/processes", "List active and restored process metadata", false), new SlashCommandSpec("/process", "Inspect or control a process", true), new SlashCommandSpec("/checkpoint", "Show the current structured checkpoint summary", false), new SlashCommandSpec("/resume", "Resume a saved session", true), new SlashCommandSpec("/load", "Alias of /resume", true), new SlashCommandSpec("/fork", "Fork a session branch", true), new SlashCommandSpec("/compact", "Compact current session memory", true), new SlashCommandSpec("/clear", "Print a new screen section", false), new SlashCommandSpec("/exit", "Exit the session", false), new SlashCommandSpec("/quit", "Exit the session", false) ); private static final List PROCESS_COMMANDS = Arrays.asList( new ProcessCommandSpec("status", "Show metadata for one process", false, false), new ProcessCommandSpec("follow", "Show process metadata with buffered logs", true, false), new ProcessCommandSpec("logs", "Read buffered logs for a process", true, false), new ProcessCommandSpec("write", "Write text to a live process stdin", false, true), new ProcessCommandSpec("stop", "Stop a live process", false, false) ); private static final List PROCESS_FOLLOW_LIMITS = Arrays.asList("200", "400", "800", "1600"); private static final List PROCESS_LOG_LIMITS = Arrays.asList("200", "480", "800", "1600"); private static final List STREAM_OPTIONS = Arrays.asList("on", "off"); private static final List EXPERIMENTAL_FEATURES = Arrays.asList("subagent", "agent-teams"); private static final List TEAM_ACTIONS = Arrays.asList("list", "status", "messages", "resume"); private static final List TEAM_MESSAGE_LIMITS = Arrays.asList("10", "20", "50", "100"); private static final List MCP_ACTIONS = Arrays.asList("list", "add", "enable", "disable", "pause", "resume", "retry", "remove"); private static final String MCP_TRANSPORT_FLAG = "--transport"; private static final List MCP_TRANSPORT_OPTIONS = Arrays.asList("stdio", "sse", "http"); private static final List PROVIDER_ACTIONS = Arrays.asList("use", "save", "add", "edit", "default", "remove"); private static final String MODEL_RESET = "reset"; private static final List PROVIDER_DEFAULT_OPTIONS = Arrays.asList("clear"); private static final List PROVIDER_MUTATION_OPTIONS = Arrays.asList( "--provider", "--protocol", "--model", "--base-url", "--api-key", "--clear-model", "--clear-base-url", "--clear-api-key" ); private static final List EXECUTABLE_ROOT_COMMANDS = Arrays.asList( "/help", "/status", "/session", "/theme", "/save", "/providers", "/provider", "/model", "/experimental", "/skills", "/agents", "/commands", "/palette", "/sessions", "/history", "/tree", "/events", "/replay", "/team", "/compacts", "/stream", "/mcp", "/processes", "/checkpoint", "/fork", "/compact", "/clear", "/exit", "/quit" ); private final CustomCommandRegistry customCommandRegistry; private final TuiConfigManager tuiConfigManager; private final Object paletteLock = new Object(); private final java.util.Map wrappedWidgets = new java.util.HashMap(); private volatile CodingSessionManager sessionManager; private volatile Supplier> processCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; private volatile Supplier> profileCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; private volatile Supplier> modelCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; private volatile Supplier> mcpServerCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; private volatile Supplier> skillCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; private volatile Supplier> agentCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; private volatile Supplier> teamCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; private volatile Runnable statusRefresh = NOOP_STATUS_REFRESH; private PaletteSnapshot paletteSnapshot = PaletteSnapshot.closed(); public SlashCommandController(CustomCommandRegistry customCommandRegistry, TuiConfigManager tuiConfigManager) { this.customCommandRegistry = customCommandRegistry; this.tuiConfigManager = tuiConfigManager; } public void setSessionManager(CodingSessionManager sessionManager) { this.sessionManager = sessionManager; } public void setProcessCandidateSupplier(Supplier> processCandidateSupplier) { if (processCandidateSupplier == null) { this.processCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; return; } this.processCandidateSupplier = processCandidateSupplier; } public void setProfileCandidateSupplier(Supplier> profileCandidateSupplier) { if (profileCandidateSupplier == null) { this.profileCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; return; } this.profileCandidateSupplier = profileCandidateSupplier; } public void setModelCandidateSupplier(Supplier> modelCandidateSupplier) { if (modelCandidateSupplier == null) { this.modelCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; return; } this.modelCandidateSupplier = modelCandidateSupplier; } public void setMcpServerCandidateSupplier(Supplier> mcpServerCandidateSupplier) { if (mcpServerCandidateSupplier == null) { this.mcpServerCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; return; } this.mcpServerCandidateSupplier = mcpServerCandidateSupplier; } public void setSkillCandidateSupplier(Supplier> skillCandidateSupplier) { if (skillCandidateSupplier == null) { this.skillCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; return; } this.skillCandidateSupplier = skillCandidateSupplier; } public void setAgentCandidateSupplier(Supplier> agentCandidateSupplier) { if (agentCandidateSupplier == null) { this.agentCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; return; } this.agentCandidateSupplier = agentCandidateSupplier; } public void setTeamCandidateSupplier(Supplier> teamCandidateSupplier) { if (teamCandidateSupplier == null) { this.teamCandidateSupplier = new Supplier>() { @Override public List get() { return Collections.emptyList(); } }; return; } this.teamCandidateSupplier = teamCandidateSupplier; } public void setStatusRefresh(Runnable statusRefresh) { this.statusRefresh = statusRefresh == null ? NOOP_STATUS_REFRESH : statusRefresh; } public void configure(LineReader lineReader) { if (lineReader == null) { return; } lineReader.setOpt(LineReader.Option.CASE_INSENSITIVE); lineReader.setOpt(LineReader.Option.AUTO_MENU); lineReader.setOpt(LineReader.Option.AUTO_MENU_LIST); lineReader.setOpt(LineReader.Option.AUTO_GROUP); lineReader.setOpt(LineReader.Option.LIST_PACKED); lineReader.getWidgets().put("ai4j-slash-menu", new Widget() { @Override public boolean apply() { return openSlashMenu(lineReader); } }); lineReader.getWidgets().put("ai4j-command-palette", new Widget() { @Override public boolean apply() { return openCommandPalette(lineReader); } }); lineReader.getWidgets().put("ai4j-accept-line", new Widget() { @Override public boolean apply() { return acceptLine(lineReader); } }); lineReader.getWidgets().put("ai4j-accept-selection", new Widget() { @Override public boolean apply() { return acceptSlashSelection(lineReader, true); } }); lineReader.getWidgets().put("ai4j-palette-up", new Widget() { @Override public boolean apply() { return navigateSlashPalette(lineReader, -1, LineReader.UP_LINE_OR_HISTORY); } }); lineReader.getWidgets().put("ai4j-palette-down", new Widget() { @Override public boolean apply() { return navigateSlashPalette(lineReader, 1, LineReader.DOWN_LINE_OR_HISTORY); } }); wrapWidget(lineReader, LineReader.SELF_INSERT, new Widget() { @Override public boolean apply() { return delegateAndRefreshSlashPalette(lineReader, LineReader.SELF_INSERT); } }); wrapWidget(lineReader, LineReader.BACKWARD_DELETE_CHAR, new Widget() { @Override public boolean apply() { return delegateAndRefreshSlashPalette(lineReader, LineReader.BACKWARD_DELETE_CHAR); } }); wrapWidget(lineReader, LineReader.DELETE_CHAR, new Widget() { @Override public boolean apply() { return delegateAndRefreshSlashPalette(lineReader, LineReader.DELETE_CHAR); } }); wrapWidget(lineReader, LineReader.UP_LINE_OR_HISTORY, new Widget() { @Override public boolean apply() { return navigateSlashPalette(lineReader, -1, LineReader.UP_LINE_OR_HISTORY); } }); wrapWidget(lineReader, LineReader.DOWN_LINE_OR_HISTORY, new Widget() { @Override public boolean apply() { return navigateSlashPalette(lineReader, 1, LineReader.DOWN_LINE_OR_HISTORY); } }); wrapWidget(lineReader, LineReader.UP_HISTORY, new Widget() { @Override public boolean apply() { return navigateSlashPalette(lineReader, -1, LineReader.UP_HISTORY); } }); wrapWidget(lineReader, LineReader.DOWN_HISTORY, new Widget() { @Override public boolean apply() { return navigateSlashPalette(lineReader, 1, LineReader.DOWN_HISTORY); } }); bindWidget(lineReader, "ai4j-slash-menu", "/"); bindWidget(lineReader, "ai4j-command-palette", KeyMap.ctrl('P')); bindWidget(lineReader, "ai4j-accept-line", "\r"); bindWidget(lineReader, "ai4j-accept-line", "\n"); bindWidget(lineReader, "ai4j-accept-selection", "\t"); bindWidget(lineReader, "ai4j-palette-up", "\u001b[A"); bindWidget(lineReader, "ai4j-palette-up", "\u001bOA"); bindWidget(lineReader, "ai4j-palette-down", "\u001b[B"); bindWidget(lineReader, "ai4j-palette-down", "\u001bOB"); } @Override public void complete(LineReader lineReader, ParsedLine parsedLine, List candidates) { if (parsedLine == null || candidates == null) { return; } candidates.addAll(suggest(parsedLine.line(), parsedLine.cursor())); } List suggest(String line, int cursor) { String raw = line == null ? "" : line; int safeCursor = Math.max(0, Math.min(cursor, raw.length())); String prefix = raw.substring(0, safeCursor); if (!prefix.startsWith("/")) { return Collections.emptyList(); } boolean endsWithSpace = !prefix.isEmpty() && Character.isWhitespace(prefix.charAt(prefix.length() - 1)); List tokens = splitTokens(prefix); if (tokens.isEmpty()) { return rootCandidates(""); } String command = tokens.get(0); if (tokens.size() == 1 && !endsWithSpace) { if (isExecutableRootCommand(command)) { return rootCandidates(command); } List exactCommandCandidates = exactCommandArgumentCandidates(command); if (!exactCommandCandidates.isEmpty()) { return exactCommandCandidates; } return rootCandidates(command); } if ("/cmd".equalsIgnoreCase(command)) { return customCommandCandidates(tokenFragment(tokens, endsWithSpace)); } if ("/theme".equalsIgnoreCase(command)) { return themeCandidates(tokenFragment(tokens, endsWithSpace)); } if ("/stream".equalsIgnoreCase(command)) { return streamCandidates(tokenFragment(tokens, endsWithSpace)); } if ("/experimental".equalsIgnoreCase(command)) { return experimentalCandidates(tokens, endsWithSpace); } if ("/skills".equalsIgnoreCase(command)) { return skillCandidates(tokenFragment(tokens, endsWithSpace)); } if ("/agents".equalsIgnoreCase(command)) { return agentCandidates(tokenFragment(tokens, endsWithSpace)); } if ("/provider".equalsIgnoreCase(command)) { return providerCandidates(tokens, endsWithSpace); } if ("/mcp".equalsIgnoreCase(command)) { return mcpCandidates(tokens, endsWithSpace); } if ("/model".equalsIgnoreCase(command)) { return modelCandidates(tokenFragment(tokens, endsWithSpace)); } if ("/resume".equalsIgnoreCase(command) || "/load".equalsIgnoreCase(command) || "/history".equalsIgnoreCase(command) || "/tree".equalsIgnoreCase(command) || "/fork".equalsIgnoreCase(command)) { return sessionCandidates(tokenFragment(tokens, endsWithSpace)); } if ("/process".equalsIgnoreCase(command)) { return processCandidates(tokens, endsWithSpace); } if ("/team".equalsIgnoreCase(command)) { return teamCandidates(tokens, endsWithSpace); } return Collections.emptyList(); } private List exactCommandArgumentCandidates(String command) { if (isBlank(command)) { return Collections.emptyList(); } if ("/cmd".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", customCommandCandidates("")); } if ("/theme".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", themeCandidates("")); } if ("/stream".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", streamCandidates("")); } if ("/experimental".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", experimentalFeatureCandidates("")); } if ("/skills".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", skillCandidates("")); } if ("/agents".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", agentCandidates("")); } if ("/provider".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", providerActionCandidates("")); } if ("/model".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", modelCandidates("")); } if ("/resume".equalsIgnoreCase(command) || "/load".equalsIgnoreCase(command) || "/history".equalsIgnoreCase(command) || "/tree".equalsIgnoreCase(command) || "/fork".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", sessionCandidates("")); } if ("/process".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", processSubcommandCandidates("")); } if ("/team".equalsIgnoreCase(command)) { return prefixCandidates(command + " ", teamActionCandidates("")); } return Collections.emptyList(); } boolean openSlashMenu(LineReader lineReader) { Buffer buffer = lineReader.getBuffer(); if (buffer == null) { return true; } String line = buffer.toString(); SlashMenuAction action = resolveSlashMenuAction(line, buffer.cursor()); if (action == SlashMenuAction.INSERT_AND_MENU) { buffer.write("/"); syncSlashPalette(lineReader); refreshStatusAndDisplay(lineReader); return true; } if (action == SlashMenuAction.MENU_ONLY) { syncSlashPalette(lineReader); refreshStatusAndDisplay(lineReader); return true; } buffer.write("/"); return true; } private boolean openCommandPalette(LineReader lineReader) { Buffer buffer = lineReader.getBuffer(); if (buffer == null) { return true; } if (buffer.length() == 0) { buffer.write("/"); syncSlashPalette(lineReader); refreshStatusAndDisplay(lineReader); return true; } if (buffer.toString().startsWith("/")) { syncSlashPalette(lineReader); refreshStatusAndDisplay(lineReader); return true; } return false; } private boolean acceptLine(LineReader lineReader) { Buffer buffer = lineReader == null ? null : lineReader.getBuffer(); String current = buffer == null ? null : buffer.toString(); if (!shouldExecuteRawInputOnEnter(current) && acceptSlashSelection(lineReader, false)) { return true; } EnterAction action = resolveAcceptLineAction(current); if (action == EnterAction.IGNORE_EMPTY) { if (buffer != null) { buffer.clear(); } clearPalette(); notifyStatusRefresh(); lineReader.callWidget(LineReader.REDRAW_LINE); lineReader.callWidget(LineReader.REDISPLAY); return true; } clearPalette(); notifyStatusRefresh(); lineReader.callWidget(LineReader.ACCEPT_LINE); return true; } private boolean shouldExecuteRawInputOnEnter(String line) { if (isBlank(line)) { return false; } String normalized = line.trim(); if (!normalized.startsWith("/") || normalized.indexOf(' ') >= 0) { return false; } for (String command : EXECUTABLE_ROOT_COMMANDS) { if (command.equalsIgnoreCase(normalized)) { return true; } } return false; } SlashMenuAction resolveSlashMenuAction(String line, int cursor) { String value = line == null ? "" : line; if (value.isEmpty()) { return SlashMenuAction.INSERT_AND_MENU; } if (value.startsWith("/") && value.indexOf(' ') < 0) { return SlashMenuAction.MENU_ONLY; } return SlashMenuAction.INSERT_ONLY; } EnterAction resolveAcceptLineAction(String line) { return isBlank(line) ? EnterAction.IGNORE_EMPTY : EnterAction.ACCEPT; } public PaletteSnapshot getPaletteSnapshot() { synchronized (paletteLock) { return paletteSnapshot.copy(); } } void movePaletteSelection(int delta) { synchronized (paletteLock) { if (!paletteSnapshot.isOpen() || paletteSnapshot.items.isEmpty()) { return; } int size = paletteSnapshot.items.size(); int nextIndex = Math.floorMod(paletteSnapshot.selectedIndex + delta, size); paletteSnapshot = paletteSnapshot.withSelectedIndex(nextIndex); } notifyStatusRefresh(); } boolean acceptSlashSelection(LineReader lineReader, boolean alwaysAccept) { if (lineReader == null) { return false; } Buffer buffer = lineReader.getBuffer(); if (buffer == null) { return false; } PaletteItemSnapshot selected = getSelectedPaletteItem(); if (selected == null) { return false; } String current = buffer.toString(); String replacement = applySelectedValue(current, buffer.cursor(), selected.value); if (!alwaysAccept && sameText(current, replacement)) { clearPalette(); notifyStatusRefresh(); return false; } buffer.clear(); buffer.write(replacement); if (shouldContinuePaletteAfterAccept(replacement, selected)) { syncSlashPalette(lineReader); refreshStatusAndDisplay(lineReader); return true; } clearPalette(); refreshStatusAndDisplay(lineReader); return true; } private String applySelectedValue(String current, int cursor, String selectedValue) { String line = current == null ? "" : current; String replacement = selectedValue == null ? "" : selectedValue; int safeCursor = Math.max(0, Math.min(cursor, line.length())); int tokenStart = safeCursor; while (tokenStart > 0 && !Character.isWhitespace(line.charAt(tokenStart - 1))) { tokenStart--; } int tokenEnd = safeCursor; while (tokenEnd < line.length() && !Character.isWhitespace(line.charAt(tokenEnd))) { tokenEnd++; } return line.substring(0, tokenStart) + replacement + line.substring(tokenEnd); } private boolean shouldContinuePaletteAfterAccept(String replacement, PaletteItemSnapshot selected) { if (selected == null || isBlank(replacement)) { return false; } if (!replacement.endsWith(" ")) { return false; } String normalized = replacement.trim(); if (commandRequiresArgument(normalized)) { return true; } return !suggest(replacement, replacement.length()).isEmpty(); } private boolean commandRequiresArgument(String command) { if (isBlank(command)) { return false; } for (SlashCommandSpec spec : BUILT_IN_COMMANDS) { if (sameText(spec.command, command)) { return spec.requiresArgument; } } return false; } private boolean isExecutableRootCommand(String value) { if (isBlank(value)) { return false; } for (String command : EXECUTABLE_ROOT_COMMANDS) { if (command.equalsIgnoreCase(value)) { return true; } } return false; } private void bindWidget(LineReader lineReader, String widgetName, String keySequence) { if (lineReader == null || keySequence == null) { return; } Reference reference = new Reference(widgetName); bind(lineReader, LineReader.MAIN, reference, keySequence); bind(lineReader, LineReader.EMACS, reference, keySequence); bind(lineReader, LineReader.VIINS, reference, keySequence); } private void bind(LineReader lineReader, String keymapName, Reference reference, String keySequence) { if (lineReader == null || reference == null || keySequence == null) { return; } java.util.Map> keyMaps = lineReader.getKeyMaps(); if (keyMaps == null) { return; } org.jline.keymap.KeyMap keyMap = keyMaps.get(keymapName); if (keyMap != null) { keyMap.bind(reference, keySequence); } } private void wrapWidget(LineReader lineReader, String widgetName, Widget widget) { if (lineReader == null || widget == null || isBlank(widgetName)) { return; } java.util.Map widgets = lineReader.getWidgets(); if (widgets == null) { return; } Widget original = widgets.get(widgetName); if (original == null) { return; } wrappedWidgets.put(widgetName, original); widgets.put(widgetName, widget); } private boolean delegateAndRefreshSlashPalette(LineReader lineReader, String widgetName) { if (!delegateWidget(lineReader, widgetName)) { return false; } refreshSlashPaletteIfNeeded(lineReader); return true; } private boolean delegateWidget(LineReader lineReader, String widgetName) { if (isBlank(widgetName)) { return false; } Widget widget = wrappedWidgets.get(widgetName); if (widget == null) { return false; } return widget.apply(); } private boolean navigateSlashPalette(LineReader lineReader, int delta, String fallbackWidgetName) { PaletteSnapshot snapshot = getPaletteSnapshot(); if (!snapshot.isOpen() || snapshot.items.isEmpty()) { return delegateWidget(lineReader, fallbackWidgetName); } movePaletteSelection(delta); refreshStatusAndDisplay(lineReader); return true; } private void refreshSlashPaletteIfNeeded(LineReader lineReader) { if (lineReader == null) { return; } Buffer buffer = lineReader.getBuffer(); String line = buffer == null ? null : buffer.toString(); if (!getPaletteSnapshot().isOpen() && (line == null || !line.startsWith("/"))) { return; } syncSlashPalette(lineReader); refreshStatusAndDisplay(lineReader); } private void syncSlashPalette(LineReader lineReader) { if (lineReader == null) { clearPalette(); return; } Buffer buffer = lineReader.getBuffer(); if (buffer == null) { clearPalette(); return; } String line = buffer.toString(); if (isBlank(line) || !line.startsWith("/")) { clearPalette(); return; } updatePalette(line, suggest(line, buffer.cursor())); } private void updatePalette(String query, List candidates) { List items = new ArrayList(); if (candidates != null) { for (Candidate candidate : candidates) { if (candidate == null) { continue; } items.add(new PaletteItemSnapshot( candidate.value(), firstNonBlank(candidate.displ(), candidate.value()), candidate.descr(), candidate.group() )); } } synchronized (paletteLock) { String selectedValue = paletteSnapshot.selectedValue(); int selectedIndex = resolveSelectedIndex(items, query, selectedValue); paletteSnapshot = new PaletteSnapshot(true, query, selectedIndex, items); } notifyStatusRefresh(); } private int resolveSelectedIndex(List items, String query, String selectedValue) { if (items == null || items.isEmpty()) { return -1; } if (!isBlank(selectedValue)) { for (int i = 0; i < items.size(); i++) { if (sameText(selectedValue, items.get(i).value)) { return i; } } } if (!isBlank(query)) { for (int i = 0; i < items.size(); i++) { if (sameText(query, items.get(i).value)) { return i; } } } return 0; } private void clearPalette() { synchronized (paletteLock) { paletteSnapshot = PaletteSnapshot.closed(); } } private PaletteItemSnapshot getSelectedPaletteItem() { synchronized (paletteLock) { if (!paletteSnapshot.isOpen() || paletteSnapshot.selectedIndex < 0 || paletteSnapshot.selectedIndex >= paletteSnapshot.items.size()) { return null; } return paletteSnapshot.items.get(paletteSnapshot.selectedIndex); } } private void refreshStatusAndDisplay(LineReader lineReader) { notifyStatusRefresh(); if (lineReader == null) { return; } lineReader.callWidget(LineReader.REDRAW_LINE); lineReader.callWidget(LineReader.REDISPLAY); } private void notifyStatusRefresh() { Runnable refresh = statusRefresh; if (refresh != null) { refresh.run(); } } private List rootCandidates(String partial) { List candidates = new ArrayList(); for (SlashCommandSpec spec : BUILT_IN_COMMANDS) { if (matches(spec.command, partial)) { candidates.add(commandCandidate(spec.command, spec.command, "Commands", spec.description, spec.requiresArgument)); } } return candidates; } private List prefixCandidates(String prefix, List candidates) { if (isBlank(prefix) || candidates == null || candidates.isEmpty()) { return candidates == null ? Collections.emptyList() : candidates; } List prefixed = new ArrayList(candidates.size()); for (Candidate candidate : candidates) { if (candidate == null) { continue; } prefixed.add(new Candidate( prefix + firstNonBlank(candidate.value(), ""), firstNonBlank(candidate.displ(), candidate.value()), candidate.group(), candidate.descr(), candidate.suffix(), candidate.key(), candidate.complete() )); } return prefixed; } private List customCommandCandidates(String partial) { if (customCommandRegistry == null) { return Collections.emptyList(); } List candidates = new ArrayList(); for (CustomCommandTemplate template : customCommandRegistry.list()) { if (template == null || !matches(template.getName(), partial)) { continue; } candidates.add(new Candidate( template.getName(), template.getName(), "Templates", firstNonBlank(template.getDescription(), "Run custom command " + template.getName()), null, null, true )); } return candidates; } private List themeCandidates(String partial) { if (tuiConfigManager == null) { return Collections.emptyList(); } List candidates = new ArrayList(); for (String themeName : tuiConfigManager.listThemeNames()) { if (!matches(themeName, partial)) { continue; } candidates.add(new Candidate(themeName, themeName, "Themes", "Switch to theme " + themeName, null, null, true)); } return candidates; } private List streamCandidates(String partial) { List candidates = new ArrayList(); for (String option : STREAM_OPTIONS) { if (!matches(option, partial)) { continue; } candidates.add(new Candidate( option, option, "Stream", "Turn transcript streaming " + option, null, null, true )); } return candidates; } private List experimentalCandidates(List tokens, boolean endsWithSpace) { if (tokens == null || tokens.isEmpty()) { return Collections.emptyList(); } if (tokens.size() == 1) { return experimentalFeatureCandidates(""); } if (tokens.size() == 2 && !endsWithSpace) { return experimentalFeatureCandidates(tokens.get(1)); } if (tokens.size() == 2 && endsWithSpace) { return experimentalToggleCandidates(""); } if (tokens.size() == 3 && !endsWithSpace) { return experimentalToggleCandidates(tokens.get(2)); } return Collections.emptyList(); } private List experimentalFeatureCandidates(String partial) { List candidates = new ArrayList(); for (String feature : EXPERIMENTAL_FEATURES) { if (!matches(feature, partial)) { continue; } candidates.add(new Candidate( feature, feature, "Experimental", describeExperimentalFeature(feature), null, null, true )); } return candidates; } private String describeExperimentalFeature(String feature) { if ("subagent".equalsIgnoreCase(feature)) { return "Toggle experimental subagent tool injection"; } if ("agent-teams".equalsIgnoreCase(feature)) { return "Toggle experimental agent team tool injection"; } return "Experimental runtime feature"; } private List experimentalToggleCandidates(String partial) { List candidates = new ArrayList(); for (String option : STREAM_OPTIONS) { if (!matches(option, partial)) { continue; } candidates.add(new Candidate( option, option, "Experimental", "Set experimental feature " + option, null, null, true )); } return candidates; } private List teamCandidates(List tokens, boolean endsWithSpace) { if (tokens == null || tokens.isEmpty()) { return Collections.emptyList(); } if (tokens.size() == 1) { return teamActionCandidates(""); } if (tokens.size() == 2 && !endsWithSpace) { return teamActionCandidates(tokens.get(1)); } String action = tokens.get(1); if ("list".equalsIgnoreCase(action)) { return Collections.emptyList(); } if ("status".equalsIgnoreCase(action) || "resume".equalsIgnoreCase(action)) { if (tokens.size() == 2 && endsWithSpace) { return teamIdCandidates(""); } if (tokens.size() == 3 && !endsWithSpace) { return teamIdCandidates(tokens.get(2)); } return Collections.emptyList(); } if ("messages".equalsIgnoreCase(action)) { if (tokens.size() == 2 && endsWithSpace) { return teamIdCandidates(""); } if (tokens.size() == 3 && !endsWithSpace) { return teamIdCandidates(tokens.get(2)); } if (tokens.size() == 3 && endsWithSpace) { return teamMessageLimitCandidates(""); } if (tokens.size() == 4 && !endsWithSpace) { return teamMessageLimitCandidates(tokens.get(3)); } } return Collections.emptyList(); } private List teamActionCandidates(String partial) { List candidates = new ArrayList(); for (String action : TEAM_ACTIONS) { if (!matches(action, partial)) { continue; } candidates.add(commandCandidate(action, action, "Team", describeTeamAction(action), !"list".equalsIgnoreCase(action))); } return candidates; } private List teamIdCandidates(String partial) { Supplier> supplier = teamCandidateSupplier; List teamIds = supplier == null ? null : supplier.get(); if (teamIds == null || teamIds.isEmpty()) { return Collections.emptyList(); } List candidates = new ArrayList(); for (String teamId : teamIds) { if (isBlank(teamId) || !matches(teamId, partial)) { continue; } candidates.add(new Candidate( teamId, teamId, "Team", "Inspect persisted team " + teamId, null, null, true )); } return candidates; } private List teamMessageLimitCandidates(String partial) { List candidates = new ArrayList(); for (String option : TEAM_MESSAGE_LIMITS) { if (!matches(option, partial)) { continue; } candidates.add(new Candidate( option, option, "Team", "Read up to " + option + " persisted team messages", null, null, true )); } return candidates; } private String describeTeamAction(String action) { if ("list".equalsIgnoreCase(action)) { return "List persisted teams in the current workspace"; } if ("status".equalsIgnoreCase(action)) { return "Show one persisted team's current snapshot"; } if ("messages".equalsIgnoreCase(action)) { return "Show recent messages from a persisted team mailbox"; } if ("resume".equalsIgnoreCase(action)) { return "Load a persisted team snapshot and reopen its board view"; } return "Team action"; } private List skillCandidates(String partial) { Supplier> supplier = skillCandidateSupplier; List skills = supplier == null ? null : supplier.get(); if (skills == null || skills.isEmpty()) { return Collections.emptyList(); } List candidates = new ArrayList(); for (String skill : skills) { if (isBlank(skill) || !matches(skill, partial)) { continue; } candidates.add(new Candidate( skill, skill, "Skills", "Inspect coding skill " + skill, null, null, true )); } return candidates; } private List agentCandidates(String partial) { Supplier> supplier = agentCandidateSupplier; List agents = supplier == null ? null : supplier.get(); if (agents == null || agents.isEmpty()) { return Collections.emptyList(); } List candidates = new ArrayList(); for (String agent : agents) { if (isBlank(agent) || !matches(agent, partial)) { continue; } candidates.add(new Candidate( agent, agent, "Agents", "Inspect coding agent " + agent, null, null, true )); } return candidates; } private List mcpCandidates(List tokens, boolean endsWithSpace) { if (tokens == null || tokens.isEmpty()) { return Collections.emptyList(); } if (tokens.size() == 1) { return mcpActionCandidates(""); } if (tokens.size() == 2 && !endsWithSpace) { return mcpActionCandidates(tokens.get(1)); } String action = tokens.get(1); if ("add".equalsIgnoreCase(action)) { return mcpAddCandidates(tokens, endsWithSpace); } if ("list".equalsIgnoreCase(action)) { return Collections.emptyList(); } if (tokens.size() == 2 && endsWithSpace) { return mcpServerNameCandidates(action, ""); } if (tokens.size() == 3 && !endsWithSpace) { return mcpServerNameCandidates(action, tokens.get(2)); } return Collections.emptyList(); } private List mcpActionCandidates(String partial) { List candidates = new ArrayList(); for (String action : MCP_ACTIONS) { if (!matches(action, partial)) { continue; } candidates.add(commandCandidate(action, action, "MCP", describeMcpAction(action), true)); } return candidates; } private String describeMcpAction(String action) { if ("list".equalsIgnoreCase(action)) { return "List MCP services"; } if ("add".equalsIgnoreCase(action)) { return "Add a global MCP service"; } if ("enable".equalsIgnoreCase(action)) { return "Enable an MCP service in this workspace"; } if ("disable".equalsIgnoreCase(action)) { return "Disable an MCP service in this workspace"; } if ("pause".equalsIgnoreCase(action)) { return "Pause an MCP service for this session"; } if ("resume".equalsIgnoreCase(action)) { return "Resume an MCP service for this session"; } if ("retry".equalsIgnoreCase(action)) { return "Reconnect an MCP service"; } if ("remove".equalsIgnoreCase(action)) { return "Delete a global MCP service"; } return "MCP action"; } private List mcpAddCandidates(List tokens, boolean endsWithSpace) { if (tokens == null || tokens.size() < 2) { return Collections.emptyList(); } if (tokens.size() == 2 && endsWithSpace) { return mcpAddOptionCandidates(""); } if (tokens.size() == 3 && !endsWithSpace) { return mcpAddOptionCandidates(tokens.get(2)); } if (tokens.size() == 3 && endsWithSpace) { if (MCP_TRANSPORT_FLAG.equalsIgnoreCase(tokens.get(2))) { return mcpTransportCandidates(""); } return Collections.emptyList(); } if (tokens.size() == 4 && !endsWithSpace && MCP_TRANSPORT_FLAG.equalsIgnoreCase(tokens.get(2))) { return mcpTransportCandidates(tokens.get(3)); } return Collections.emptyList(); } private List mcpAddOptionCandidates(String partial) { List candidates = new ArrayList(); if (matches(MCP_TRANSPORT_FLAG, partial)) { candidates.add(commandCandidate( MCP_TRANSPORT_FLAG, MCP_TRANSPORT_FLAG, "MCP", "Choose the MCP transport: stdio, sse, http", true )); } return candidates; } private List mcpTransportCandidates(String partial) { List candidates = new ArrayList(); for (String transport : MCP_TRANSPORT_OPTIONS) { if (!matches(transport, partial)) { continue; } candidates.add(new Candidate( transport, transport, "MCP", "Use " + transport + " transport", null, null, true )); } return candidates; } private List mcpServerNameCandidates(String action, String partial) { Supplier> supplier = mcpServerCandidateSupplier; List serverNames = supplier == null ? null : supplier.get(); if (serverNames == null || serverNames.isEmpty()) { return Collections.emptyList(); } List candidates = new ArrayList(); for (String serverName : serverNames) { if (isBlank(serverName) || !matches(serverName, partial)) { continue; } candidates.add(new Candidate( serverName, serverName, "MCP", describeMcpServerAction(action, serverName), null, null, true )); } return candidates; } private String describeMcpServerAction(String action, String serverName) { if ("enable".equalsIgnoreCase(action)) { return "Enable MCP service " + serverName; } if ("disable".equalsIgnoreCase(action)) { return "Disable MCP service " + serverName; } if ("pause".equalsIgnoreCase(action)) { return "Pause MCP service " + serverName + " for this session"; } if ("resume".equalsIgnoreCase(action)) { return "Resume MCP service " + serverName + " for this session"; } if ("retry".equalsIgnoreCase(action)) { return "Reconnect MCP service " + serverName; } if ("remove".equalsIgnoreCase(action)) { return "Delete global MCP service " + serverName; } return "Use MCP service " + serverName; } private List providerCandidates(List tokens, boolean endsWithSpace) { if (tokens == null || tokens.isEmpty()) { return Collections.emptyList(); } if (tokens.size() == 1) { return providerActionCandidates(""); } if (tokens.size() == 2 && !endsWithSpace) { String action = tokens.get(1); if (isExactProviderAction(action)) { List nestedCandidates = providerNameCandidates(action, ""); if (!nestedCandidates.isEmpty()) { return prefixCandidates("/provider " + action + " ", nestedCandidates); } } return providerActionCandidates(action); } String action = tokens.get(1); if (("add".equalsIgnoreCase(action) || "edit".equalsIgnoreCase(action)) && (tokens.size() > 2 || endsWithSpace)) { return providerMutationCandidates(action, tokens, endsWithSpace); } if (tokens.size() == 2 && endsWithSpace) { return providerNameCandidates(action, ""); } if (tokens.size() == 3 && !endsWithSpace) { return providerNameCandidates(action, tokens.get(2)); } return Collections.emptyList(); } private boolean isExactProviderAction(String value) { if (isBlank(value)) { return false; } for (String action : PROVIDER_ACTIONS) { if (action.equalsIgnoreCase(value)) { return true; } } return false; } private List providerActionCandidates(String partial) { List candidates = new ArrayList(); for (String action : PROVIDER_ACTIONS) { if (!matches(action, partial)) { continue; } candidates.add(commandCandidate(action, action, "Provider", "provider " + action, true)); } return candidates; } private List providerNameCandidates(String action, String partial) { if ("add".equalsIgnoreCase(action)) { return Collections.emptyList(); } if ("default".equalsIgnoreCase(action)) { return providerDefaultCandidates(partial); } Supplier> supplier = profileCandidateSupplier; List profiles = supplier == null ? null : supplier.get(); if (profiles == null || profiles.isEmpty()) { return Collections.emptyList(); } List candidates = new ArrayList(); for (String profile : profiles) { if (!matches(profile, partial)) { continue; } candidates.add(new Candidate( profile, profile, "Profiles", describeProviderProfileAction(action, profile), null, null, true )); } return candidates; } private String describeProviderProfileAction(String action, String profile) { if ("save".equalsIgnoreCase(action)) { return "Overwrite saved profile " + profile; } if ("edit".equalsIgnoreCase(action)) { return "Edit profile " + profile; } if ("remove".equalsIgnoreCase(action)) { return "Delete profile " + profile; } return "Use profile " + profile; } private List providerDefaultCandidates(String partial) { List candidates = new ArrayList(); for (String option : PROVIDER_DEFAULT_OPTIONS) { if (!matches(option, partial)) { continue; } candidates.add(new Candidate( option, option, "Provider", "Clear the global default profile", null, null, true )); } Supplier> supplier = profileCandidateSupplier; List profiles = supplier == null ? null : supplier.get(); if (profiles == null) { return candidates; } for (String profile : profiles) { if (!matches(profile, partial)) { continue; } candidates.add(new Candidate( profile, profile, "Profiles", "Set the default profile to " + profile, null, null, true )); } return candidates; } private List providerMutationCandidates(String action, List tokens, boolean endsWithSpace) { if (tokens == null || tokens.size() < 2) { return Collections.emptyList(); } if ("edit".equalsIgnoreCase(action)) { if (tokens.size() == 2 && endsWithSpace) { return providerNameCandidates(action, ""); } if (tokens.size() == 3 && !endsWithSpace) { return providerNameCandidates(action, tokens.get(2)); } } else if (tokens.size() == 2 && endsWithSpace) { return Collections.emptyList(); } if (tokens.size() <= 3) { return endsWithSpace ? providerMutationOptionCandidates("") : Collections.emptyList(); } String lastToken = tokens.get(tokens.size() - 1); if (endsWithSpace) { if (isProviderMutationOption(lastToken)) { return providerMutationValueCandidates(lastToken, ""); } return providerMutationOptionCandidates(""); } if (lastToken.startsWith("--")) { return providerMutationOptionCandidates(lastToken); } String previousToken = tokens.get(tokens.size() - 2); if (isProviderMutationOption(previousToken)) { return providerMutationValueCandidates(previousToken, lastToken); } return Collections.emptyList(); } private List providerMutationOptionCandidates(String partial) { List candidates = new ArrayList(); for (String option : PROVIDER_MUTATION_OPTIONS) { if (!matches(option, partial)) { continue; } candidates.add(commandCandidate( option, option, "Provider", describeProviderMutationOption(option), true )); } return candidates; } private String describeProviderMutationOption(String option) { if ("--provider".equalsIgnoreCase(option)) { return "Set provider name"; } if ("--protocol".equalsIgnoreCase(option)) { return "Set protocol: chat, responses"; } if ("--model".equalsIgnoreCase(option)) { return "Set default model"; } if ("--base-url".equalsIgnoreCase(option)) { return "Set custom base URL"; } if ("--api-key".equalsIgnoreCase(option)) { return "Set profile API key"; } if ("--clear-model".equalsIgnoreCase(option)) { return "Clear saved model"; } if ("--clear-base-url".equalsIgnoreCase(option)) { return "Clear saved base URL"; } if ("--clear-api-key".equalsIgnoreCase(option)) { return "Clear saved API key"; } return "Provider option"; } private boolean isProviderMutationOption(String value) { if (isBlank(value)) { return false; } for (String option : PROVIDER_MUTATION_OPTIONS) { if (option.equalsIgnoreCase(value)) { return true; } } return false; } private List providerMutationValueCandidates(String option, String partial) { if ("--provider".equalsIgnoreCase(option)) { return providerTypeCandidates(partial); } if ("--protocol".equalsIgnoreCase(option)) { return providerProtocolCandidates(partial); } return Collections.emptyList(); } private List providerTypeCandidates(String partial) { List candidates = new ArrayList(); for (PlatformType platformType : PlatformType.values()) { if (platformType == null || isBlank(platformType.getPlatform()) || !matches(platformType.getPlatform(), partial)) { continue; } candidates.add(new Candidate( platformType.getPlatform(), platformType.getPlatform(), "Providers", "Use provider " + platformType.getPlatform(), null, null, true )); } return candidates; } private List providerProtocolCandidates(String partial) { List candidates = new ArrayList(); addProviderProtocolCandidate(candidates, CliProtocol.CHAT, partial); addProviderProtocolCandidate(candidates, CliProtocol.RESPONSES, partial); return candidates; } private void addProviderProtocolCandidate(List candidates, CliProtocol protocol, String partial) { if (protocol == null || isBlank(protocol.getValue()) || !matches(protocol.getValue(), partial)) { return; } candidates.add(new Candidate( protocol.getValue(), protocol.getValue(), "Protocols", "Use protocol " + protocol.getValue(), null, null, true )); } private List modelCandidates(String partial) { LinkedHashMap candidates = new LinkedHashMap(); Supplier> supplier = modelCandidateSupplier; List modelCandidates = supplier == null ? null : supplier.get(); if (modelCandidates != null) { for (ModelCompletionCandidate modelCandidate : modelCandidates) { if (modelCandidate == null || isBlank(modelCandidate.getModel()) || !matches(modelCandidate.getModel(), partial)) { continue; } String model = modelCandidate.getModel(); candidates.put(model.toLowerCase(Locale.ROOT), new Candidate( model, model, "Models", firstNonBlank(modelCandidate.getDescription(), "Use model " + model), null, null, true )); } } if (matches(MODEL_RESET, partial)) { candidates.put("__reset__", new Candidate( MODEL_RESET, MODEL_RESET, "Model", "Reset the workspace model override", null, null, true )); } return new ArrayList(candidates.values()); } private List sessionCandidates(String partial) { CodingSessionManager manager = sessionManager; if (manager == null) { return Collections.emptyList(); } try { List candidates = new ArrayList(); for (CodingSessionDescriptor descriptor : manager.list()) { if (descriptor == null || !matches(descriptor.getSessionId(), partial)) { continue; } candidates.add(new Candidate( descriptor.getSessionId(), descriptor.getSessionId(), "Sessions", firstNonBlank(descriptor.getSummary(), "Saved session"), null, null, true )); } return candidates; } catch (IOException ignored) { return Collections.emptyList(); } } private List processCandidates(List tokens, boolean endsWithSpace) { if (tokens == null || tokens.isEmpty()) { return Collections.emptyList(); } if (tokens.size() == 1) { return processSubcommandCandidates(""); } if (tokens.size() == 2 && !endsWithSpace) { return processSubcommandCandidates(tokens.get(1)); } ProcessCommandSpec commandSpec = findProcessCommandSpec(tokens.get(1)); if (commandSpec == null) { return Collections.emptyList(); } if (tokens.size() == 2 && endsWithSpace) { return processIdCandidates(commandSpec, ""); } if (tokens.size() == 3 && !endsWithSpace) { return processIdCandidates(commandSpec, tokens.get(2)); } if (tokens.size() == 3 && endsWithSpace) { if (commandSpec.acceptsFreeText) { return Collections.emptyList(); } if (commandSpec.supportsLimit) { return processLimitCandidates(commandSpec, ""); } return Collections.emptyList(); } if (tokens.size() == 4 && !endsWithSpace && commandSpec.supportsLimit) { return processLimitCandidates(commandSpec, tokens.get(3)); } return Collections.emptyList(); } private List processSubcommandCandidates(String partial) { List candidates = new ArrayList(); for (ProcessCommandSpec action : PROCESS_COMMANDS) { if (!matches(action.name, partial)) { continue; } candidates.add(commandCandidate( action.name, action.name, "Process", action.description, true )); } return candidates; } private List processIdCandidates(ProcessCommandSpec commandSpec, String partial) { Supplier> supplier = processCandidateSupplier; List processCandidates = supplier == null ? null : supplier.get(); if (processCandidates == null || processCandidates.isEmpty()) { return Collections.emptyList(); } List candidates = new ArrayList(); for (ProcessCompletionCandidate processCandidate : processCandidates) { if (processCandidate == null || !matches(processCandidate.getProcessId(), partial)) { continue; } candidates.add(commandCandidate( processCandidate.getProcessId(), processCandidate.getProcessId(), "Processes", firstNonBlank(processCandidate.getDescription(), "Process " + processCandidate.getProcessId()), commandSpec.supportsLimit || commandSpec.acceptsFreeText )); } return candidates; } private List processLimitCandidates(ProcessCommandSpec commandSpec, String partial) { List limits = "logs".equalsIgnoreCase(commandSpec.name) ? PROCESS_LOG_LIMITS : PROCESS_FOLLOW_LIMITS; List candidates = new ArrayList(); for (String limit : limits) { if (!matches(limit, partial)) { continue; } candidates.add(new Candidate( limit, limit, "Limits", commandSpec.name + " limit " + limit, null, null, true )); } return candidates; } private ProcessCommandSpec findProcessCommandSpec(String value) { if (isBlank(value)) { return null; } for (ProcessCommandSpec commandSpec : PROCESS_COMMANDS) { if (commandSpec.name.equalsIgnoreCase(value)) { return commandSpec; } } return null; } private Candidate commandCandidate(String value, String display, String group, String description, boolean appendSpace) { String candidateValue = appendSpace ? value + " " : value; return new Candidate(candidateValue, display, group, description, null, null, !appendSpace); } private String tokenFragment(List tokens, boolean endsWithSpace) { if (tokens == null || tokens.isEmpty()) { return ""; } if (endsWithSpace) { return ""; } return tokens.get(tokens.size() - 1); } private List splitTokens(String value) { if (isBlank(value)) { return Collections.emptyList(); } String trimmed = value.trim(); if (trimmed.isEmpty()) { return Collections.emptyList(); } return Arrays.asList(trimmed.split("\\s+")); } private boolean matches(String candidate, String partial) { if (isBlank(candidate)) { return false; } if (isBlank(partial) || "/".equals(partial)) { return true; } String normalizedCandidate = candidate.toLowerCase(Locale.ROOT); String normalizedPartial = partial.toLowerCase(Locale.ROOT); return normalizedCandidate.startsWith(normalizedPartial); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private boolean sameText(String left, String right) { if (left == null) { return right == null; } return left.equals(right); } private static final class SlashCommandSpec { private final String command; private final String description; private final boolean requiresArgument; private SlashCommandSpec(String command, String description, boolean requiresArgument) { this.command = command; this.description = description; this.requiresArgument = requiresArgument; } } public static final class ProcessCompletionCandidate { private final String processId; private final String description; public ProcessCompletionCandidate(String processId, String description) { this.processId = processId; this.description = description; } public String getProcessId() { return processId; } public String getDescription() { return description; } } public static final class ModelCompletionCandidate { private final String model; private final String description; public ModelCompletionCandidate(String model, String description) { this.model = model; this.description = description; } public String getModel() { return model; } public String getDescription() { return description; } } private static final class ProcessCommandSpec { private final String name; private final String description; private final boolean supportsLimit; private final boolean acceptsFreeText; private ProcessCommandSpec(String name, String description, boolean supportsLimit, boolean acceptsFreeText) { this.name = name; this.description = description; this.supportsLimit = supportsLimit; this.acceptsFreeText = acceptsFreeText; } } public static final class PaletteSnapshot { private final boolean open; private final String query; private final int selectedIndex; private final List items; private PaletteSnapshot(boolean open, String query, int selectedIndex, List items) { this.open = open; this.query = query; this.selectedIndex = selectedIndex; this.items = items == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList(items)); } public static PaletteSnapshot closed() { return new PaletteSnapshot(false, "", -1, Collections.emptyList()); } public boolean isOpen() { return open; } public String getQuery() { return query; } public int getSelectedIndex() { return selectedIndex; } public List getItems() { return items; } PaletteSnapshot withSelectedIndex(int selectedIndex) { return new PaletteSnapshot(open, query, selectedIndex, items); } PaletteSnapshot copy() { return new PaletteSnapshot(open, query, selectedIndex, items); } String selectedValue() { if (selectedIndex < 0 || selectedIndex >= items.size()) { return null; } return items.get(selectedIndex).value; } } public static final class PaletteItemSnapshot { private final String value; private final String display; private final String description; private final String group; private PaletteItemSnapshot(String value, String display, String description, String group) { this.value = value; this.display = display; this.description = description; this.group = group; } public String getValue() { return value; } public String getDisplay() { return display; } public String getDescription() { return description; } public String getGroup() { return group; } } enum SlashMenuAction { INSERT_AND_MENU, MENU_ONLY, INSERT_ONLY } enum EnterAction { ACCEPT, IGNORE_EMPTY } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpCodingCliAgentFactory.java ================================================ package io.github.lnyocly.ai4j.cli.acp; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig; import io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import java.util.Collection; final class AcpCodingCliAgentFactory extends DefaultCodingCliAgentFactory { private final AcpToolApprovalDecorator.PermissionGateway permissionGateway; private final CliResolvedMcpConfig resolvedMcpConfig; AcpCodingCliAgentFactory(AcpToolApprovalDecorator.PermissionGateway permissionGateway, CliResolvedMcpConfig resolvedMcpConfig) { this.permissionGateway = permissionGateway; this.resolvedMcpConfig = resolvedMcpConfig; } @Override protected CliMcpRuntimeManager prepareMcpRuntime(CodeCommandOptions options, Collection pausedMcpServers, TerminalIO terminal) { if (resolvedMcpConfig == null) { return super.prepareMcpRuntime(options, pausedMcpServers, terminal); } try { return CliMcpRuntimeManager.initialize(resolvedMcpConfig); } catch (Exception ex) { return null; } } @Override protected ToolExecutorDecorator createToolExecutorDecorator(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) { return new AcpToolApprovalDecorator( options == null ? ApprovalMode.AUTO : options.getApprovalMode(), permissionGateway ); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpCommand.java ================================================ package io.github.lnyocly.ai4j.cli.acp; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Properties; public class AcpCommand { private final Map env; private final Properties properties; private final Path currentDirectory; private final CodeCommandOptionsParser parser = new CodeCommandOptionsParser(); public AcpCommand(Map env, Properties properties, Path currentDirectory) { this.env = env; this.properties = properties; this.currentDirectory = currentDirectory; } public int run(List args, InputStream in, OutputStream out, OutputStream err) { try { CodeCommandOptions options = parser.parse(args, env, properties, currentDirectory); if (options.isHelp()) { printHelp(err); return 0; } return new AcpJsonRpcServer(in, out, err, options).run(); } catch (IllegalArgumentException ex) { writeLine(err, "Argument error: " + ex.getMessage()); printHelp(err); return 2; } catch (Exception ex) { writeLine(err, "ACP failed: " + safeMessage(ex)); return 1; } } private void printHelp(OutputStream err) { writeLine(err, "ai4j-cli acp"); writeLine(err, " Start the coding agent as an ACP stdio server."); writeLine(err, ""); writeLine(err, "Usage:"); writeLine(err, " ai4j-cli acp --provider --model [options]"); writeLine(err, ""); writeLine(err, "Notes:"); writeLine(err, " ACP mode uses newline-delimited JSON-RPC on stdin/stdout."); writeLine(err, " All logs and warnings are written to stderr."); writeLine(err, " Provider/model/api-key options follow the same parsing rules as `ai4j-cli code`."); } private void writeLine(OutputStream stream, String line) { try { stream.write((line + System.lineSeparator()).getBytes("UTF-8")); stream.flush(); } catch (Exception ignored) { } } private String safeMessage(Throwable throwable) { String message = throwable == null ? null : throwable.getMessage(); return message == null || message.trim().isEmpty() ? (throwable == null ? "unknown error" : throwable.getClass().getSimpleName()) : message.trim(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpJsonRpcServer.java ================================================ package io.github.lnyocly.ai4j.cli.acp; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.mcp.CliMcpServerDefinition; import io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig; import io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpServer; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.cli.provider.CliProviderProfile; import io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig; import io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig; import io.github.lnyocly.ai4j.cli.runtime.HeadlessCodingSessionRuntime; import io.github.lnyocly.ai4j.cli.runtime.HeadlessTurnObserver; import io.github.lnyocly.ai4j.cli.runtime.CodingTaskSessionEventBridge; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager; import io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.FileSessionEventStore; import io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.coding.runtime.CodingRuntime; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.service.PlatformType; import java.io.BufferedReader; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; public class AcpJsonRpcServer implements Closeable { interface AgentFactoryProvider { CodingCliAgentFactory create(CodeCommandOptions options, AcpToolApprovalDecorator.PermissionGateway permissionGateway, CliResolvedMcpConfig resolvedMcpConfig); } private static final String METHOD_INITIALIZE = "initialize"; private static final String METHOD_SESSION_NEW = "session/new"; private static final String METHOD_SESSION_LOAD = "session/load"; private static final String METHOD_SESSION_LIST = "session/list"; private static final String METHOD_SESSION_PROMPT = "session/prompt"; private static final String METHOD_SESSION_SET_MODE = "session/set_mode"; private static final String METHOD_SESSION_SET_CONFIG_OPTION = "session/set_config_option"; private static final String METHOD_SESSION_CANCEL = "session/cancel"; private static final String METHOD_SESSION_UPDATE = "session/update"; private static final String METHOD_SESSION_REQUEST_PERMISSION = "session/request_permission"; private final InputStream inputStream; private final PrintWriter stdout; private final PrintWriter stderr; private final CodeCommandOptions baseOptions; private final AgentFactoryProvider agentFactoryProvider; private final Object writeLock = new Object(); private final AtomicLong outboundRequestIds = new AtomicLong(1L); private final ExecutorService requestExecutor = Executors.newSingleThreadExecutor(); private final ExecutorService promptExecutor = Executors.newCachedThreadPool(); private final ConcurrentMap sessions = new ConcurrentHashMap(); private final ConcurrentMap> pendingClientResponses = new ConcurrentHashMap>(); public AcpJsonRpcServer(InputStream inputStream, OutputStream outputStream, OutputStream errorStream, CodeCommandOptions baseOptions) { this(inputStream, outputStream, errorStream, baseOptions, new AgentFactoryProvider() { @Override public CodingCliAgentFactory create(CodeCommandOptions options, AcpToolApprovalDecorator.PermissionGateway permissionGateway, CliResolvedMcpConfig resolvedMcpConfig) { return new AcpCodingCliAgentFactory(permissionGateway, resolvedMcpConfig); } }); } AcpJsonRpcServer(InputStream inputStream, OutputStream outputStream, OutputStream errorStream, CodeCommandOptions baseOptions, AgentFactoryProvider agentFactoryProvider) { this.inputStream = inputStream; this.stdout = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), true); this.stderr = new PrintWriter(new OutputStreamWriter(errorStream, StandardCharsets.UTF_8), true); this.baseOptions = baseOptions; this.agentFactoryProvider = agentFactoryProvider; } public int run() { BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); try { String line; while ((line = reader.readLine()) != null) { String trimmed = line == null ? null : line.trim(); if (trimmed == null || trimmed.isEmpty()) { continue; } JSONObject message; try { message = JSON.parseObject(trimmed); } catch (Exception ex) { logError("Invalid ACP JSON message: " + safeMessage(ex)); continue; } if (message != null) { handleMessage(message); } } return 0; } catch (IOException ex) { logError("ACP server failed: " + safeMessage(ex)); return 1; } finally { awaitPendingWork(); close(); } } private void handleMessage(final JSONObject message) { if (message.containsKey("method")) { final String method = message.getString("method"); final Object id = message.get("id"); final JSONObject params = message.getJSONObject("params"); requestExecutor.submit(new Runnable() { @Override public void run() { if (id == null) { handleNotification(method, params); } else { handleRequest(id, method, params); } } }); return; } if (message.containsKey("id")) { CompletableFuture future = pendingClientResponses.remove(requestIdKey(message.get("id"))); if (future != null) { future.complete(message); } } } private void handleRequest(Object id, String method, JSONObject params) { try { if (METHOD_INITIALIZE.equals(method)) { sendResponse(id, buildInitializeResponse(params)); return; } if (METHOD_SESSION_NEW.equals(method)) { SessionHandle handle = createSession(params, false); sendResponse(id, handle.buildSessionOpenResult()); handle.sendAvailableCommandsUpdate(); return; } if (METHOD_SESSION_LOAD.equals(method)) { SessionHandle handle = createSession(params, true); handle.replayHistory(); sendResponse(id, handle.buildSessionOpenResult()); handle.sendAvailableCommandsUpdate(); return; } if (METHOD_SESSION_LIST.equals(method)) { sendResponse(id, newMap("sessions", listSessions(params))); return; } if (METHOD_SESSION_PROMPT.equals(method)) { promptSession(id, params); return; } if (METHOD_SESSION_SET_MODE.equals(method)) { sendResponse(id, setSessionMode(params)); return; } if (METHOD_SESSION_SET_CONFIG_OPTION.equals(method)) { sendResponse(id, setSessionConfigOption(params)); return; } if (METHOD_SESSION_CANCEL.equals(method)) { cancelSession(params); sendResponse(id, Collections.emptyMap()); return; } sendError(id, -32601, "Method not found: " + method); } catch (Exception ex) { sendError(id, -32000, safeMessage(ex)); } } private void handleNotification(String method, JSONObject params) { try { if (METHOD_SESSION_CANCEL.equals(method)) { cancelSession(params); return; } logError("Ignoring unsupported ACP notification: " + method); } catch (Exception ex) { logError("Failed to handle ACP notification " + method + ": " + safeMessage(ex)); } } private Map buildInitializeResponse(JSONObject params) { int protocolVersion = 1; if (params != null && params.getIntValue("protocolVersion") > 0) { protocolVersion = params.getIntValue("protocolVersion"); } return newMap( "protocolVersion", protocolVersion, "agentInfo", newMap( "name", "ai4j-cli", "version", "2.1.0" ), "agentCapabilities", newMap( "loadSession", Boolean.TRUE, "mcpCapabilities", newMap( "http", Boolean.TRUE, "sse", Boolean.TRUE ), "promptCapabilities", newMap( "audio", Boolean.FALSE, "embeddedContext", Boolean.FALSE, "image", Boolean.FALSE ), "sessionCapabilities", newMap( "list", new LinkedHashMap() ) ), "authMethods", Collections.emptyList() ); } private void sendResponse(Object id, Map result) { Map payload = new LinkedHashMap(); payload.put("jsonrpc", "2.0"); payload.put("id", id); payload.put("result", result == null ? Collections.emptyMap() : result); writeMessage(payload); } private void sendError(Object id, int code, String message) { Map payload = new LinkedHashMap(); payload.put("jsonrpc", "2.0"); payload.put("id", id); payload.put("error", newMap( "code", code, "message", firstNonBlank(message, "unknown ACP error") )); writeMessage(payload); } private void sendNotification(String method, Map params) { Map payload = new LinkedHashMap(); payload.put("jsonrpc", "2.0"); payload.put("method", method); payload.put("params", params == null ? Collections.emptyMap() : params); writeMessage(payload); } private void sendRequest(String id, String method, Map params) { Map payload = new LinkedHashMap(); payload.put("jsonrpc", "2.0"); payload.put("id", id); payload.put("method", method); payload.put("params", params == null ? Collections.emptyMap() : params); writeMessage(payload); } private void writeMessage(Map payload) { synchronized (writeLock) { stdout.println(JSON.toJSONString(payload)); stdout.flush(); } } private void sendSessionUpdate(String sessionId, Map update) { sendNotification(METHOD_SESSION_UPDATE, newMap( "sessionId", sessionId, "update", update )); } private void sendAvailableCommandsUpdate(String sessionId) { if (isBlank(sessionId)) { return; } sendSessionUpdate(sessionId, newMap( "sessionUpdate", "available_commands_update", "availableCommands", AcpSlashCommandSupport.availableCommands() )); } private SessionHandle requireSession(String sessionId) { SessionHandle handle = sessions.get(sessionId); if (handle == null) { throw new IllegalArgumentException("Unknown ACP session: " + sessionId); } return handle; } private void logError(String message) { synchronized (writeLock) { stderr.println(firstNonBlank(message, "unknown ACP error")); stderr.flush(); } } private SessionHandle createSession(JSONObject params, boolean loadExisting) throws Exception { String cwd = requireAbsolutePath(params == null ? null : params.getString("cwd")); String requestedSessionId = params == null ? null : trimToNull(params.getString("sessionId")); CodeCommandOptions sessionOptions = resolveSessionOptions(cwd, requestedSessionId, loadExisting ? requestedSessionId : null); CliResolvedMcpConfig resolvedMcpConfig = resolveMcpConfig(params == null ? null : params.getJSONArray("mcpServers")); final AtomicReference permissionSessionIdRef = new AtomicReference(requestedSessionId); AcpToolApprovalDecorator.PermissionGateway permissionGateway = new AcpToolApprovalDecorator.PermissionGateway() { @Override public AcpToolApprovalDecorator.PermissionDecision requestApproval(String toolName, AgentToolCall call, Map rawInput) throws Exception { return requestPermission(permissionSessionIdRef.get(), toolName, call, rawInput); } }; CodingCliAgentFactory factory = agentFactoryProvider.create(sessionOptions, permissionGateway, resolvedMcpConfig); CodingCliAgentFactory.PreparedCodingAgent prepared = factory.prepare(sessionOptions, null, null, Collections.emptySet()); logMcpWarnings(prepared.getMcpRuntimeManager()); CodingSessionManager sessionManager = createSessionManager(sessionOptions); ManagedCodingSession session = loadExisting ? sessionManager.resume(prepared.getAgent(), prepared.getProtocol(), sessionOptions, requestedSessionId) : sessionManager.create(prepared.getAgent(), prepared.getProtocol(), sessionOptions); permissionSessionIdRef.set(session.getSessionId()); SessionHandle handle = new SessionHandle(sessionOptions, sessionManager, factory, permissionGateway, prepared, session, resolvedMcpConfig); sessions.put(session.getSessionId(), handle); if (!isBlank(requestedSessionId) && !session.getSessionId().equals(requestedSessionId)) { sessions.put(requestedSessionId, handle); } return handle; } private void promptSession(final Object requestId, JSONObject params) throws Exception { String sessionId = requiredString(params, "sessionId"); final SessionHandle handle = requireSession(sessionId); final String input = flattenPrompt(requiredArray(params, "prompt")); handle.startPrompt(new Runnable() { @Override public void run() { HeadlessCodingSessionRuntime.PromptControl promptControl = handle.beginPrompt(); try { HeadlessCodingSessionRuntime.PromptResult result = AcpSlashCommandSupport.supports(input) ? handle.runSlashCommand(input, promptControl) : handle.runPrompt(input, promptControl); sendResponse(requestId, newMap("stopReason", result.getStopReason())); } catch (Exception ex) { sendError(requestId, -32000, safeMessage(ex)); } } }); } private Map setSessionMode(JSONObject params) throws Exception { String sessionId = requiredString(params, "sessionId"); String modeId = requiredString(params, "modeId"); SessionHandle handle = requireSession(sessionId); handle.setMode(modeId); return Collections.emptyMap(); } private Map setSessionConfigOption(JSONObject params) throws Exception { String sessionId = requiredString(params, "sessionId"); String configId = requiredString(params, "configId"); Object valueObject = params == null ? null : params.get("value"); if (valueObject == null) { throw new IllegalArgumentException("Missing required field: value"); } String value = String.valueOf(valueObject); SessionHandle handle = requireSession(sessionId); return newMap( "configOptions", handle.setConfigOption(configId, value) ); } private void cancelSession(JSONObject params) { if (params == null) { return; } String sessionId = trimToNull(params.getString("sessionId")); if (sessionId == null) { return; } SessionHandle handle = sessions.get(sessionId); if (handle != null) { handle.cancel(); } for (CompletableFuture future : pendingClientResponses.values()) { future.complete(buildCancelledPermissionResponse()); } } private List> listSessions(JSONObject params) throws Exception { String cwd = requireAbsolutePath(params == null ? null : params.getString("cwd")); CodingSessionManager sessionManager = createSessionManager(resolveSessionOptions(cwd, null, null)); List descriptors = sessionManager.list(); List> result = new ArrayList>(); for (CodingSessionDescriptor descriptor : descriptors) { result.add(newMap( "sessionId", descriptor.getSessionId(), "title", firstNonBlank(descriptor.getSummary(), descriptor.getSessionId()), "createdAt", descriptor.getCreatedAtEpochMs(), "updatedAt", descriptor.getUpdatedAtEpochMs() )); } return result; } private AcpToolApprovalDecorator.PermissionDecision requestPermission(String sessionId, String toolName, AgentToolCall call, Map rawInput) throws Exception { String requestId = String.valueOf(outboundRequestIds.getAndIncrement()); CompletableFuture future = new CompletableFuture(); pendingClientResponses.put(requestIdKey(requestId), future); sendRequest(requestId, METHOD_SESSION_REQUEST_PERMISSION, newMap( "sessionId", sessionId, "toolCall", buildPermissionToolCall(toolName, call, rawInput), "options", buildPermissionOptions() )); JSONObject response = future.get(); JSONObject result = response == null ? null : response.getJSONObject("result"); JSONObject outcome = result == null ? null : result.getJSONObject("outcome"); if (outcome == null) { return new AcpToolApprovalDecorator.PermissionDecision(false, null); } String kind = outcome.getString("outcome"); if ("selected".equals(kind)) { String optionId = outcome.getString("optionId"); boolean approved = "allow_once".equals(optionId) || "allow_always".equals(optionId); return new AcpToolApprovalDecorator.PermissionDecision(approved, optionId); } return new AcpToolApprovalDecorator.PermissionDecision(false, "cancelled"); } private Map buildPermissionToolCall(String toolName, AgentToolCall call, Map rawInput) { return newMap( "toolCallId", call == null ? UUID.randomUUID().toString() : firstNonBlank(call.getCallId(), UUID.randomUUID().toString()), "kind", mapToolKind(toolName), "title", firstNonBlank(toolName, call == null ? null : call.getName(), "tool"), "rawInput", rawInput ); } private List> buildPermissionOptions() { return Arrays.asList( newMap("optionId", "allow_once", "name", "Allow once", "kind", "allow_once"), newMap("optionId", "allow_always", "name", "Always allow", "kind", "allow_always"), newMap("optionId", "reject_once", "name", "Reject once", "kind", "reject_once"), newMap("optionId", "reject_always", "name", "Always reject", "kind", "reject_always") ); } private JSONObject buildCancelledPermissionResponse() { JSONObject response = new JSONObject(); JSONObject result = new JSONObject(); JSONObject outcome = new JSONObject(); outcome.put("outcome", "cancelled"); result.put("outcome", outcome); response.put("result", result); return response; } private void logMcpWarnings(CliMcpRuntimeManager runtimeManager) { if (runtimeManager == null) { return; } for (String warning : runtimeManager.buildStartupWarnings()) { logError("Warning: " + warning); } } private CliResolvedMcpConfig resolveMcpConfig(JSONArray mcpServers) { if (mcpServers == null || mcpServers.isEmpty()) { return null; } Map servers = new LinkedHashMap(); List enabled = new ArrayList(); for (int i = 0; i < mcpServers.size(); i++) { JSONObject server = mcpServers.getJSONObject(i); if (server == null) { continue; } String name = firstNonBlank(trimToNull(server.getString("name")), "mcp-" + (i + 1)); CliMcpServerDefinition definition = CliMcpServerDefinition.builder() .type(normalizeTransportType(server.getString("type"), server.getString("command"))) .url(trimToNull(server.getString("url"))) .command(trimToNull(server.getString("command"))) .args(toStringList(server.getJSONArray("args"))) .env(toStringMap(server.getJSONObject("env"))) .cwd(trimToNull(server.getString("cwd"))) .headers(toStringMap(server.getJSONObject("headers"))) .build(); String validationError = validateDefinition(definition); servers.put(name, new CliResolvedMcpServer( name, definition.getType(), true, false, validationError == null, validationError, definition )); enabled.add(name); } return new CliResolvedMcpConfig(servers, enabled, Collections.emptyList(), Collections.emptyList()); } private CodeCommandOptions resolveSessionOptions(String workspace, String sessionId, String resumeSessionId) { String resolvedWorkspace = firstNonBlank(workspace, baseOptions == null ? null : baseOptions.getWorkspace(), Paths.get(".").toAbsolutePath().normalize().toString()); Path workspacePath = Paths.get(resolvedWorkspace).toAbsolutePath().normalize(); String defaultBaseStoreDir = baseOptions == null || isBlank(baseOptions.getWorkspace()) ? null : Paths.get(baseOptions.getWorkspace()).resolve(".ai4j").resolve("sessions").toAbsolutePath().normalize().toString(); String configuredStoreDir = baseOptions == null ? null : baseOptions.getSessionStoreDir(); String resolvedStoreDir = configuredStoreDir; if (isBlank(resolvedStoreDir) || resolvedStoreDir.equals(defaultBaseStoreDir)) { resolvedStoreDir = workspacePath.resolve(".ai4j").resolve("sessions").toString(); } if (baseOptions == null) { throw new IllegalStateException("ACP base options are required"); } return baseOptions.withSessionContext(workspacePath.toString(), sessionId, resumeSessionId, resolvedStoreDir); } private CodingSessionManager createSessionManager(CodeCommandOptions options) { if (options.isNoSession()) { Path directory = Paths.get(options.getWorkspace()).resolve(".ai4j").resolve("memory-sessions"); return new DefaultCodingSessionManager( new InMemoryCodingSessionStore(directory), new InMemorySessionEventStore() ); } Path sessionDirectory = Paths.get(options.getSessionStoreDir()); return new DefaultCodingSessionManager( new FileCodingSessionStore(sessionDirectory), new FileSessionEventStore(sessionDirectory.resolve("events")) ); } private String flattenPrompt(JSONArray blocks) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < blocks.size(); i++) { JSONObject block = blocks.getJSONObject(i); if (block == null) { continue; } String type = normalizeType(block.getString("type")); if ("text".equals(type)) { appendBlock(builder, block.getString("text")); continue; } if ("resource_link".equals(type) || "resource".equals(type)) { JSONObject resource = "resource".equals(type) ? block.getJSONObject("resource") : block; if (resource != null) { appendBlock(builder, "[Resource] " + firstNonBlank(resource.getString("name"), resource.getString("title"), resource.getString("uri"))); appendBlock(builder, resource.getString("text")); } } } return builder.toString().trim(); } private void appendBlock(StringBuilder builder, String text) { if (isBlank(text)) { return; } if (builder.length() > 0) { builder.append("\n\n"); } builder.append(text); } private String normalizeType(String type) { return type == null ? null : type.trim().toLowerCase(Locale.ROOT).replace('-', '_'); } private String mapToolKind(String toolName) { String normalized = toolName == null ? "" : toolName.trim().toLowerCase(Locale.ROOT); if ("write_file".equals(normalized) || "apply_patch".equals(normalized)) { return "edit"; } if ("read_file".equals(normalized)) { return "read"; } return "other"; } private String validateDefinition(CliMcpServerDefinition definition) { if (definition == null) { return "missing MCP server definition"; } String type = trimToNull(definition.getType()); if (type == null) { return "missing MCP transport type"; } if ("stdio".equals(type)) { return isBlank(definition.getCommand()) ? "stdio transport requires command" : null; } if ("sse".equals(type) || "streamable_http".equals(type)) { return isBlank(definition.getUrl()) ? type + " transport requires url" : null; } return "unsupported MCP transport: " + type; } private String normalizeTransportType(String type, String command) { String normalized = trimToNull(type); if (normalized == null) { return isBlank(command) ? null : "stdio"; } String lowerCase = normalized.toLowerCase(Locale.ROOT); return "http".equals(lowerCase) ? "streamable_http" : lowerCase; } private List toStringList(JSONArray values) { if (values == null || values.isEmpty()) { return null; } List items = new ArrayList(); for (int i = 0; i < values.size(); i++) { String value = trimToNull(values.getString(i)); if (value != null) { items.add(value); } } return items.isEmpty() ? null : items; } private Map toStringMap(JSONObject object) { if (object == null || object.isEmpty()) { return null; } Map map = new LinkedHashMap(); for (Map.Entry entry : object.entrySet()) { String key = trimToNull(entry.getKey()); if (key != null) { map.put(key, entry.getValue() == null ? null : String.valueOf(entry.getValue())); } } return map.isEmpty() ? null : map; } private Map newMap(Object... values) { Map map = new LinkedHashMap(); if (values == null) { return map; } for (int i = 0; i + 1 < values.length; i += 2) { Object key = values[i]; if (key != null) { map.put(String.valueOf(key), values[i + 1]); } } return map; } private String requestIdKey(Object id) { return id == null ? "null" : String.valueOf(id); } private String requiredString(JSONObject params, String key) { String value = params == null ? null : trimToNull(params.getString(key)); if (value == null) { throw new IllegalArgumentException("Missing required field: " + key); } return value; } private JSONArray requiredArray(JSONObject params, String key) { JSONArray value = params == null ? null : params.getJSONArray(key); if (value == null) { throw new IllegalArgumentException("Missing required field: " + key); } return value; } private String requireAbsolutePath(String path) { String value = trimToNull(path); if (value == null) { throw new IllegalArgumentException("cwd is required"); } Path resolved = Paths.get(value); if (!resolved.isAbsolute()) { throw new IllegalArgumentException("cwd must be an absolute path"); } return resolved.normalize().toString(); } private String trimToNull(String value) { return isBlank(value) ? null : value.trim(); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private String clip(String value, int maxChars) { if (value == null) { return null; } String normalized = value.replace('\r', ' ').replace('\n', ' ').trim(); if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, Math.max(0, maxChars)); } private String clipPreserveNewlines(String value, int maxChars) { if (value == null) { return null; } if (value.length() <= maxChars) { return value; } return value.substring(0, Math.max(0, maxChars)); } private String safeMessage(Throwable throwable) { String message = null; Throwable current = throwable; Throwable last = throwable; while (current != null) { if (!isBlank(current.getMessage())) { message = current.getMessage().trim(); } last = current; current = current.getCause(); } return isBlank(message) ? (last == null ? "unknown ACP error" : last.getClass().getSimpleName()) : message; } private Map textContent(String text) { return newMap( "type", "text", "text", text == null ? "" : text ); } private List> toolCallTextContent(String text) { if (isBlank(text)) { return null; } return Collections.singletonList(newMap( "type", "content", "content", textContent(text) )); } private Object parseJsonOrText(String value) { if (isBlank(value)) { return Collections.emptyMap(); } try { return JSON.parse(value); } catch (Exception ex) { return newMap("text", value); } } private Map toStructuredSessionUpdate(SessionEvent event) { if (event == null || event.getType() == null) { return null; } if (event.getType() == SessionEventType.TASK_CREATED) { return newMap( "sessionUpdate", "tool_call", "toolCallId", payloadText(event, "callId", payloadText(event, "taskId", null)), "title", payloadText(event, "title", event.getSummary()), "kind", "other", "status", mapTaskStatusValue(payloadText(event, "status", null)), "rawInput", buildTaskRawInput(event) ); } if (event.getType() == SessionEventType.TASK_UPDATED) { String text = buildTaskUpdateText(event); return newMap( "sessionUpdate", "tool_call_update", "toolCallId", payloadText(event, "callId", payloadText(event, "taskId", null)), "status", mapTaskStatusValue(payloadText(event, "status", null)), "content", toolCallTextContent(text), "rawOutput", buildTaskRawOutput(event) ); } if (event.getType() == SessionEventType.TEAM_MESSAGE) { return toTeamMessageAcpUpdate(event); } if (event.getType() == SessionEventType.AUTO_CONTINUE || event.getType() == SessionEventType.AUTO_STOP || event.getType() == SessionEventType.BLOCKED) { return newMap( "sessionUpdate", "agent_message_chunk", "content", textContent(firstNonBlank(event.getSummary(), event.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '))) ); } return null; } private String payloadText(SessionEvent event, String key, String defaultValue) { if (event == null || event.getPayload() == null || key == null) { return defaultValue; } Object value = event.getPayload().get(key); return value == null ? defaultValue : String.valueOf(value); } private Object payloadValue(SessionEvent event, String key) { if (event == null || event.getPayload() == null || key == null) { return null; } return event.getPayload().get(key); } private Map buildTaskRawInput(SessionEvent event) { return newMap( "taskId", payloadValue(event, "taskId"), "definition", payloadValue(event, "tool"), "subagent", payloadValue(event, "subagent"), "memberId", payloadValue(event, "memberId"), "memberName", payloadValue(event, "memberName"), "task", payloadValue(event, "task"), "context", payloadValue(event, "context"), "dependsOn", payloadValue(event, "dependsOn"), "childSessionId", payloadValue(event, "childSessionId"), "background", payloadValue(event, "background"), "sessionMode", payloadValue(event, "sessionMode"), "depth", payloadValue(event, "depth"), "phase", payloadValue(event, "phase"), "percent", payloadValue(event, "percent") ); } private Map buildTaskRawOutput(SessionEvent event) { String text = extractTaskPrimaryText(event); return newMap( "text", text, "taskId", payloadValue(event, "taskId"), "status", payloadValue(event, "status"), "phase", payloadValue(event, "phase"), "percent", payloadValue(event, "percent"), "detail", payloadValue(event, "detail"), "memberId", payloadValue(event, "memberId"), "memberName", payloadValue(event, "memberName"), "heartbeatCount", payloadValue(event, "heartbeatCount"), "updatedAtEpochMs", payloadValue(event, "updatedAtEpochMs"), "lastHeartbeatTime", payloadValue(event, "lastHeartbeatTime"), "durationMillis", payloadValue(event, "durationMillis"), "output", payloadValue(event, "output"), "error", payloadValue(event, "error") ); } private String buildTaskUpdateText(SessionEvent event) { String text = extractTaskPrimaryText(event); if (!isTeamTaskEvent(event)) { return text; } String member = firstNonBlank(payloadText(event, "memberName", null), payloadText(event, "memberId", null)); String phase = payloadText(event, "phase", null); String status = payloadText(event, "status", null); String percent = payloadText(event, "percent", null); StringBuilder prefix = new StringBuilder(); if (!isBlank(member)) { prefix.append('[').append(member).append("] "); } if (!isBlank(phase)) { prefix.append(phase); } else if (!isBlank(status)) { prefix.append(status); } if (!isBlank(percent)) { if (prefix.length() > 0) { prefix.append(' '); } prefix.append(percent).append('%'); } if (prefix.length() == 0) { return text; } if (isBlank(text)) { return prefix.toString(); } return prefix.append(" - ").append(text).toString(); } private String extractTaskPrimaryText(SessionEvent event) { return firstNonBlank( payloadText(event, "error", null), payloadText(event, "output", null), payloadText(event, "detail", event.getSummary()) ); } private boolean isTeamTaskEvent(SessionEvent event) { String callId = payloadText(event, "callId", null); String taskId = payloadText(event, "taskId", null); String title = payloadText(event, "title", null); if (!isBlank(callId) && callId.startsWith("team-task:")) { return true; } if (!isBlank(taskId) && taskId.startsWith("team-task:")) { return true; } if (!isBlank(title) && title.startsWith("Team task")) { return true; } return !isBlank(payloadText(event, "memberId", null)) || !isBlank(payloadText(event, "memberName", null)) || !isBlank(payloadText(event, "heartbeatCount", null)); } private String mapTaskStatusValue(String status) { String normalized = trimToNull(status); if (normalized == null) { return "pending"; } String lower = normalized.toLowerCase(Locale.ROOT); if ("running".equals(lower) || "in_progress".equals(lower) || "in-progress".equals(lower) || "started".equals(lower)) { return "in_progress"; } if ("completed".equals(lower) || "fallback".equals(lower)) { return "completed"; } if ("failed".equals(lower) || "cancelled".equals(lower) || "canceled".equals(lower) || "error".equals(lower)) { return "failed"; } return "pending"; } private Map toTeamMessageAcpUpdate(SessionEvent event) { if (event == null) { return null; } String taskId = payloadText(event, "taskId", null); String toolCallId = firstNonBlank(payloadText(event, "callId", null), isBlank(taskId) ? null : "team-task:" + taskId); String messageType = trimToNull(payloadText(event, "messageType", null)); String fromMemberId = trimToNull(payloadText(event, "fromMemberId", null)); String toMemberId = trimToNull(payloadText(event, "toMemberId", null)); String text = firstNonBlank( payloadText(event, "content", null), payloadText(event, "detail", event.getSummary()) ); String rendered = formatTeamMessageText(messageType, fromMemberId, toMemberId, text); if (isBlank(toolCallId)) { return newMap( "sessionUpdate", "agent_message_chunk", "content", textContent(rendered) ); } return newMap( "sessionUpdate", "tool_call_update", "toolCallId", toolCallId, "content", toolCallTextContent(rendered), "rawOutput", newMap( "type", "team_message", "messageId", payloadText(event, "messageId", null), "taskId", taskId, "fromMemberId", fromMemberId, "toMemberId", toMemberId, "messageType", messageType, "text", text ) ); } private String formatTeamMessageText(String messageType, String fromMemberId, String toMemberId, String text) { String route = formatTeamMessageRoute(fromMemberId, toMemberId); String prefix; if (!isBlank(route) && !isBlank(messageType)) { prefix = "[" + messageType + "] " + route; } else if (!isBlank(messageType)) { prefix = "[" + messageType + "]"; } else { prefix = route; } if (isBlank(prefix)) { return text; } if (isBlank(text)) { return prefix; } return prefix + "\n" + text; } private String formatTeamMessageRoute(String fromMemberId, String toMemberId) { if (isBlank(fromMemberId) && isBlank(toMemberId)) { return null; } return firstNonBlank(fromMemberId, "?") + " -> " + firstNonBlank(toMemberId, "?"); } private void awaitPendingWork() { requestExecutor.shutdown(); try { requestExecutor.awaitTermination(5, TimeUnit.SECONDS); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } promptExecutor.shutdown(); try { promptExecutor.awaitTermination(5, TimeUnit.SECONDS); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } @Override public void close() { for (SessionHandle handle : new LinkedHashSet(sessions.values())) { if (handle != null) { handle.close(); } } sessions.clear(); for (CompletableFuture future : pendingClientResponses.values()) { future.complete(buildCancelledPermissionResponse()); } pendingClientResponses.clear(); requestExecutor.shutdownNow(); promptExecutor.shutdownNow(); } private final class SessionHandle implements Closeable { private volatile CodeCommandOptions options; private volatile CodeCommandOptions runtimeOptions; private volatile CodeCommandOptions pendingRuntimeOptions; private final CodingSessionManager sessionManager; private volatile CodingCliAgentFactory factory; private final AcpToolApprovalDecorator.PermissionGateway permissionGateway; private volatile CodingCliAgentFactory.PreparedCodingAgent prepared; private volatile ManagedCodingSession session; private volatile HeadlessCodingSessionRuntime runtime; private volatile CodingRuntime codingRuntime; private volatile CodingTaskSessionEventBridge taskEventBridge; private final CliResolvedMcpConfig resolvedMcpConfig; private final CliProviderConfigManager providerConfigManager; private volatile HeadlessCodingSessionRuntime.PromptControl activePrompt; private SessionHandle(CodeCommandOptions options, CodingSessionManager sessionManager, CodingCliAgentFactory factory, AcpToolApprovalDecorator.PermissionGateway permissionGateway, CodingCliAgentFactory.PreparedCodingAgent prepared, ManagedCodingSession session, CliResolvedMcpConfig resolvedMcpConfig) { this.options = options; this.runtimeOptions = options; this.sessionManager = sessionManager; this.factory = factory; this.permissionGateway = permissionGateway; this.prepared = prepared; this.session = session; this.resolvedMcpConfig = resolvedMcpConfig; this.providerConfigManager = new CliProviderConfigManager(Paths.get(firstNonBlank( options == null ? null : options.getWorkspace(), session == null ? null : session.getWorkspace(), "." ))); this.runtime = new HeadlessCodingSessionRuntime(options, sessionManager); this.codingRuntime = prepared == null || prepared.getAgent() == null ? null : prepared.getAgent().getRuntime(); this.taskEventBridge = registerTaskEventBridge(); } private ManagedCodingSession getSession() { return session; } private Map buildSessionOpenResult() { ManagedCodingSession currentSession = session; return newMap( "sessionId", currentSession == null ? null : currentSession.getSessionId(), "configOptions", buildConfigOptions(), "modes", buildModes() ); } private synchronized Map buildModes() { List> items = new ArrayList>(); for (ApprovalMode mode : ApprovalMode.values()) { if (mode == null) { continue; } items.add(newMap( "id", mode.getValue(), "name", approvalModeName(mode), "description", approvalModeDescription(mode) )); } return newMap( "currentModeId", currentApprovalMode().getValue(), "availableModes", items ); } private synchronized List> buildConfigOptions() { List> configOptions = new ArrayList>(); configOptions.add(newMap( "id", "mode", "name", "Permission Mode", "description", "Controls how tool approval is handled in this ACP session.", "category", "mode", "currentValue", currentApprovalMode().getValue(), "type", "select", "options", buildModeOptionValues() )); configOptions.add(newMap( "id", "model", "name", "Model", "description", "Selects the effective model for subsequent turns in this ACP session.", "category", "model", "currentValue", options == null ? null : options.getModel(), "type", "select", "options", buildModelOptionValues() )); return configOptions; } private synchronized List> setConfigOption(String configId, String value) throws Exception { String normalizedId = trimToNull(configId); if ("mode".equalsIgnoreCase(normalizedId)) { applyModeChange(value, true); return buildConfigOptions(); } if ("model".equalsIgnoreCase(normalizedId)) { applyModelChange(value, true, true); return buildConfigOptions(); } throw new IllegalArgumentException("Unknown session config option: " + configId); } private synchronized void setMode(String modeId) throws Exception { applyModeChange(modeId, true); } private void startPrompt(Runnable runnable) { promptExecutor.submit(runnable); } private synchronized HeadlessCodingSessionRuntime.PromptControl beginPrompt() { if (activePrompt != null && !activePrompt.isCancelled()) { throw new IllegalStateException("session already has an active prompt"); } activePrompt = new HeadlessCodingSessionRuntime.PromptControl(); return activePrompt; } private HeadlessCodingSessionRuntime.PromptResult runPrompt(String input, HeadlessCodingSessionRuntime.PromptControl promptControl) throws Exception { try { ManagedCodingSession currentSession = session; HeadlessCodingSessionRuntime currentRuntime = runtime; return currentRuntime.runPrompt(currentSession, input, promptControl, new AcpTurnObserver(currentSession.getSessionId())); } finally { completePrompt(promptControl); } } private HeadlessCodingSessionRuntime.PromptResult runSlashCommand(String input, HeadlessCodingSessionRuntime.PromptControl promptControl) throws Exception { try { ManagedCodingSession currentSession = session; String turnId = UUID.randomUUID().toString(); appendSimpleEvent(SessionEventType.USER_MESSAGE, turnId, clip(input, 200), newMap( "input", clipPreserveNewlines(input, options != null && options.isVerbose() ? 4000 : 1200) )); sendSessionUpdate(currentSession.getSessionId(), newMap( "sessionUpdate", "user_message_chunk", "content", textContent(input) )); AcpSlashCommandSupport.ExecutionResult result = AcpSlashCommandSupport.execute(buildSlashCommandContext(), input); String output = result == null ? null : result.getOutput(); appendSimpleEvent(SessionEventType.ASSISTANT_MESSAGE, turnId, clip(output, 200), newMap( "kind", "command", "output", clipPreserveNewlines(output, options != null && options.isVerbose() ? 4000 : 1200) )); if (!isBlank(output)) { sendSessionUpdate(session.getSessionId(), newMap( "sessionUpdate", "agent_message_chunk", "content", textContent(output) )); } persistSessionIfConfigured(); return HeadlessCodingSessionRuntime.PromptResult.completed(turnId, output, null); } finally { completePrompt(promptControl); } } private void cancel() { HeadlessCodingSessionRuntime.PromptControl control = activePrompt; if (control != null) { control.cancel(); } } private void replayHistory() throws IOException { List events = sessionManager.listEvents(session.getSessionId(), null, null); if (events == null) { return; } for (SessionEvent event : events) { Map update = toHistoryUpdate(event); if (update != null) { sendSessionUpdate(session.getSessionId(), update); } } } private void sendAvailableCommandsUpdate() { AcpJsonRpcServer.this.sendAvailableCommandsUpdate(session == null ? null : session.getSessionId()); } private void appendSimpleEvent(SessionEventType type, String turnId, String summary, Map payload) { if (sessionManager == null || session == null || type == null) { return; } try { sessionManager.appendEvent(session.getSessionId(), SessionEvent.builder() .sessionId(session.getSessionId()) .type(type) .turnId(turnId) .summary(summary) .payload(payload) .build()); } catch (IOException ignored) { } } private void persistSessionIfConfigured() { if (sessionManager == null || session == null || options == null || !options.isAutoSaveSession()) { return; } try { sessionManager.save(session); } catch (IOException ignored) { } } private AcpSlashCommandSupport.Context buildSlashCommandContext() { return new AcpSlashCommandSupport.Context( session, sessionManager, options, prepared, prepared == null ? null : prepared.getMcpRuntimeManager(), new AcpSlashCommandSupport.RuntimeCommandHandler() { @Override public String executeProviders() { return executeProvidersCommand(); } @Override public String executeProvider(String argument) throws Exception { return executeProviderCommand(argument); } @Override public String executeModel(String argument) throws Exception { return executeModelCommand(argument); } @Override public String executeExperimental(String argument) throws Exception { return executeExperimentalCommand(argument); } } ); } private synchronized String executeProvidersCommand() { return renderProvidersOutput(); } private synchronized String executeProviderCommand(String argument) throws Exception { if (isBlank(argument)) { return renderCurrentProviderOutput(); } String trimmed = argument.trim(); String[] parts = trimmed.split("\\s+", 2); String action = parts[0].toLowerCase(Locale.ROOT); String value = parts.length > 1 ? trimToNull(parts[1]) : null; if ("use".equals(action)) { return switchToProviderProfile(value); } if ("save".equals(action)) { return saveCurrentProviderProfile(value); } if ("add".equals(action)) { return addProviderProfile(value); } if ("edit".equals(action)) { return editProviderProfile(value); } if ("default".equals(action)) { return setDefaultProviderProfile(value); } if ("remove".equals(action)) { return removeProviderProfile(value); } return "Unknown /provider action: " + action + ". Use /provider, /providers, /provider use , /provider save , " + "/provider add ..., /provider edit ..., /provider default , " + "or /provider remove ."; } private synchronized String executeModelCommand(String argument) throws Exception { if (isBlank(argument)) { return renderModelOutput(); } applyModelChange(argument, true, false); persistSessionIfConfigured(); return renderModelOutput(); } private synchronized String executeExperimentalCommand(String argument) throws Exception { if (isBlank(argument)) { return renderExperimentalOutput(); } List tokens = Arrays.asList(argument.trim().split("\\s+")); if (tokens.size() != 2) { return "Usage: /experimental "; } String feature = normalizeExperimentalFeature(tokens.get(0)); Boolean enabled = parseExperimentalToggle(tokens.get(1)); if (feature == null || enabled == null) { return "Usage: /experimental "; } CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if ("subagent".equals(feature)) { workspaceConfig.setExperimentalSubagentsEnabled(enabled); } else { workspaceConfig.setExperimentalAgentTeamsEnabled(enabled); } providerConfigManager.saveWorkspaceConfig(workspaceConfig); rebindSession(options); persistSessionIfConfigured(); return renderExperimentalOutput(); } private void applyModeChange(String rawModeId, boolean emitUpdates) throws Exception { ApprovalMode nextMode = ApprovalMode.parse(rawModeId); CodeCommandOptions currentOptions = options; if (currentOptions != null && nextMode == currentOptions.getApprovalMode()) { if (emitUpdates) { emitModeUpdate(); emitConfigOptionUpdate(); } return; } applySessionOptionsChange(currentOptions.withApprovalMode(nextMode), emitUpdates, emitUpdates); } private void applyModelChange(String rawValue, boolean emitUpdates, boolean requireListedOption) throws Exception { String normalized = trimToNull(rawValue); if (normalized == null) { throw new IllegalArgumentException("Model value is required"); } CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if ("reset".equalsIgnoreCase(normalized)) { workspaceConfig.setModelOverride(null); providerConfigManager.saveWorkspaceConfig(workspaceConfig); applySessionOptionsChange(resolveCurrentProviderOptions(null), false, emitUpdates); } else { if (requireListedOption && !isSupportedModelValue(normalized)) { throw new IllegalArgumentException("Unsupported model for current ACP session: " + normalized); } workspaceConfig.setModelOverride(normalized); providerConfigManager.saveWorkspaceConfig(workspaceConfig); CodeCommandOptions currentOptions = options; CliProtocol currentProtocol = currentProtocol(); applySessionOptionsChange(currentOptions.withRuntime( currentOptions == null ? null : currentOptions.getProvider(), currentProtocol, normalized, currentOptions == null ? null : currentOptions.getApiKey(), currentOptions == null ? null : currentOptions.getBaseUrl() ), false, emitUpdates); } } private synchronized String switchToProviderProfile(String profileName) throws Exception { if (isBlank(profileName)) { return "Usage: /provider use "; } String normalizedName = profileName.trim(); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliProviderProfile profile = providersConfig.getProfiles().get(normalizedName); if (profile == null) { return "Unknown provider profile: " + normalizedName; } CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); workspaceConfig.setActiveProfile(normalizedName); providerConfigManager.saveWorkspaceConfig(workspaceConfig); rebindSession(resolveProfileRuntimeOptions(profile, workspaceConfig)); emitConfigOptionUpdate(); persistSessionIfConfigured(); return renderCurrentProviderOutput(); } private synchronized String saveCurrentProviderProfile(String profileName) throws IOException { if (isBlank(profileName)) { return "Usage: /provider save "; } String normalizedName = profileName.trim(); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliProtocol currentProtocol = currentProtocol(); providersConfig.getProfiles().put(normalizedName, CliProviderProfile.builder() .provider(options == null || options.getProvider() == null ? null : options.getProvider().getPlatform()) .protocol(currentProtocol == null ? null : currentProtocol.getValue()) .model(options == null ? null : options.getModel()) .baseUrl(options == null ? null : options.getBaseUrl()) .apiKey(options == null ? null : options.getApiKey()) .build()); if (isBlank(providersConfig.getDefaultProfile())) { providersConfig.setDefaultProfile(normalizedName); } providerConfigManager.saveProvidersConfig(providersConfig); return "provider saved: " + normalizedName + " -> " + providerConfigManager.globalProvidersPath(); } private synchronized String addProviderProfile(String rawArguments) throws IOException { String usage = "Usage: /provider add --provider [--protocol ] [--model ] [--base-url ] [--api-key ]"; ProviderProfileMutation mutation = parseProviderProfileMutation(rawArguments); if (mutation == null) { return usage; } CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); if (providersConfig.getProfiles().containsKey(mutation.profileName)) { return "Provider profile already exists: " + mutation.profileName + ". Use /provider edit " + mutation.profileName + " ..."; } if (isBlank(mutation.provider)) { return usage; } PlatformType provider = parseProviderType(mutation.provider); if (provider == null) { return "Unsupported provider: " + mutation.provider; } String baseUrlValue = mutation.clearBaseUrl ? null : mutation.baseUrl; CliProtocol protocolValue = parseProviderProtocol(firstNonBlank( mutation.protocol, CliProtocol.defaultProtocol(provider, baseUrlValue).getValue() )); if (protocolValue == null) { return "Unsupported protocol: " + mutation.protocol; } if (!isSupportedProviderProtocol(provider, protocolValue)) { return "Provider " + provider.getPlatform() + " does not support responses protocol in ai4j-cli yet"; } providersConfig.getProfiles().put(mutation.profileName, CliProviderProfile.builder() .provider(provider.getPlatform()) .protocol(protocolValue.getValue()) .model(mutation.clearModel ? null : mutation.model) .baseUrl(mutation.clearBaseUrl ? null : mutation.baseUrl) .apiKey(mutation.clearApiKey ? null : mutation.apiKey) .build()); if (isBlank(providersConfig.getDefaultProfile())) { providersConfig.setDefaultProfile(mutation.profileName); } providerConfigManager.saveProvidersConfig(providersConfig); return "provider added: " + mutation.profileName + " -> " + providerConfigManager.globalProvidersPath(); } private synchronized String editProviderProfile(String rawArguments) throws Exception { String usage = "Usage: /provider edit [--provider ] [--protocol ] [--model |--clear-model] [--base-url |--clear-base-url] [--api-key |--clear-api-key]"; ProviderProfileMutation mutation = parseProviderProfileMutation(rawArguments); if (mutation == null || !mutation.hasAnyFieldChanges()) { return usage; } CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliProviderProfile existing = providersConfig.getProfiles().get(mutation.profileName); if (existing == null) { return "Unknown provider profile: " + mutation.profileName; } String effectiveProfileBeforeEdit = resolveEffectiveProfileName(); PlatformType provider = parseProviderType(firstNonBlank(mutation.provider, existing.getProvider())); if (provider == null) { return "Unsupported provider: " + firstNonBlank(mutation.provider, existing.getProvider()); } String baseUrlValue = mutation.clearBaseUrl ? null : firstNonBlank(mutation.baseUrl, existing.getBaseUrl()); String protocolRaw = mutation.protocol; if (isBlank(protocolRaw)) { protocolRaw = firstNonBlank( normalizeStoredProtocol(existing.getProtocol(), provider, baseUrlValue), CliProtocol.defaultProtocol(provider, baseUrlValue).getValue() ); } CliProtocol protocolValue = parseProviderProtocol(protocolRaw); if (protocolValue == null) { return "Unsupported protocol: " + protocolRaw; } if (!isSupportedProviderProtocol(provider, protocolValue)) { return "Provider " + provider.getPlatform() + " does not support responses protocol in ai4j-cli yet"; } existing.setProvider(provider.getPlatform()); existing.setProtocol(protocolValue.getValue()); if (mutation.clearModel) { existing.setModel(null); } else if (mutation.model != null) { existing.setModel(mutation.model); } if (mutation.clearBaseUrl) { existing.setBaseUrl(null); } else if (mutation.baseUrl != null) { existing.setBaseUrl(mutation.baseUrl); } if (mutation.clearApiKey) { existing.setApiKey(null); } else if (mutation.apiKey != null) { existing.setApiKey(mutation.apiKey); } providersConfig.getProfiles().put(mutation.profileName, existing); providerConfigManager.saveProvidersConfig(providersConfig); if (mutation.profileName.equals(effectiveProfileBeforeEdit)) { CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); rebindSession(resolveProfileRuntimeOptions(existing, workspaceConfig)); emitConfigOptionUpdate(); persistSessionIfConfigured(); return "provider updated: " + mutation.profileName + " -> " + providerConfigManager.globalProvidersPath() + "\n" + renderCurrentProviderOutput(); } return "provider updated: " + mutation.profileName + " -> " + providerConfigManager.globalProvidersPath(); } private synchronized String removeProviderProfile(String profileName) throws IOException { if (isBlank(profileName)) { return "Usage: /provider remove "; } String normalizedName = profileName.trim(); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); if (providersConfig.getProfiles().remove(normalizedName) == null) { return "Unknown provider profile: " + normalizedName; } if (normalizedName.equals(providersConfig.getDefaultProfile())) { providersConfig.setDefaultProfile(null); } providerConfigManager.saveProvidersConfig(providersConfig); CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if (normalizedName.equals(workspaceConfig.getActiveProfile())) { workspaceConfig.setActiveProfile(null); providerConfigManager.saveWorkspaceConfig(workspaceConfig); } return "provider removed: " + normalizedName; } private synchronized String setDefaultProviderProfile(String profileName) throws IOException { if (isBlank(profileName)) { return "Usage: /provider default "; } String normalizedName = profileName.trim(); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); if ("clear".equalsIgnoreCase(normalizedName)) { providersConfig.setDefaultProfile(null); providerConfigManager.saveProvidersConfig(providersConfig); return "provider default cleared"; } if (!providersConfig.getProfiles().containsKey(normalizedName)) { return "Unknown provider profile: " + normalizedName; } providersConfig.setDefaultProfile(normalizedName); providerConfigManager.saveProvidersConfig(providersConfig); return "provider default: " + normalizedName; } private String renderProvidersOutput() { CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if (providersConfig.getProfiles().isEmpty()) { return "providers: (none)"; } StringBuilder builder = new StringBuilder(); builder.append("providers:\n"); List names = new ArrayList(providersConfig.getProfiles().keySet()); Collections.sort(names); for (String name : names) { CliProviderProfile profile = providersConfig.getProfiles().get(name); builder.append("- ").append(name); if (name.equals(workspaceConfig.getActiveProfile())) { builder.append(" [active]"); } if (name.equals(providersConfig.getDefaultProfile())) { builder.append(" [default]"); } builder.append(" | provider=").append(profile == null ? null : profile.getProvider()); builder.append(", protocol=").append(profile == null ? null : profile.getProtocol()); builder.append(", model=").append(profile == null ? null : profile.getModel()); if (!isBlank(profile == null ? null : profile.getBaseUrl())) { builder.append(", baseUrl=").append(profile.getBaseUrl()); } builder.append('\n'); } return builder.toString().trim(); } private String renderCurrentProviderOutput() { CodeCommandOptions currentOptions = options; CliProtocol currentProtocol = currentProtocol(); CliResolvedProviderConfig resolved = providerConfigManager.resolve( currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform(), currentProtocol == null ? null : currentProtocol.getValue(), null, currentOptions == null ? null : currentOptions.getApiKey(), currentOptions == null ? null : currentOptions.getBaseUrl(), Collections.emptyMap(), new Properties() ); StringBuilder builder = new StringBuilder(); builder.append("provider:\n"); builder.append("- activeProfile=").append(firstNonBlank(resolved.getActiveProfile(), "(none)")).append('\n'); builder.append("- defaultProfile=").append(firstNonBlank(resolved.getDefaultProfile(), "(none)")).append('\n'); builder.append("- effectiveProfile=").append(firstNonBlank(resolved.getEffectiveProfile(), "(none)")).append('\n'); builder.append("- provider=").append(currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform()) .append(", protocol=").append(currentProtocol == null ? null : currentProtocol.getValue()) .append(", model=").append(currentOptions == null ? null : currentOptions.getModel()).append('\n'); builder.append("- baseUrl=").append(firstNonBlank(currentOptions == null ? null : currentOptions.getBaseUrl(), "(default)")).append('\n'); builder.append("- apiKey=").append(isBlank(currentOptions == null ? null : currentOptions.getApiKey()) ? "(missing)" : maskSecret(currentOptions.getApiKey())).append('\n'); builder.append("- store=").append(providerConfigManager.globalProvidersPath()); return builder.toString().trim(); } private String renderModelOutput() { CodeCommandOptions currentOptions = options; CliProtocol currentProtocol = currentProtocol(); CliResolvedProviderConfig resolved = providerConfigManager.resolve( currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform(), currentProtocol == null ? null : currentProtocol.getValue(), null, currentOptions == null ? null : currentOptions.getApiKey(), currentOptions == null ? null : currentOptions.getBaseUrl(), Collections.emptyMap(), new Properties() ); StringBuilder builder = new StringBuilder(); builder.append("model:\n"); builder.append("- current=").append(currentOptions == null ? null : currentOptions.getModel()).append('\n'); builder.append("- override=").append(firstNonBlank(resolved.getModelOverride(), "(none)")).append('\n'); builder.append("- profile=").append(firstNonBlank(resolved.getEffectiveProfile(), "(none)")).append('\n'); builder.append("- workspaceConfig=").append(providerConfigManager.workspaceConfigPath()); return builder.toString().trim(); } private String renderExperimentalOutput() { CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); StringBuilder builder = new StringBuilder(); builder.append("experimental:\n"); builder.append("- subagent=").append(renderExperimentalState( workspaceConfig == null ? null : workspaceConfig.getExperimentalSubagentsEnabled(), DefaultCodingCliAgentFactory.isExperimentalSubagentsEnabled(workspaceConfig) )).append('\n'); builder.append("- agent-teams=").append(renderExperimentalState( workspaceConfig == null ? null : workspaceConfig.getExperimentalAgentTeamsEnabled(), DefaultCodingCliAgentFactory.isExperimentalAgentTeamsEnabled(workspaceConfig) )).append('\n'); builder.append("- workspaceConfig=").append(providerConfigManager.workspaceConfigPath()); return builder.toString().trim(); } private String renderExperimentalState(Boolean configuredValue, boolean effectiveValue) { String base = effectiveValue ? "on" : "off"; return configuredValue == null ? base + " (default)" : base; } private String normalizeExperimentalFeature(String raw) { if (isBlank(raw)) { return null; } String normalized = raw.trim().toLowerCase(Locale.ROOT); if ("subagent".equals(normalized) || "subagents".equals(normalized)) { return "subagent"; } if ("agent-teams".equals(normalized) || "agent-team".equals(normalized) || "agentteams".equals(normalized) || "team".equals(normalized) || "teams".equals(normalized)) { return "agent-teams"; } return null; } private Boolean parseExperimentalToggle(String raw) { if (isBlank(raw)) { return null; } String normalized = raw.trim().toLowerCase(Locale.ROOT); if ("on".equals(normalized) || "enable".equals(normalized) || "enabled".equals(normalized)) { return Boolean.TRUE; } if ("off".equals(normalized) || "disable".equals(normalized) || "disabled".equals(normalized)) { return Boolean.FALSE; } return null; } private List> buildModeOptionValues() { List> values = new ArrayList>(); for (ApprovalMode mode : ApprovalMode.values()) { if (mode == null) { continue; } values.add(newMap( "value", mode.getValue(), "name", approvalModeName(mode), "description", approvalModeDescription(mode) )); } return values; } private List> buildModelOptionValues() { LinkedHashMap> values = new LinkedHashMap>(); CliResolvedProviderConfig resolved = providerConfigManager.resolve( options == null || options.getProvider() == null ? null : options.getProvider().getPlatform(), currentProtocol() == null ? null : currentProtocol().getValue(), null, options == null ? null : options.getApiKey(), options == null ? null : options.getBaseUrl(), Collections.emptyMap(), new Properties() ); String currentProvider = options != null && options.getProvider() != null ? options.getProvider().getPlatform() : (resolved.getProvider() == null ? null : resolved.getProvider().getPlatform()); addModelOptionValue(values, resolved.getModelOverride(), "Current workspace override"); addModelOptionValue(values, options == null ? null : options.getModel(), "Current effective model"); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); addProfileModelOptionValue(values, workspaceConfig == null ? null : workspaceConfig.getActiveProfile(), currentProvider, "Active profile"); addProfileModelOptionValue(values, providersConfig.getDefaultProfile(), currentProvider, "Default profile"); for (String profileName : providerConfigManager.listProfileNames()) { addProfileModelOptionValue(values, profileName, currentProvider, "Saved profile"); } return new ArrayList>(values.values()); } private void addProfileModelOptionValue(LinkedHashMap> values, String profileName, String provider, String label) { if (values == null || isBlank(profileName)) { return; } CliProviderProfile profile = providerConfigManager.getProfile(profileName); if (profile == null || isBlank(profile.getModel())) { return; } if (!isBlank(provider) && !provider.equalsIgnoreCase(firstNonBlank(profile.getProvider(), provider))) { return; } addModelOptionValue(values, profile.getModel(), label + " " + profileName); } private void addModelOptionValue(LinkedHashMap> values, String model, String description) { if (values == null || isBlank(model)) { return; } String normalizedKey = model.trim().toLowerCase(Locale.ROOT); if (values.containsKey(normalizedKey)) { return; } values.put(normalizedKey, newMap( "value", model.trim(), "name", model.trim(), "description", description )); } private boolean isSupportedModelValue(String value) { if (isBlank(value)) { return false; } String normalized = value.trim(); if (options != null && normalized.equals(options.getModel())) { return true; } for (Map option : buildModelOptionValues()) { if (option == null) { continue; } Object candidate = option.get("value"); if (candidate != null && normalized.equals(String.valueOf(candidate))) { return true; } } return false; } private ApprovalMode currentApprovalMode() { return options == null ? ApprovalMode.AUTO : options.getApprovalMode(); } private String approvalModeName(ApprovalMode mode) { if (mode == ApprovalMode.MANUAL) { return "Manual"; } if (mode == ApprovalMode.SAFE) { return "Safe"; } return "Auto"; } private String approvalModeDescription(ApprovalMode mode) { if (mode == ApprovalMode.MANUAL) { return "Require user approval for every tool call."; } if (mode == ApprovalMode.SAFE) { return "Ask for approval on editing and command execution actions."; } return "Run tools automatically unless the ACP client enforces its own checks."; } private void emitModeUpdate() { ManagedCodingSession currentSession = session; if (currentSession == null || isBlank(currentSession.getSessionId())) { return; } sendSessionUpdate(currentSession.getSessionId(), newMap( "sessionUpdate", "current_mode_update", "currentModeId", currentApprovalMode().getValue(), "modeId", currentApprovalMode().getValue() )); } private void emitConfigOptionUpdate() { ManagedCodingSession currentSession = session; if (currentSession == null || isBlank(currentSession.getSessionId())) { return; } sendSessionUpdate(currentSession.getSessionId(), newMap( "sessionUpdate", "config_option_update", "configOptions", buildConfigOptions() )); } private String resolveEffectiveProfileName() { CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); String activeProfile = workspaceConfig == null ? null : workspaceConfig.getActiveProfile(); if (!isBlank(activeProfile) && providersConfig.getProfiles().containsKey(activeProfile)) { return activeProfile; } String defaultProfile = providersConfig.getDefaultProfile(); return !isBlank(defaultProfile) && providersConfig.getProfiles().containsKey(defaultProfile) ? defaultProfile : null; } private CodeCommandOptions resolveCurrentProviderOptions(String modelOverride) { CodeCommandOptions currentOptions = options; CliProtocol currentProtocol = currentProtocol(); CliResolvedProviderConfig resolved = providerConfigManager.resolve( currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform(), currentProtocol == null ? null : currentProtocol.getValue(), modelOverride, currentOptions == null ? null : currentOptions.getApiKey(), currentOptions == null ? null : currentOptions.getBaseUrl(), Collections.emptyMap(), new Properties() ); return currentOptions.withRuntime( resolved.getProvider(), resolved.getProtocol(), resolved.getModel(), resolved.getApiKey(), resolved.getBaseUrl() ); } private CodeCommandOptions resolveProfileRuntimeOptions(CliProviderProfile profile, CliWorkspaceConfig workspaceConfig) { CodeCommandOptions currentOptions = options; PlatformType currentProvider = currentOptions == null ? null : currentOptions.getProvider(); PlatformType provider = parseProviderType(firstNonBlank( profile == null ? null : profile.getProvider(), currentProvider == null ? null : currentProvider.getPlatform() )); if (provider == null) { provider = currentProvider == null ? PlatformType.OPENAI : currentProvider; } String baseUrl = firstNonBlank(profile == null ? null : profile.getBaseUrl(), currentOptions == null ? null : currentOptions.getBaseUrl()); String protocolRaw = firstNonBlank( normalizeStoredProtocol(profile == null ? null : profile.getProtocol(), provider, baseUrl), currentProtocol() == null ? null : currentProtocol().getValue(), CliProtocol.defaultProtocol(provider, baseUrl).getValue() ); CliProtocol protocolValue = parseProviderProtocol(protocolRaw); if (protocolValue == null) { protocolValue = currentProtocol() == null ? CliProtocol.defaultProtocol(provider, baseUrl) : currentProtocol(); } String model = firstNonBlank( workspaceConfig == null ? null : workspaceConfig.getModelOverride(), profile == null ? null : profile.getModel(), currentOptions == null ? null : currentOptions.getModel() ); String apiKey = firstNonBlank(profile == null ? null : profile.getApiKey(), currentOptions == null ? null : currentOptions.getApiKey()); return currentOptions.withRuntime(provider, protocolValue, model, apiKey, baseUrl); } private void rebindSession(CodeCommandOptions nextOptions) throws Exception { if (agentFactoryProvider == null) { throw new IllegalStateException("ACP runtime switching is unavailable"); } CodingCliAgentFactory nextFactory = agentFactoryProvider.create(nextOptions, permissionGateway, resolvedMcpConfig); CodingCliAgentFactory.PreparedCodingAgent nextPrepared = nextFactory.prepare(nextOptions, null, null, Collections.emptySet()); ManagedCodingSession previousSession = session; CodingCliAgentFactory.PreparedCodingAgent previousPrepared = prepared; CodingRuntime previousRuntime = codingRuntime; CodingTaskSessionEventBridge previousBridge = taskEventBridge; ManagedCodingSession rebound = previousSession; if (previousSession != null && previousSession.getSession() != null) { io.github.lnyocly.ai4j.coding.CodingSessionState state = previousSession.getSession().exportState(); io.github.lnyocly.ai4j.coding.CodingSession nextSession = nextPrepared.getAgent().newSession(previousSession.getSessionId(), state); rebound = new ManagedCodingSession( nextSession, nextOptions.getProvider() == null ? null : nextOptions.getProvider().getPlatform(), nextPrepared.getProtocol() == null ? null : nextPrepared.getProtocol().getValue(), nextOptions.getModel(), nextOptions.getWorkspace(), nextOptions.getWorkspaceDescription(), nextOptions.getSystemPrompt(), nextOptions.getInstructions(), firstNonBlank(previousSession.getRootSessionId(), previousSession.getSessionId()), previousSession.getParentSessionId(), previousSession.getCreatedAtEpochMs(), previousSession.getUpdatedAtEpochMs() ); } if (previousRuntime != null && previousBridge != null) { previousRuntime.removeListener(previousBridge); } if (previousPrepared != null && previousPrepared.getMcpRuntimeManager() != null) { previousPrepared.getMcpRuntimeManager().close(); } if (previousSession != null) { previousSession.close(); } this.options = nextOptions; this.runtimeOptions = nextOptions; this.factory = nextFactory; this.prepared = nextPrepared; this.session = rebound; this.runtime = new HeadlessCodingSessionRuntime(nextOptions, sessionManager); this.codingRuntime = nextPrepared == null || nextPrepared.getAgent() == null ? null : nextPrepared.getAgent().getRuntime(); this.taskEventBridge = registerTaskEventBridge(); } private void applySessionOptionsChange(CodeCommandOptions nextOptions, boolean emitModeUpdate, boolean emitConfigOptionUpdate) throws Exception { if (nextOptions == null) { throw new IllegalArgumentException("ACP session configuration is unavailable"); } boolean promptActive; boolean changed; synchronized (this) { changed = !sameRuntimeConfig(options, nextOptions); promptActive = hasActivePrompt(); if (promptActive) { options = nextOptions; pendingRuntimeOptions = nextOptions; } } if (!promptActive && changed) { rebindSession(nextOptions); } else if (!promptActive) { options = nextOptions; } if (emitModeUpdate) { emitModeUpdate(); } if (emitConfigOptionUpdate) { emitConfigOptionUpdate(); } persistSessionIfConfigured(); } private void completePrompt(HeadlessCodingSessionRuntime.PromptControl promptControl) { CodeCommandOptions deferredOptions = null; synchronized (this) { if (activePrompt == promptControl) { activePrompt = null; } if (pendingRuntimeOptions != null) { deferredOptions = pendingRuntimeOptions; pendingRuntimeOptions = null; } } if (deferredOptions == null || deferredOptions != options || sameRuntimeConfig(runtimeOptions, deferredOptions)) { return; } try { rebindSession(deferredOptions); } catch (Exception ex) { rollbackDeferredSessionOptions(deferredOptions, ex); } } private void rollbackDeferredSessionOptions(CodeCommandOptions deferredOptions, Exception ex) { CodeCommandOptions boundOptions = runtimeOptions; boolean modeChanged = boundOptions != null && deferredOptions != null && boundOptions.getApprovalMode() != deferredOptions.getApprovalMode(); synchronized (this) { if (options == deferredOptions) { options = boundOptions; } } if (modeChanged) { emitModeUpdate(); } emitConfigOptionUpdate(); logError("Failed to apply deferred ACP session configuration: " + safeMessage(ex)); } private boolean hasActivePrompt() { return activePrompt != null && !activePrompt.isCancelled(); } private boolean sameRuntimeConfig(CodeCommandOptions left, CodeCommandOptions right) { if (left == right) { return true; } if (left == null || right == null) { return false; } return samePlatform(left.getProvider(), right.getProvider()) && sameProtocol(left.getProtocol(), right.getProtocol()) && sameValue(left.getModel(), right.getModel()) && sameValue(left.getApiKey(), right.getApiKey()) && sameValue(left.getBaseUrl(), right.getBaseUrl()) && left.getApprovalMode() == right.getApprovalMode(); } private boolean samePlatform(PlatformType left, PlatformType right) { if (left == right) { return true; } if (left == null || right == null) { return false; } return sameValue(left.getPlatform(), right.getPlatform()); } private boolean sameProtocol(CliProtocol left, CliProtocol right) { if (left == right) { return true; } if (left == null || right == null) { return false; } return sameValue(left.getValue(), right.getValue()); } private boolean sameValue(String left, String right) { return left == null ? right == null : left.equals(right); } private ProviderProfileMutation parseProviderProfileMutation(String rawArguments) { if (isBlank(rawArguments)) { return null; } String[] tokens = rawArguments.trim().split("\\s+"); if (tokens.length == 0 || isBlank(tokens[0])) { return null; } ProviderProfileMutation mutation = new ProviderProfileMutation(tokens[0].trim()); for (int i = 1; i < tokens.length; i++) { String token = tokens[i]; if ("--provider".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i); if (value == null) { return null; } mutation.provider = value; continue; } if ("--protocol".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i); if (value == null) { return null; } mutation.protocol = value; continue; } if ("--model".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i); if (value == null) { return null; } mutation.model = value; mutation.clearModel = false; continue; } if ("--base-url".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i); if (value == null) { return null; } mutation.baseUrl = value; mutation.clearBaseUrl = false; continue; } if ("--api-key".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i); if (value == null) { return null; } mutation.apiKey = value; mutation.clearApiKey = false; continue; } if ("--clear-model".equalsIgnoreCase(token)) { mutation.model = null; mutation.clearModel = true; continue; } if ("--clear-base-url".equalsIgnoreCase(token)) { mutation.baseUrl = null; mutation.clearBaseUrl = true; continue; } if ("--clear-api-key".equalsIgnoreCase(token)) { mutation.apiKey = null; mutation.clearApiKey = true; continue; } return null; } return mutation; } private String requireProviderMutationValue(String[] tokens, int index) { if (tokens == null || index < 0 || index >= tokens.length || isBlank(tokens[index])) { return null; } return tokens[index].trim(); } private PlatformType parseProviderType(String raw) { if (isBlank(raw)) { return null; } for (PlatformType platformType : PlatformType.values()) { if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) { return platformType; } } return null; } private CliProtocol parseProviderProtocol(String raw) { if (isBlank(raw)) { return null; } try { return CliProtocol.parse(raw); } catch (IllegalArgumentException ex) { return null; } } private boolean isSupportedProviderProtocol(PlatformType provider, CliProtocol protocol) { if (provider == null || protocol == null || protocol == CliProtocol.CHAT) { return true; } return provider == PlatformType.OPENAI || provider == PlatformType.DOUBAO || provider == PlatformType.DASHSCOPE; } private String normalizeStoredProtocol(String raw, PlatformType provider, String baseUrl) { if (isBlank(raw)) { return null; } return CliProtocol.resolveConfigured(raw, provider, baseUrl).getValue(); } private CliProtocol currentProtocol() { return prepared == null ? null : prepared.getProtocol(); } private String maskSecret(String value) { if (isBlank(value)) { return null; } String trimmed = value.trim(); if (trimmed.length() <= 8) { return "****"; } return trimmed.substring(0, 4) + "..." + trimmed.substring(trimmed.length() - 4); } private Map toHistoryUpdate(SessionEvent event) { if (event == null || event.getType() == null) { return null; } if (event.getType() == SessionEventType.USER_MESSAGE) { return newMap( "sessionUpdate", "user_message_chunk", "content", textContent(payloadText(event, "input", event.getSummary())) ); } if (event.getType() == SessionEventType.ASSISTANT_MESSAGE) { String kind = event.getPayload() == null ? null : String.valueOf(event.getPayload().get("kind")); return newMap( "sessionUpdate", "reasoning".equals(kind) ? "agent_thought_chunk" : "agent_message_chunk", "content", textContent(payloadText(event, "output", event.getSummary())) ); } if (event.getType() == SessionEventType.TOOL_CALL) { return newMap( "sessionUpdate", "tool_call", "toolCallId", payloadText(event, "callId", null), "title", payloadText(event, "tool", event.getSummary()), "kind", mapToolKind(payloadText(event, "tool", null)), "status", "pending", "rawInput", newMap( "tool", payloadText(event, "tool", null), "arguments", payloadJsonText(event, "arguments") ) ); } if (event.getType() == SessionEventType.TOOL_RESULT) { return newMap( "sessionUpdate", "tool_call_update", "toolCallId", payloadText(event, "callId", null), "status", "completed", "content", toolCallTextContent(payloadText(event, "output", event.getSummary())) ); } if (event.getType() == SessionEventType.TASK_CREATED) { return newMap( "sessionUpdate", "tool_call", "toolCallId", payloadText(event, "callId", payloadText(event, "taskId", null)), "title", payloadText(event, "title", event.getSummary()), "kind", "other", "status", mapTaskStatus(payloadText(event, "status", null)), "rawInput", buildTaskRawInput(event) ); } if (event.getType() == SessionEventType.TASK_UPDATED) { String text = buildTaskUpdateText(event); return newMap( "sessionUpdate", "tool_call_update", "toolCallId", payloadText(event, "callId", payloadText(event, "taskId", null)), "status", mapTaskStatus(payloadText(event, "status", null)), "content", toolCallTextContent(text), "rawOutput", buildTaskRawOutput(event) ); } if (event.getType() == SessionEventType.TEAM_MESSAGE) { return toTeamMessageAcpUpdate(event); } if (event.getType() == SessionEventType.ERROR) { return newMap( "sessionUpdate", "agent_message_chunk", "content", textContent(payloadText(event, "error", event.getSummary())) ); } if (event.getType() == SessionEventType.AUTO_CONTINUE || event.getType() == SessionEventType.AUTO_STOP || event.getType() == SessionEventType.BLOCKED) { return newMap( "sessionUpdate", "agent_message_chunk", "content", textContent(firstNonBlank(event.getSummary(), event.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '))) ); } return null; } private String payloadText(SessionEvent event, String key, String defaultValue) { if (event == null || event.getPayload() == null || key == null) { return defaultValue; } Object value = event.getPayload().get(key); return value == null ? defaultValue : String.valueOf(value); } private Object payloadJsonText(SessionEvent event, String key) { return parseJsonOrText(payloadText(event, key, null)); } private CodingTaskSessionEventBridge registerTaskEventBridge() { if (codingRuntime == null || sessionManager == null) { return null; } CodingTaskSessionEventBridge bridge = new CodingTaskSessionEventBridge(sessionManager, new CodingTaskSessionEventBridge.SessionEventConsumer() { @Override public void onEvent(SessionEvent event) { if (event == null || !session.getSessionId().equals(event.getSessionId())) { return; } Map update = toHistoryUpdate(event); if (update != null) { sendSessionUpdate(session.getSessionId(), update); } } }); codingRuntime.addListener(bridge); return bridge; } private String mapTaskStatus(String status) { String normalized = trimToNull(status); if (normalized == null) { return "pending"; } String lower = normalized.toLowerCase(Locale.ROOT); if ("running".equals(lower) || "in_progress".equals(lower) || "in-progress".equals(lower) || "started".equals(lower)) { return "in_progress"; } if ("completed".equals(lower)) { return "completed"; } if ("fallback".equals(lower)) { return "completed"; } if ("failed".equals(lower) || "cancelled".equals(lower) || "canceled".equals(lower) || "error".equals(lower)) { return "failed"; } return "pending"; } @Override public void close() { CodingRuntime currentRuntime = codingRuntime; CodingTaskSessionEventBridge currentBridge = taskEventBridge; CodingCliAgentFactory.PreparedCodingAgent currentPrepared = prepared; ManagedCodingSession currentSession = session; if (currentRuntime != null && currentBridge != null) { currentRuntime.removeListener(currentBridge); } cancel(); if (currentPrepared != null && currentPrepared.getMcpRuntimeManager() != null) { currentPrepared.getMcpRuntimeManager().close(); } if (currentSession != null) { currentSession.close(); } } private final class ProviderProfileMutation { private final String profileName; private String provider; private String protocol; private String model; private boolean clearModel; private String baseUrl; private boolean clearBaseUrl; private String apiKey; private boolean clearApiKey; private ProviderProfileMutation(String profileName) { this.profileName = profileName; } private boolean hasAnyFieldChanges() { return provider != null || protocol != null || model != null || clearModel || baseUrl != null || clearBaseUrl || apiKey != null || clearApiKey; } } } private final class AcpTurnObserver extends HeadlessTurnObserver.Adapter { private final String sessionId; private AcpTurnObserver(String sessionId) { this.sessionId = sessionId; } @Override public void onTurnStarted(ManagedCodingSession session, String turnId, String input) { sendSessionUpdate(sessionId, newMap( "sessionUpdate", "user_message_chunk", "content", textContent(input) )); } @Override public void onReasoningDelta(ManagedCodingSession session, String turnId, Integer step, String delta) { sendSessionUpdate(sessionId, newMap( "sessionUpdate", "agent_thought_chunk", "content", textContent(delta) )); } @Override public void onAssistantDelta(ManagedCodingSession session, String turnId, Integer step, String delta) { sendSessionUpdate(sessionId, newMap( "sessionUpdate", "agent_message_chunk", "content", textContent(delta) )); } @Override public void onToolCall(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call) { sendSessionUpdate(sessionId, newMap( "sessionUpdate", "tool_call", "toolCallId", call == null ? UUID.randomUUID().toString() : firstNonBlank(call.getCallId(), UUID.randomUUID().toString()), "title", call == null ? "tool" : firstNonBlank(call.getName(), "tool"), "kind", mapToolKind(call == null ? null : call.getName()), "status", "pending", "rawInput", newMap( "tool", call == null ? null : call.getName(), "arguments", parseJsonOrText(call == null ? null : call.getArguments()) ) )); } @Override public void onToolResult(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call, AgentToolResult result, boolean failed) { sendSessionUpdate(sessionId, newMap( "sessionUpdate", "tool_call_update", "toolCallId", result == null ? null : firstNonBlank(result.getCallId(), call == null ? null : call.getCallId()), "status", failed ? "failed" : "completed", "content", toolCallTextContent(result == null ? null : result.getOutput()), "rawOutput", newMap("text", result == null ? null : result.getOutput()) )); } @Override public void onTurnError(ManagedCodingSession session, String turnId, Integer step, String message) { sendSessionUpdate(sessionId, newMap( "sessionUpdate", "agent_message_chunk", "content", textContent(message) )); } @Override public void onSessionEvent(ManagedCodingSession session, SessionEvent event) { Map update = toStructuredSessionUpdate(event); if (update != null) { sendSessionUpdate(sessionId, update); } } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpSlashCommandSupport.java ================================================ package io.github.lnyocly.ai4j.cli.acp; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.mcp.CliMcpStatusSnapshot; import io.github.lnyocly.ai4j.cli.runtime.CliTeamStateManager; import io.github.lnyocly.ai4j.cli.runtime.TeamBoardRenderSupport; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.cli.session.StoredCodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpointFormatter; import io.github.lnyocly.ai4j.coding.CodingSessionSnapshot; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.text.SimpleDateFormat; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TimeZone; final class AcpSlashCommandSupport { private static final int DEFAULT_EVENT_LIMIT = 12; private static final int DEFAULT_PROCESS_LOG_LIMIT = 800; private static final List COMMANDS = Collections.unmodifiableList(Arrays.asList( new CommandSpec("help", "Show ACP slash commands", null), new CommandSpec("status", "Show current session status", null), new CommandSpec("session", "Show current session metadata", null), new CommandSpec("save", "Persist the current session state", null), new CommandSpec("providers", "List saved provider profiles", null), new CommandSpec("provider", "Show or switch the active provider profile", "use | save | add/edit/default/remove ..."), new CommandSpec("model", "Show or switch the active model override", "optional model name | reset"), new CommandSpec("experimental", "Show or switch experimental runtime features", "optional feature | "), new CommandSpec("skills", "List coding skills or inspect one skill", "optional skill name"), new CommandSpec("agents", "List coding agents or inspect one agent", "optional agent name"), new CommandSpec("mcp", "Show MCP services and status", null), new CommandSpec("sessions", "List saved sessions", null), new CommandSpec("history", "Show session lineage", "optional target session id"), new CommandSpec("tree", "Show the session tree", "optional root session id"), new CommandSpec("events", "Show recent session events", "optional limit"), new CommandSpec("team", "Show current team board or persisted team state", "optional: list | status [team-id] | messages [team-id] [limit] | resume [team-id]"), new CommandSpec("compacts", "Show compact history", "optional limit"), new CommandSpec("checkpoint", "Show current checkpoint summary", null), new CommandSpec("processes", "List managed processes", null), new CommandSpec("process", "Inspect process status or logs", "status | logs [limit]") )); private AcpSlashCommandSupport() { } static List> availableCommands() { List> commands = new ArrayList>(); for (CommandSpec spec : COMMANDS) { Map command = new LinkedHashMap(); command.put("name", spec.name); command.put("description", spec.description); if (!isBlank(spec.inputHint)) { Map input = new LinkedHashMap(); input.put("hint", spec.inputHint); command.put("input", input); } commands.add(command); } return commands; } static boolean supports(String input) { return parse(input) != null; } static ExecutionResult execute(Context context, String input) throws Exception { ParsedCommand command = parse(input); if (context == null || command == null) { return null; } String name = command.name; if ("help".equals(name)) { return ExecutionResult.of(renderHelp()); } if ("status".equals(name)) { return ExecutionResult.of(renderStatus(context)); } if ("session".equals(name)) { return ExecutionResult.of(renderSession(context)); } if ("save".equals(name)) { StoredCodingSession stored = context.sessionManager.save(context.session); return ExecutionResult.of("saved session: " + stored.getSessionId() + " -> " + stored.getStorePath()); } if ("providers".equals(name)) { return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.PROVIDERS, null)); } if ("provider".equals(name)) { return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.PROVIDER, command.argument)); } if ("model".equals(name)) { return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.MODEL, command.argument)); } if ("experimental".equals(name)) { return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.EXPERIMENTAL, command.argument)); } if ("skills".equals(name)) { return ExecutionResult.of(renderSkills(context, command.argument)); } if ("agents".equals(name)) { return ExecutionResult.of(renderAgents(context, command.argument)); } if ("mcp".equals(name)) { return ExecutionResult.of(renderMcp(context)); } if ("sessions".equals(name)) { return ExecutionResult.of(renderSessions(context)); } if ("history".equals(name)) { return ExecutionResult.of(renderHistory(context, command.argument)); } if ("tree".equals(name)) { return ExecutionResult.of(renderTree(context, command.argument)); } if ("events".equals(name)) { return ExecutionResult.of(renderEvents(context, command.argument)); } if ("team".equals(name)) { return ExecutionResult.of(renderTeam(context, command.argument)); } if ("compacts".equals(name)) { return ExecutionResult.of(renderCompacts(context, command.argument)); } if ("checkpoint".equals(name)) { return ExecutionResult.of(renderCheckpoint(context)); } if ("processes".equals(name)) { return ExecutionResult.of(renderProcesses(context)); } if ("process".equals(name)) { return ExecutionResult.of(renderProcess(context, command.argument)); } return null; } private static String executeRuntimeCommand(Context context, RuntimeCommand command, String argument) throws Exception { if (context == null || context.runtimeCommandHandler == null || command == null) { return "command unavailable in this ACP session"; } if (command == RuntimeCommand.PROVIDERS) { return context.runtimeCommandHandler.executeProviders(); } if (command == RuntimeCommand.PROVIDER) { return context.runtimeCommandHandler.executeProvider(argument); } if (command == RuntimeCommand.MODEL) { return context.runtimeCommandHandler.executeModel(argument); } if (command == RuntimeCommand.EXPERIMENTAL) { return context.runtimeCommandHandler.executeExperimental(argument); } return "command unavailable in this ACP session"; } private static ParsedCommand parse(String input) { String normalized = trimToNull(input); if (normalized == null || !normalized.startsWith("/")) { return null; } String body = normalized.substring(1).trim(); if (body.isEmpty()) { return null; } int space = body.indexOf(' '); String name = (space < 0 ? body : body.substring(0, space)).trim().toLowerCase(Locale.ROOT); if (findSpec(name) == null) { return null; } String argument = space < 0 ? null : trimToNull(body.substring(space + 1)); return new ParsedCommand(name, argument); } private static CommandSpec findSpec(String name) { if (isBlank(name)) { return null; } for (CommandSpec spec : COMMANDS) { if (spec.name.equalsIgnoreCase(name.trim())) { return spec; } } return null; } private static String renderHelp() { StringBuilder builder = new StringBuilder(); builder.append("ACP slash commands:\n"); for (CommandSpec spec : COMMANDS) { builder.append("- /").append(spec.name).append(" | ").append(spec.description); if (!isBlank(spec.inputHint)) { builder.append(" | input: ").append(spec.inputHint); } builder.append('\n'); } return builder.toString().trim(); } private static String renderStatus(Context context) { ManagedCodingSession session = context.session; if (session == null) { return "status: (none)"; } CodingSessionSnapshot snapshot = snapshot(session); StringBuilder builder = new StringBuilder(); builder.append("status:\n"); builder.append("- session=").append(session.getSessionId()).append('\n'); builder.append("- provider=").append(firstNonBlank(session.getProvider(), "(none)")) .append(", protocol=").append(firstNonBlank(session.getProtocol(), "(none)")) .append(", model=").append(firstNonBlank(session.getModel(), "(none)")).append('\n'); builder.append("- workspace=").append(firstNonBlank(session.getWorkspace(), "(none)")).append('\n'); builder.append("- mode=").append(context.options != null && context.options.isNoSession() ? "memory-only" : "persistent") .append(", memory=").append(snapshot == null ? 0 : snapshot.getMemoryItemCount()) .append(", activeProcesses=").append(snapshot == null ? 0 : snapshot.getActiveProcessCount()) .append(", restoredProcesses=").append(snapshot == null ? 0 : snapshot.getRestoredProcessCount()) .append(", tokens=").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\n'); String mcpSummary = renderMcpSummary(context.mcpRuntimeManager); if (!isBlank(mcpSummary)) { builder.append("- mcp=").append(mcpSummary).append('\n'); } builder.append("- checkpointGoal=").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 120)).append('\n'); builder.append("- compact=").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), "none")); return builder.toString().trim(); } private static String renderSession(Context context) { ManagedCodingSession session = context.session; if (session == null) { return "session: (none)"; } CodingSessionDescriptor descriptor = session.toDescriptor(); CodingSessionSnapshot snapshot = snapshot(session); StringBuilder builder = new StringBuilder(); builder.append("session:\n"); builder.append("- id=").append(descriptor.getSessionId()).append('\n'); builder.append("- root=").append(firstNonBlank(descriptor.getRootSessionId(), "(none)")) .append(", parent=").append(firstNonBlank(descriptor.getParentSessionId(), "(none)")).append('\n'); builder.append("- provider=").append(firstNonBlank(descriptor.getProvider(), "(none)")) .append(", protocol=").append(firstNonBlank(descriptor.getProtocol(), "(none)")) .append(", model=").append(firstNonBlank(descriptor.getModel(), "(none)")).append('\n'); builder.append("- workspace=").append(firstNonBlank(descriptor.getWorkspace(), "(none)")) .append(", mode=").append(context.options != null && context.options.isNoSession() ? "memory-only" : "persistent").append('\n'); builder.append("- created=").append(formatTimestamp(descriptor.getCreatedAtEpochMs())) .append(", updated=").append(formatTimestamp(descriptor.getUpdatedAtEpochMs())).append('\n'); builder.append("- memory=").append(descriptor.getMemoryItemCount()) .append(", processes=").append(descriptor.getProcessCount()) .append(" (active=").append(descriptor.getActiveProcessCount()) .append(", restored=").append(descriptor.getRestoredProcessCount()).append(")").append('\n'); builder.append("- tokens=").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\n'); builder.append("- checkpoint=").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 160)).append('\n'); builder.append("- compact=").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), "none")).append('\n'); builder.append("- summary=").append(clip(descriptor.getSummary(), 220)); return builder.toString().trim(); } private static String renderSkills(Context context, String argument) { WorkspaceContext workspaceContext = context.session == null || context.session.getSession() == null ? null : context.session.getSession().getWorkspaceContext(); if (workspaceContext == null) { return "skills: (none)"; } List skills = workspaceContext.getAvailableSkills(); if (!isBlank(argument)) { CodingSkillDescriptor selected = findSkill(skills, argument); if (selected == null) { return "skills: unknown skill `" + argument.trim() + "`"; } StringBuilder builder = new StringBuilder(); builder.append("skill:\n"); builder.append("- name=").append(firstNonBlank(selected.getName(), "skill")).append('\n'); builder.append("- source=").append(firstNonBlank(selected.getSource(), "unknown")).append('\n'); builder.append("- path=").append(firstNonBlank(selected.getSkillFilePath(), "(missing)")).append('\n'); builder.append("- description=").append(firstNonBlank(selected.getDescription(), "No description available.")); return builder.toString().trim(); } if (skills == null || skills.isEmpty()) { return "skills: (none)"; } StringBuilder builder = new StringBuilder(); builder.append("skills:\n"); builder.append("- count=").append(skills.size()).append('\n'); for (CodingSkillDescriptor skill : skills) { if (skill == null) { continue; } builder.append("- ").append(firstNonBlank(skill.getName(), "skill")) .append(" | source=").append(firstNonBlank(skill.getSource(), "unknown")) .append(" | path=").append(firstNonBlank(skill.getSkillFilePath(), "(missing)")) .append(" | description=").append(firstNonBlank(skill.getDescription(), "No description available.")) .append('\n'); } return builder.toString().trim(); } private static String renderAgents(Context context, String argument) { CodingCliAgentFactory.PreparedCodingAgent prepared = context.prepared; CodingAgentDefinitionRegistry registry = prepared == null || prepared.getAgent() == null ? null : prepared.getAgent().getDefinitionRegistry(); List definitions = registry == null ? null : registry.listDefinitions(); if (!isBlank(argument)) { CodingAgentDefinition selected = findAgent(definitions, argument); if (selected == null) { return "agents: unknown agent `" + argument.trim() + "`"; } StringBuilder builder = new StringBuilder(); builder.append("agent:\n"); builder.append("- name=").append(firstNonBlank(selected.getName(), "agent")).append('\n'); builder.append("- tool=").append(firstNonBlank(selected.getToolName(), "(none)")).append('\n'); builder.append("- model=").append(firstNonBlank(selected.getModel(), "(inherit)")).append('\n'); builder.append("- background=").append(selected.isBackground()).append('\n'); builder.append("- tools=").append(renderAllowedTools(selected)).append('\n'); builder.append("- description=").append(firstNonBlank(selected.getDescription(), "No description available.")); return builder.toString().trim(); } if (definitions == null || definitions.isEmpty()) { return "agents: (none)"; } StringBuilder builder = new StringBuilder(); builder.append("agents:\n"); builder.append("- count=").append(definitions.size()).append('\n'); for (CodingAgentDefinition definition : definitions) { if (definition == null) { continue; } builder.append("- ").append(firstNonBlank(definition.getName(), "agent")) .append(" | tool=").append(firstNonBlank(definition.getToolName(), "(none)")) .append(" | model=").append(firstNonBlank(definition.getModel(), "(inherit)")) .append(" | background=").append(definition.isBackground()) .append(" | tools=").append(renderAllowedTools(definition)) .append(" | description=").append(firstNonBlank(definition.getDescription(), "No description available.")) .append('\n'); } return builder.toString().trim(); } private static String renderMcp(Context context) { CliMcpRuntimeManager runtimeManager = context.mcpRuntimeManager; if (runtimeManager == null || !runtimeManager.hasStatuses()) { return "mcp: (none)"; } List statuses = runtimeManager.getStatuses(); if (statuses == null || statuses.isEmpty()) { return "mcp: (none)"; } StringBuilder builder = new StringBuilder(); builder.append("mcp:\n"); for (CliMcpStatusSnapshot status : statuses) { if (status == null) { continue; } builder.append("- ").append(firstNonBlank(status.getServerName(), "(server)")) .append(" | type=").append(firstNonBlank(status.getTransportType(), "(unknown)")) .append(" | state=").append(firstNonBlank(status.getState(), "(unknown)")) .append(" | workspace=").append(status.isWorkspaceEnabled() ? "enabled" : "disabled") .append(" | paused=").append(status.isSessionPaused() ? "yes" : "no") .append(" | tools=").append(status.getToolCount()); if (!isBlank(status.getErrorSummary())) { builder.append(" | error=").append(clip(status.getErrorSummary(), 120)); } builder.append('\n'); } return builder.toString().trim(); } private static String renderSessions(Context context) throws Exception { List sessions = context.sessionManager.list(); if (sessions == null || sessions.isEmpty()) { return "sessions: (none)"; } List sorted = new ArrayList(sessions); Collections.sort(sorted, new Comparator() { @Override public int compare(CodingSessionDescriptor left, CodingSessionDescriptor right) { long l = left == null ? 0L : left.getUpdatedAtEpochMs(); long r = right == null ? 0L : right.getUpdatedAtEpochMs(); return Long.compare(r, l); } }); StringBuilder builder = new StringBuilder("sessions:\n"); for (CodingSessionDescriptor session : sorted) { if (session == null) { continue; } builder.append("- ").append(session.getSessionId()) .append(" | root=").append(clip(session.getRootSessionId(), 24)) .append(" | parent=").append(clip(firstNonBlank(session.getParentSessionId(), "-"), 24)) .append(" | updated=").append(formatTimestamp(session.getUpdatedAtEpochMs())) .append(" | memory=").append(session.getMemoryItemCount()) .append(" | processes=").append(session.getProcessCount()) .append(" | ").append(clip(session.getSummary(), 120)) .append('\n'); } return builder.toString().trim(); } private static String renderHistory(Context context, String targetSessionId) throws Exception { List sessions = mergeCurrentSession(context.sessionManager.list(), context.session); CodingSessionDescriptor target = resolveTargetDescriptor(sessions, context.session, targetSessionId); if (target == null) { return "history: (none)"; } List history = resolveHistory(sessions, target); if (history.isEmpty()) { return "history: (none)"; } StringBuilder builder = new StringBuilder("history:\n"); for (CodingSessionDescriptor session : history) { builder.append("- ").append(session.getSessionId()) .append(" | parent=").append(firstNonBlank(session.getParentSessionId(), "(root)")) .append(" | updated=").append(formatTimestamp(session.getUpdatedAtEpochMs())) .append(" | ").append(clip(session.getSummary(), 120)) .append('\n'); } return builder.toString().trim(); } private static String renderTree(Context context, String rootArgument) throws Exception { List sessions = mergeCurrentSession(context.sessionManager.list(), context.session); List lines = renderTreeLines(sessions, rootArgument, context.session == null ? null : context.session.getSessionId()); if (lines.isEmpty()) { return "tree: (none)"; } StringBuilder builder = new StringBuilder("tree:\n"); for (String line : lines) { builder.append(line).append('\n'); } return builder.toString().trim(); } private static String renderEvents(Context context, String limitArgument) throws Exception { ManagedCodingSession session = context.session; if (session == null) { return "events: (no current session)"; } Integer limit = parseLimit(limitArgument); List events = context.sessionManager.listEvents(session.getSessionId(), limit, null); if (events == null || events.isEmpty()) { return "events: (none)"; } StringBuilder builder = new StringBuilder("events:\n"); for (SessionEvent event : events) { if (event == null) { continue; } builder.append("- ").append(formatTimestamp(event.getTimestamp())) .append(" | ").append(event.getType()) .append(event.getStep() == null ? "" : " | step=" + event.getStep()) .append(" | ").append(clip(event.getSummary(), 160)) .append('\n'); } return builder.toString().trim(); } private static String renderCompacts(Context context, String limitArgument) throws Exception { ManagedCodingSession session = context.session; if (session == null) { return "compacts: (no current session)"; } int limit = parseLimit(limitArgument); List events = context.sessionManager.listEvents(session.getSessionId(), null, null); List lines = buildCompactLines(events, limit); if (lines.isEmpty()) { return "compacts: (none)"; } StringBuilder builder = new StringBuilder("compacts:\n"); for (String line : lines) { builder.append(line).append('\n'); } return builder.toString().trim(); } private static String renderCheckpoint(Context context) { CodingSessionSnapshot snapshot = snapshot(context.session); String summary = snapshot == null ? null : snapshot.getSummary(); if (isBlank(summary)) { return "checkpoint: (none)"; } return "checkpoint:\n" + CodingSessionCheckpointFormatter.render(CodingSessionCheckpointFormatter.parse(summary)); } private static String renderTeam(Context context, String argument) throws Exception { if (context == null || context.session == null || context.sessionManager == null) { return "team: (none)"; } if (!isBlank(argument)) { CliTeamStateManager manager = new CliTeamStateManager(resolveWorkspaceRoot(context)); List tokens = splitWhitespace(argument); if (tokens.isEmpty()) { return manager.renderListOutput(); } String action = tokens.get(0).toLowerCase(Locale.ROOT); if ("list".equals(action)) { return manager.renderListOutput(); } if ("status".equals(action)) { return manager.renderStatusOutput(tokens.size() > 1 ? tokens.get(1) : null); } if ("messages".equals(action)) { Integer limit = tokens.size() > 2 ? Integer.valueOf(parseLimit(tokens.get(2))) : null; return manager.renderMessagesOutput(tokens.size() > 1 ? tokens.get(1) : null, limit); } if ("resume".equals(action)) { return manager.renderResumeOutput(tokens.size() > 1 ? tokens.get(1) : null); } return "Usage: /team | /team list | /team status [team-id] | /team messages [team-id] [limit] | /team resume [team-id]"; } List events = context.sessionManager.listEvents(context.session.getSessionId(), null, null); return TeamBoardRenderSupport.renderBoardOutput(TeamBoardRenderSupport.renderBoardLines(events)); } private static java.nio.file.Path resolveWorkspaceRoot(Context context) { String workspace = context == null || context.session == null ? null : trimToNull(context.session.getWorkspace()); if (workspace == null && context != null && context.options != null) { workspace = trimToNull(context.options.getWorkspace()); } if (workspace == null) { return java.nio.file.Paths.get(".").toAbsolutePath().normalize(); } return java.nio.file.Paths.get(workspace).toAbsolutePath().normalize(); } private static String renderProcesses(Context context) { CodingSessionSnapshot snapshot = snapshot(context.session); List processes = snapshot == null ? null : snapshot.getProcesses(); if (processes == null || processes.isEmpty()) { return "processes: (none)"; } StringBuilder builder = new StringBuilder("processes:\n"); for (BashProcessInfo process : processes) { if (process == null) { continue; } builder.append("- ").append(firstNonBlank(process.getProcessId(), "(process)")) .append(" | status=").append(process.getStatus()) .append(" | mode=").append(process.isControlAvailable() ? "live" : "metadata-only") .append(" | restored=").append(process.isRestored()) .append(" | cwd=").append(clip(process.getWorkingDirectory(), 48)) .append(" | cmd=").append(clip(process.getCommand(), 72)) .append('\n'); } return builder.toString().trim(); } private static String renderProcess(Context context, String rawArguments) throws Exception { if (context.session == null || context.session.getSession() == null) { return "process: (no current session)"; } if (isBlank(rawArguments)) { return "Usage: /process status \nUsage: /process logs [limit]"; } String[] parts = rawArguments.trim().split("\\s+", 3); String action = parts[0].toLowerCase(Locale.ROOT); if ("status".equals(action)) { if (parts.length < 2) { return "Usage: /process status "; } return renderProcessStatusOutput(context.session.getSession().processStatus(parts[1])); } if ("logs".equals(action)) { if (parts.length < 2) { return "Usage: /process logs [limit]"; } Integer limit = parts.length >= 3 ? parseLimit(parts[2]) : DEFAULT_PROCESS_LOG_LIMIT; BashProcessInfo info = context.session.getSession().processStatus(parts[1]); BashProcessLogChunk logs = context.session.getSession().processLogs(parts[1], null, limit); return renderProcessDetailsOutput(info, logs); } return "Unknown process action: " + action + ". Use /process status or /process logs [limit]."; } private static CodingSessionSnapshot snapshot(ManagedCodingSession session) { return session == null || session.getSession() == null ? null : session.getSession().snapshot(); } private static List mergeCurrentSession(List sessions, ManagedCodingSession currentSession) { LinkedHashMap merged = new LinkedHashMap(); if (sessions != null) { for (CodingSessionDescriptor session : sessions) { if (session != null && !isBlank(session.getSessionId())) { merged.put(session.getSessionId(), session); } } } if (currentSession != null && !isBlank(currentSession.getSessionId())) { merged.put(currentSession.getSessionId(), currentSession.toDescriptor()); } return new ArrayList(merged.values()); } private static CodingSessionDescriptor resolveTargetDescriptor(List sessions, ManagedCodingSession currentSession, String targetSessionId) { String requested = trimToNull(targetSessionId); if (requested == null && currentSession != null) { requested = currentSession.getSessionId(); } if (requested == null) { return null; } for (CodingSessionDescriptor session : sessions) { if (session != null && requested.equals(session.getSessionId())) { return session; } } return null; } private static List resolveHistory(List sessions, CodingSessionDescriptor target) { if (target == null || sessions == null || sessions.isEmpty()) { return Collections.emptyList(); } Map byId = new LinkedHashMap(); for (CodingSessionDescriptor session : sessions) { if (session != null && !isBlank(session.getSessionId())) { byId.put(session.getSessionId(), session); } } ArrayDeque chain = new ArrayDeque(); Set seen = new LinkedHashSet(); CodingSessionDescriptor current = target; while (current != null && !seen.contains(current.getSessionId())) { chain.addFirst(current); seen.add(current.getSessionId()); current = byId.get(current.getParentSessionId()); } return new ArrayList(chain); } private static List renderTreeLines(List sessions, String rootArgument, String currentSessionId) { if (sessions == null || sessions.isEmpty()) { return Collections.emptyList(); } Map byId = new LinkedHashMap(); Map> children = new LinkedHashMap>(); for (CodingSessionDescriptor session : sessions) { if (session == null || isBlank(session.getSessionId())) { continue; } byId.put(session.getSessionId(), session); String parentId = trimToNull(session.getParentSessionId()); List siblings = children.get(parentId); if (siblings == null) { siblings = new ArrayList(); children.put(parentId, siblings); } siblings.add(session); } for (List siblings : children.values()) { Collections.sort(siblings, new Comparator() { @Override public int compare(CodingSessionDescriptor left, CodingSessionDescriptor right) { long l = left == null ? 0L : left.getCreatedAtEpochMs(); long r = right == null ? 0L : right.getCreatedAtEpochMs(); int createdCompare = Long.compare(l, r); if (createdCompare != 0) { return createdCompare; } String leftId = left == null ? "" : firstNonBlank(left.getSessionId(), ""); String rightId = right == null ? "" : firstNonBlank(right.getSessionId(), ""); return leftId.compareTo(rightId); } }); } String requestedRoot = trimToNull(rootArgument); List roots = new ArrayList(); if (requestedRoot != null) { CodingSessionDescriptor root = byId.get(requestedRoot); if (root != null) { roots.add(root); } } else { List top = children.get(null); if (top != null) { roots.addAll(top); } } if (roots.isEmpty()) { return Collections.emptyList(); } List lines = new ArrayList(); for (int i = 0; i < roots.size(); i++) { renderTreeNode(lines, children, roots.get(i), "", i == roots.size() - 1, currentSessionId); } return lines; } private static void renderTreeNode(List lines, Map> children, CodingSessionDescriptor session, String prefix, boolean last, String currentSessionId) { if (lines == null || session == null) { return; } String branch = prefix + (prefix.isEmpty() ? "" : (last ? "\\- " : "|- ")); lines.add((prefix.isEmpty() ? "" : branch) + renderTreeLabel(session, currentSessionId)); List descendants = children.get(session.getSessionId()); if (descendants == null || descendants.isEmpty()) { return; } String childPrefix = prefix + (prefix.isEmpty() ? "" : (last ? " " : "| ")); if (prefix.isEmpty()) { childPrefix = ""; } for (int i = 0; i < descendants.size(); i++) { boolean childLast = i == descendants.size() - 1; String nextPrefix = prefix.isEmpty() ? "" : childPrefix; renderTreeNode(lines, children, descendants.get(i), nextPrefix, childLast, currentSessionId); } } private static String renderTreeLabel(CodingSessionDescriptor session, String currentSessionId) { StringBuilder builder = new StringBuilder(); builder.append(session.getSessionId()); if (!isBlank(currentSessionId) && currentSessionId.equals(session.getSessionId())) { builder.append(" [current]"); } builder.append(" | updated=").append(formatTimestamp(session.getUpdatedAtEpochMs())); builder.append(" | ").append(clip(session.getSummary(), 120)); return builder.toString(); } private static List buildCompactLines(List events, int limit) { List compactEvents = new ArrayList(); if (events != null) { for (SessionEvent event : events) { if (event != null && event.getType() == SessionEventType.COMPACT) { compactEvents.add(event); } } } if (compactEvents.isEmpty()) { return Collections.emptyList(); } int safeLimit = limit <= 0 ? DEFAULT_EVENT_LIMIT : limit; int from = Math.max(0, compactEvents.size() - safeLimit); List lines = new ArrayList(); for (int i = from; i < compactEvents.size(); i++) { SessionEvent event = compactEvents.get(i); Map payload = event.getPayload(); StringBuilder line = new StringBuilder(); line.append(formatTimestamp(event.getTimestamp())) .append(" | mode=").append(resolveCompactMode(event)) .append(" | tokens=").append(formatCompactDelta(payloadInt(payload, "estimatedTokensBefore"), payloadInt(payload, "estimatedTokensAfter"))) .append(" | items=").append(formatCompactDelta(payloadInt(payload, "beforeItemCount"), payloadInt(payload, "afterItemCount"))); if (payloadBoolean(payload, "splitTurn")) { line.append(" | splitTurn"); } String checkpointGoal = clip(payloadString(payload, "checkpointGoal"), 64); if (!isBlank(checkpointGoal) && !"(none)".equals(checkpointGoal)) { line.append(" | goal=").append(checkpointGoal); } lines.add(line.toString()); String summary = firstNonBlank(payloadString(payload, "summary"), event.getSummary()); if (!isBlank(summary)) { lines.add(" - " + clip(summary, 140)); } } return lines; } private static String renderProcessStatusOutput(BashProcessInfo processInfo) { if (processInfo == null) { return "process status: (none)"; } StringBuilder builder = new StringBuilder("process status:\n"); appendProcessSummary(builder, processInfo); return builder.toString().trim(); } private static String renderProcessDetailsOutput(BashProcessInfo processInfo, BashProcessLogChunk logs) { StringBuilder builder = new StringBuilder(renderProcessStatusOutput(processInfo)); String content = logs == null ? null : logs.getContent(); if (!isBlank(content)) { builder.append('\n').append('\n').append("process logs:\n").append(content.trim()); } return builder.toString().trim(); } private static void appendProcessSummary(StringBuilder builder, BashProcessInfo processInfo) { if (builder == null || processInfo == null) { return; } builder.append("- id=").append(firstNonBlank(processInfo.getProcessId(), "(process)")).append('\n'); builder.append("- status=").append(processInfo.getStatus()) .append(", mode=").append(processInfo.isControlAvailable() ? "live" : "metadata-only") .append(", restored=").append(processInfo.isRestored()).append('\n'); builder.append("- pid=").append(processInfo.getPid() == null ? "(none)" : processInfo.getPid()) .append(", exitCode=").append(processInfo.getExitCode() == null ? "(running)" : processInfo.getExitCode()).append('\n'); builder.append("- cwd=").append(firstNonBlank(processInfo.getWorkingDirectory(), "(none)")).append('\n'); builder.append("- command=").append(firstNonBlank(processInfo.getCommand(), "(none)")).append('\n'); builder.append("- started=").append(formatTimestamp(processInfo.getStartedAt())) .append(", ended=").append(processInfo.getEndedAt() == null ? "(running)" : formatTimestamp(processInfo.getEndedAt())); } private static CodingSkillDescriptor findSkill(List skills, String name) { if (skills == null || isBlank(name)) { return null; } String normalized = name.trim(); for (CodingSkillDescriptor skill : skills) { if (skill != null && !isBlank(skill.getName()) && skill.getName().trim().equalsIgnoreCase(normalized)) { return skill; } } return null; } private static CodingAgentDefinition findAgent(List definitions, String nameOrToolName) { if (definitions == null || isBlank(nameOrToolName)) { return null; } String normalized = nameOrToolName.trim(); for (CodingAgentDefinition definition : definitions) { if (definition == null) { continue; } if ((!isBlank(definition.getName()) && definition.getName().trim().equalsIgnoreCase(normalized)) || (!isBlank(definition.getToolName()) && definition.getToolName().trim().equalsIgnoreCase(normalized))) { return definition; } } return null; } private static String renderAllowedTools(CodingAgentDefinition definition) { if (definition == null || definition.getAllowedToolNames() == null || definition.getAllowedToolNames().isEmpty()) { return "(inherit/all available)"; } StringBuilder builder = new StringBuilder(); for (String toolName : definition.getAllowedToolNames()) { if (isBlank(toolName)) { continue; } if (builder.length() > 0) { builder.append(", "); } builder.append(toolName.trim()); } return builder.length() == 0 ? "(inherit/all available)" : builder.toString(); } private static String renderMcpSummary(CliMcpRuntimeManager runtimeManager) { if (runtimeManager == null || !runtimeManager.hasStatuses()) { return null; } int connected = 0; int errors = 0; int paused = 0; int disabled = 0; int missing = 0; int tools = 0; for (CliMcpStatusSnapshot status : runtimeManager.getStatuses()) { if (status == null) { continue; } tools += Math.max(0, status.getToolCount()); if (CliMcpRuntimeManager.STATE_CONNECTED.equals(status.getState())) { connected++; } else if (CliMcpRuntimeManager.STATE_ERROR.equals(status.getState())) { errors++; } else if (CliMcpRuntimeManager.STATE_PAUSED.equals(status.getState())) { paused++; } else if (CliMcpRuntimeManager.STATE_DISABLED.equals(status.getState())) { disabled++; } else if (CliMcpRuntimeManager.STATE_MISSING.equals(status.getState())) { missing++; } } return "connected " + connected + ", errors " + errors + ", paused " + paused + ", disabled " + disabled + ", missing " + missing + ", tools " + tools; } private static String resolveCompactMode(SessionEvent event) { if (event == null || event.getPayload() == null) { return "unknown"; } Object automatic = event.getPayload().get("automatic"); if (automatic instanceof Boolean) { return Boolean.TRUE.equals(automatic) ? "auto" : "manual"; } String summary = firstNonBlank(event.getSummary(), ""); if (summary.startsWith("auto compact")) { return "auto"; } if (summary.startsWith("manual compact")) { return "manual"; } return "unknown"; } private static String formatCompactDelta(Integer before, Integer after) { return defaultInt(before) + "->" + defaultInt(after); } private static Integer parseLimit(String raw) { String trimmed = trimToNull(raw); if (trimmed == null) { return DEFAULT_EVENT_LIMIT; } try { int parsed = Integer.parseInt(trimmed); return parsed <= 0 ? DEFAULT_EVENT_LIMIT : parsed; } catch (NumberFormatException ignore) { return DEFAULT_EVENT_LIMIT; } } private static int defaultInt(Integer value) { return value == null ? 0 : value; } private static Integer payloadInt(Map payload, String key) { if (payload == null || key == null) { return null; } Object value = payload.get(key); if (value instanceof Number) { return Integer.valueOf(((Number) value).intValue()); } try { return value == null ? null : Integer.valueOf(String.valueOf(value)); } catch (Exception ignore) { return null; } } private static boolean payloadBoolean(Map payload, String key) { if (payload == null || key == null) { return false; } Object value = payload.get(key); if (value instanceof Boolean) { return Boolean.TRUE.equals(value); } return value != null && "true".equalsIgnoreCase(String.valueOf(value)); } private static String payloadString(Map payload, String key) { if (payload == null || key == null) { return null; } Object value = payload.get(key); return value == null ? null : String.valueOf(value); } private static String formatTimestamp(long epochMs) { if (epochMs <= 0L) { return "(unknown)"; } SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT); format.setTimeZone(TimeZone.getDefault()); return format.format(new Date(epochMs)); } private static String clip(String value, int maxChars) { String normalized = value == null ? null : value.replace('\r', ' ').replace('\n', ' ').trim(); if (isBlank(normalized)) { return "(none)"; } if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, Math.max(0, maxChars)); } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private static String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static List splitWhitespace(String value) { if (isBlank(value)) { return Collections.emptyList(); } return Arrays.asList(value.trim().split("\\s+")); } static final class Context { final ManagedCodingSession session; final CodingSessionManager sessionManager; final CodeCommandOptions options; final CodingCliAgentFactory.PreparedCodingAgent prepared; final CliMcpRuntimeManager mcpRuntimeManager; final RuntimeCommandHandler runtimeCommandHandler; Context(ManagedCodingSession session, CodingSessionManager sessionManager, CodeCommandOptions options, CodingCliAgentFactory.PreparedCodingAgent prepared, CliMcpRuntimeManager mcpRuntimeManager) { this(session, sessionManager, options, prepared, mcpRuntimeManager, null); } Context(ManagedCodingSession session, CodingSessionManager sessionManager, CodeCommandOptions options, CodingCliAgentFactory.PreparedCodingAgent prepared, CliMcpRuntimeManager mcpRuntimeManager, RuntimeCommandHandler runtimeCommandHandler) { this.session = session; this.sessionManager = sessionManager; this.options = options; this.prepared = prepared; this.mcpRuntimeManager = mcpRuntimeManager; this.runtimeCommandHandler = runtimeCommandHandler; } } interface RuntimeCommandHandler { String executeProviders() throws Exception; String executeProvider(String argument) throws Exception; String executeModel(String argument) throws Exception; String executeExperimental(String argument) throws Exception; } static final class ExecutionResult { private final String output; private ExecutionResult(String output) { this.output = output; } static ExecutionResult of(String output) { return new ExecutionResult(output); } String getOutput() { return output; } } private static final class CommandSpec { private final String name; private final String description; private final String inputHint; private CommandSpec(String name, String description, String inputHint) { this.name = name; this.description = description; this.inputHint = inputHint; } } private static final class ParsedCommand { private final String name; private final String argument; private ParsedCommand(String name, String argument) { this.name = name; this.argument = argument; } } private enum RuntimeCommand { PROVIDERS, PROVIDER, MODEL, EXPERIMENTAL } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpToolApprovalDecorator.java ================================================ package io.github.lnyocly.ai4j.cli.acp; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.runtime.CliToolApprovalDecorator; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator; import java.util.LinkedHashMap; import java.util.Map; public class AcpToolApprovalDecorator implements ToolExecutorDecorator { public interface PermissionGateway { PermissionDecision requestApproval(String toolName, AgentToolCall call, Map rawInput) throws Exception; } public static final class PermissionDecision { private final boolean approved; private final String optionId; public PermissionDecision(boolean approved, String optionId) { this.approved = approved; this.optionId = optionId; } public boolean isApproved() { return approved; } public String getOptionId() { return optionId; } } private final ApprovalMode approvalMode; private final PermissionGateway permissionGateway; public AcpToolApprovalDecorator(ApprovalMode approvalMode, PermissionGateway permissionGateway) { this.approvalMode = approvalMode == null ? ApprovalMode.AUTO : approvalMode; this.permissionGateway = permissionGateway; } @Override public ToolExecutor decorate(final String toolName, final ToolExecutor delegate) { if (delegate == null || approvalMode == ApprovalMode.AUTO) { return delegate; } return new ToolExecutor() { @Override public String execute(AgentToolCall call) throws Exception { if (requiresApproval(toolName, call)) { requestApproval(toolName, call); } return delegate.execute(call); } }; } private boolean requiresApproval(String toolName, AgentToolCall call) { if (approvalMode == ApprovalMode.MANUAL) { return true; } if (CodingToolNames.APPLY_PATCH.equals(toolName)) { return true; } if (!CodingToolNames.BASH.equals(toolName)) { return false; } JSONObject arguments = parseArguments(call == null ? null : call.getArguments()); String action = arguments.getString("action"); if (isBlank(action)) { action = "exec"; } return "exec".equals(action) || "start".equals(action) || "stop".equals(action) || "write".equals(action); } private void requestApproval(String toolName, AgentToolCall call) throws Exception { if (permissionGateway == null) { throw new IllegalStateException("Tool call requires approval but no ACP permission gateway is available"); } PermissionDecision decision = permissionGateway.requestApproval(toolName, call, buildRawInput(toolName, call)); if (decision == null || !decision.isApproved()) { throw new IllegalStateException(buildApprovalRejectedMessage(toolName, call, decision == null ? null : decision.getOptionId())); } } private Map buildRawInput(String toolName, AgentToolCall call) { Map rawInput = new LinkedHashMap(); rawInput.put("tool", firstNonBlank(toolName, call == null ? null : call.getName())); rawInput.put("callId", call == null ? null : call.getCallId()); rawInput.put("arguments", parseArguments(call == null ? null : call.getArguments())); return rawInput; } private String buildApprovalRejectedMessage(String toolName, AgentToolCall call, String optionId) { return CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX + " " + firstNonBlank(optionId, toolName, call == null ? null : call.getName(), "tool"); } private JSONObject parseArguments(String rawArguments) { if (isBlank(rawArguments)) { return new JSONObject(); } try { JSONObject arguments = JSON.parseObject(rawArguments); return arguments == null ? new JSONObject() : arguments; } catch (Exception ex) { return new JSONObject(); } } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/agent/CliCodingAgentRegistry.java ================================================ package io.github.lnyocly.ai4j.cli.agent; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.definition.CodingApprovalMode; import io.github.lnyocly.ai4j.coding.definition.CodingIsolationMode; import io.github.lnyocly.ai4j.coding.definition.CodingMemoryScope; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import io.github.lnyocly.ai4j.coding.definition.StaticCodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; public class CliCodingAgentRegistry { private final Path workspaceRoot; private final List configuredDirectories; public CliCodingAgentRegistry(Path workspaceRoot, List configuredDirectories) { this.workspaceRoot = workspaceRoot == null ? Paths.get(".").toAbsolutePath().normalize() : workspaceRoot.toAbsolutePath().normalize(); this.configuredDirectories = configuredDirectories == null ? Collections.emptyList() : new ArrayList(configuredDirectories); } public CodingAgentDefinitionRegistry loadRegistry() { return new StaticCodingAgentDefinitionRegistry(listDefinitions()); } public List listDefinitions() { List ordered = new ArrayList(); for (Path directory : listRoots()) { loadDirectory(directory, ordered); } return ordered.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(ordered); } public List listRoots() { LinkedHashSet roots = new LinkedHashSet(); Path home = homeAgentsDirectory(); if (home != null) { roots.add(home); } roots.add(workspaceAgentsDirectory()); for (String configuredDirectory : configuredDirectories) { if (isBlank(configuredDirectory)) { continue; } Path root = Paths.get(configuredDirectory.trim()); if (!root.isAbsolute()) { root = workspaceRoot.resolve(configuredDirectory.trim()); } roots.add(root.toAbsolutePath().normalize()); } return new ArrayList(roots); } private void loadDirectory(Path directory, List ordered) { if (directory == null || ordered == null || !Files.isDirectory(directory)) { return; } try (DirectoryStream stream = Files.newDirectoryStream(directory)) { for (Path file : stream) { if (!hasSupportedExtension(file)) { continue; } CodingAgentDefinition definition = loadDefinition(file); if (definition != null) { mergeDefinition(ordered, definition); } } } catch (IOException ignored) { } } private CodingAgentDefinition loadDefinition(Path file) { if (file == null || !Files.isRegularFile(file)) { return null; } try { String raw = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); ParsedDefinition parsed = parse(raw); String fallbackName = defaultName(file); String name = firstNonBlank(parsed.meta("name"), fallbackName); String instructions = trimToNull(parsed.body); String description = trimToNull(parsed.meta("description")); if (instructions == null) { instructions = description; } if (isBlank(name) || isBlank(instructions)) { return null; } Set allowedTools = parseAllowedTools(parsed.meta("tools")); CodingIsolationMode isolationMode = parseEnum( parsed.meta("isolationmode"), CodingIsolationMode.class, allowedTools != null && allowedTools.equals(CodingToolNames.readOnlyBuiltIn()) ? CodingIsolationMode.READ_ONLY : CodingIsolationMode.INHERIT ); return CodingAgentDefinition.builder() .name(name) .description(description) .toolName(firstNonBlank(parsed.meta("toolname"), defaultToolName(name))) .model(trimToNull(parsed.meta("model"))) .instructions(instructions) .systemPrompt(trimToNull(parsed.meta("systemprompt"))) .allowedToolNames(allowedTools) .sessionMode(parseEnum(parsed.meta("sessionmode"), CodingSessionMode.class, CodingSessionMode.FORK)) .isolationMode(isolationMode) .memoryScope(parseEnum(parsed.meta("memoryscope"), CodingMemoryScope.class, CodingMemoryScope.INHERIT)) .approvalMode(parseEnum(parsed.meta("approvalmode"), CodingApprovalMode.class, CodingApprovalMode.INHERIT)) .background(parseBoolean(parsed.meta("background"), false)) .build(); } catch (IOException ignored) { return null; } } private ParsedDefinition parse(String raw) { String value = raw == null ? "" : raw.replace("\r\n", "\n"); if (!value.startsWith("---\n")) { return new ParsedDefinition(Collections.emptyList(), value.trim()); } int end = value.indexOf("\n---\n", 4); if (end < 0) { end = value.indexOf("\n---", 4); } if (end < 0) { return new ParsedDefinition(Collections.emptyList(), value.trim()); } String header = value.substring(4, end).trim(); String body = value.substring(Math.min(value.length(), end + 5)).trim(); List metadata = new ArrayList(); if (!isBlank(header)) { String[] lines = header.split("\n"); for (String line : lines) { String trimmed = trimToNull(line); if (trimmed == null || trimmed.startsWith("#")) { continue; } int separator = trimmed.indexOf(':'); if (separator <= 0) { continue; } String key = normalizeKey(trimmed.substring(0, separator)); String content = trimToNull(trimmed.substring(separator + 1)); if (key != null) { metadata.add(new MetadataEntry(key, stripQuotes(content))); } } } return new ParsedDefinition(metadata, body); } private Set parseAllowedTools(String raw) { String value = trimToNull(raw); if (value == null) { return null; } String normalized = normalizeToken(value); if ("all".equals(normalized) || "builtin_all".equals(normalized) || "all_built_in".equals(normalized)) { return CodingToolNames.allBuiltIn(); } if ("read_only".equals(normalized) || "readonly".equals(normalized)) { return CodingToolNames.readOnlyBuiltIn(); } if ("inherit".equals(normalized) || "default".equals(normalized)) { return null; } String flattened = value; if (flattened.startsWith("[") && flattened.endsWith("]") && flattened.length() >= 2) { flattened = flattened.substring(1, flattened.length() - 1); } String[] parts = flattened.split(","); LinkedHashSet toolNames = new LinkedHashSet(); for (String part : parts) { String token = trimToNull(part); if (token != null) { toolNames.add(token); } } return toolNames.isEmpty() ? null : Collections.unmodifiableSet(toolNames); } private > E parseEnum(String raw, Class type, E defaultValue) { String value = normalizeToken(raw); if (value == null || type == null) { return defaultValue; } for (E constant : type.getEnumConstants()) { if (constant != null && normalizeToken(constant.name()).equals(value)) { return constant; } } return defaultValue; } private boolean parseBoolean(String raw, boolean defaultValue) { String value = normalizeToken(raw); if (value == null) { return defaultValue; } if ("true".equals(value) || "yes".equals(value) || "on".equals(value)) { return true; } if ("false".equals(value) || "no".equals(value) || "off".equals(value)) { return false; } return defaultValue; } private void mergeDefinition(List ordered, CodingAgentDefinition candidate) { if (ordered == null || candidate == null || isBlank(candidate.getName())) { return; } String name = normalizeToken(candidate.getName()); String toolName = normalizeToken(candidate.getToolName()); for (Iterator iterator = ordered.iterator(); iterator.hasNext(); ) { CodingAgentDefinition existing = iterator.next(); if (existing == null) { iterator.remove(); continue; } if (sameKey(name, existing.getName()) || sameKey(toolName, existing.getToolName()) || sameKey(name, existing.getToolName()) || sameKey(toolName, existing.getName())) { iterator.remove(); } } ordered.add(candidate); } private boolean sameKey(String left, String right) { String normalizedRight = normalizeToken(right); return left != null && normalizedRight != null && left.equals(normalizedRight); } private Path workspaceAgentsDirectory() { return workspaceRoot.resolve(".ai4j").resolve("agents").toAbsolutePath().normalize(); } private Path homeAgentsDirectory() { String userHome = System.getProperty("user.home"); return isBlank(userHome) ? null : Paths.get(userHome).resolve(".ai4j").resolve("agents").toAbsolutePath().normalize(); } private boolean hasSupportedExtension(Path file) { if (file == null || file.getFileName() == null) { return false; } String name = file.getFileName().toString().toLowerCase(Locale.ROOT); return name.endsWith(".md") || name.endsWith(".txt") || name.endsWith(".prompt"); } private String defaultName(Path file) { if (file == null || file.getFileName() == null) { return null; } String fileName = file.getFileName().toString(); int dot = fileName.lastIndexOf('.'); return dot > 0 ? fileName.substring(0, dot) : fileName; } private String defaultToolName(String name) { String slug = normalizeToolSegment(name); if (slug == null) { return null; } return slug.startsWith("delegate_") ? slug : "delegate_" + slug; } private String normalizeToolSegment(String value) { String normalized = normalizeToken(value); if (normalized == null) { return null; } normalized = normalized.replace('-', '_'); StringBuilder builder = new StringBuilder(); boolean previousUnderscore = false; for (int i = 0; i < normalized.length(); i++) { char current = normalized.charAt(i); boolean allowed = (current >= 'a' && current <= 'z') || (current >= '0' && current <= '9') || current == '_'; char next = allowed ? current : '_'; if (next == '_') { if (!previousUnderscore && builder.length() > 0) { builder.append(next); } previousUnderscore = true; } else { builder.append(next); previousUnderscore = false; } } String result = builder.toString(); while (result.endsWith("_")) { result = result.substring(0, result.length() - 1); } return result.isEmpty() ? null : result; } private String normalizeKey(String value) { String normalized = normalizeToken(value); if (normalized == null) { return null; } return normalized.replace("-", "").replace("_", ""); } private String normalizeToken(String value) { if (isBlank(value)) { return null; } return value.trim().toLowerCase(Locale.ROOT).replace(' ', '_').replace('-', '_'); } private String stripQuotes(String value) { if (value == null || value.length() < 2) { return value; } if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { return value.substring(1, value.length() - 1).trim(); } return value; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { String normalized = trimToNull(value); if (normalized != null) { return normalized; } } return null; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static final class ParsedDefinition { private final List metadata; private final String body; private ParsedDefinition(List metadata, String body) { this.metadata = metadata == null ? Collections.emptyList() : metadata; this.body = body; } private String meta(String key) { if (metadata.isEmpty() || key == null) { return null; } String normalized = key.trim().toLowerCase(Locale.ROOT); for (MetadataEntry entry : metadata) { if (entry != null && normalized.equals(entry.key)) { return entry.value; } } return null; } } private static final class MetadataEntry { private final String key; private final String value; private MetadataEntry(String key, String value) { this.key = key; this.value = value; } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CodeCommand.java ================================================ package io.github.lnyocly.ai4j.cli.command; import io.github.lnyocly.ai4j.cli.render.CliAnsi; import io.github.lnyocly.ai4j.cli.CliUiMode; import io.github.lnyocly.ai4j.cli.SlashCommandController; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.factory.CodingCliTuiFactory; import io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliTuiFactory; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.runtime.CodingCliSessionRunner; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager; import io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.FileSessionEventStore; import io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore; import io.github.lnyocly.ai4j.cli.shell.JlineCodeCommandRunner; import io.github.lnyocly.ai4j.cli.shell.JlineShellContext; import io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.tui.JlineTerminalIO; import io.github.lnyocly.ai4j.tui.StreamsTerminalIO; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Map; import java.util.Properties; public class CodeCommand { private enum InteractiveBackend { LEGACY, JLINE } private final CodingCliAgentFactory agentFactory; private final CodingCliTuiFactory tuiFactory; private final Map env; private final Properties properties; private final Path currentDirectory; private final CodeCommandOptionsParser parser = new CodeCommandOptionsParser(); public CodeCommand(CodingCliAgentFactory agentFactory, Map env, Properties properties, Path currentDirectory) { this(agentFactory, new DefaultCodingCliTuiFactory(), env, properties, currentDirectory); } public CodeCommand(CodingCliAgentFactory agentFactory, CodingCliTuiFactory tuiFactory, Map env, Properties properties, Path currentDirectory) { this.agentFactory = agentFactory; this.tuiFactory = tuiFactory == null ? new DefaultCodingCliTuiFactory() : tuiFactory; this.env = env; this.properties = properties; this.currentDirectory = currentDirectory; } public int run(List args, TerminalIO terminal) { CodeCommandOptions options = null; TerminalIO runtimeTerminal = terminal; JlineShellContext shellContext = null; SlashCommandController slashCommandController = null; try { options = parser.parse(args, env, properties, currentDirectory); if (options.isHelp()) { printHelp(terminal); return 0; } CodingSessionManager sessionManager = createSessionManager(options); InteractiveBackend interactiveBackend = resolveInteractiveBackend(options, terminal); if (interactiveBackend == InteractiveBackend.JLINE) { closeQuietly(terminal); slashCommandController = new SlashCommandController( new CustomCommandRegistry(Paths.get(options.getWorkspace())), new io.github.lnyocly.ai4j.tui.TuiConfigManager(Paths.get(options.getWorkspace())) ); shellContext = JlineShellContext.openSystem(slashCommandController); runtimeTerminal = new JlineShellTerminalIO(shellContext, slashCommandController); } TuiInteractionState interactionState = new TuiInteractionState(); CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare( options, runtimeTerminal, interactionState, java.util.Collections.emptySet() ); printMcpStartupWarnings(runtimeTerminal, prepared.getMcpRuntimeManager()); CodingAgent agent = prepared.getAgent(); if (interactiveBackend == InteractiveBackend.JLINE) { JlineCodeCommandRunner runner = new JlineCodeCommandRunner( agent, prepared.getProtocol(), prepared.getMcpRuntimeManager(), options, runtimeTerminal, sessionManager, interactionState, shellContext, slashCommandController, agentFactory, env, properties ); shellContext = null; return runner.runCommand(); } CodingCliSessionRunner runner = new CodingCliSessionRunner(agent, prepared.getProtocol(), options, runtimeTerminal, sessionManager, interactionState, tuiFactory, null, agentFactory, env, properties); runner.setMcpRuntimeManager(prepared.getMcpRuntimeManager()); return runner.run(); } catch (IllegalArgumentException ex) { terminal.errorln("Argument error: " + ex.getMessage()); printHelp(terminal); return 2; } catch (Exception ex) { terminal.errorln("CLI failed: " + safeMessage(ex)); if (options != null && options.isVerbose()) { terminal.errorln(stackTraceOf(ex)); } return 1; } finally { if (shellContext != null) { try { shellContext.close(); } catch (Exception ignored) { } } } } private void printHelp(TerminalIO terminal) { terminal.println("ai4j-cli code"); terminal.println(" Start a minimal coding session in one-shot or interactive REPL mode.\n"); terminal.println("Usage:"); terminal.println(" ai4j-cli code --model [options]"); terminal.println(" ai4j-cli code --model --prompt \"Fix the failing tests in this workspace\"\n"); terminal.println("Core options:"); terminal.println(" --ui Interaction mode, default: cli"); terminal.println(" --provider Provider name, default: openai"); terminal.println(" --protocol Protocol family; provider default is used when omitted"); terminal.println(" --model Model name, required unless config provides one"); terminal.println(" --api-key API key, or use env/config provider profiles"); terminal.println(" --base-url Custom baseUrl for OpenAI-compatible providers"); terminal.println(" --workspace Workspace root, default: current directory"); terminal.println(" --workspace-description Extra workspace description"); terminal.println(" --prompt One-shot mode; omit to enter interactive mode"); terminal.println(" --system Additional system prompt"); terminal.println(" --instructions Additional instructions"); terminal.println(" --theme TUI theme name or override"); terminal.println(" --approval Tool approval strategy, default: auto"); terminal.println(" --session-id Use a fixed session id for a new session"); terminal.println(" --resume Resume a saved session"); terminal.println(" --fork Fork a saved session into a new branch"); terminal.println(" --no-session Keep session state in memory only"); terminal.println(" --session-dir Session store directory, default: /.ai4j/sessions\n"); terminal.println("Advanced options:"); terminal.println(" --max-steps Default: unlimited (0)"); terminal.println(" --temperature "); terminal.println(" --top-p "); terminal.println(" --max-output-tokens "); terminal.println(" --parallel-tool-calls Default: false"); terminal.println(" --auto-save-session Default: true"); terminal.println(" --auto-compact Default: true"); terminal.println(" --compact-context-window-tokens Default: 128000"); terminal.println(" --compact-reserve-tokens Default: 16384"); terminal.println(" --compact-keep-recent-tokens Default: 20000"); terminal.println(" --compact-summary-max-output-tokens Default: 400"); terminal.println(" --allow-outside-workspace Allow explicit paths outside the workspace"); terminal.println(" --verbose Enable detailed CLI logs\n"); terminal.println("Environment variables:"); terminal.println(" AI4J_UI, AI4J_PROVIDER, AI4J_PROTOCOL, AI4J_MODEL, AI4J_API_KEY, AI4J_BASE_URL"); terminal.println(" AI4J_WORKSPACE, AI4J_SYSTEM_PROMPT, AI4J_INSTRUCTIONS, AI4J_PROMPT"); terminal.println(" AI4J_SESSION_ID, AI4J_RESUME_SESSION, AI4J_FORK_SESSION, AI4J_NO_SESSION, AI4J_SESSION_DIR, AI4J_THEME, AI4J_APPROVAL, AI4J_AUTO_SAVE_SESSION"); terminal.println(" AI4J_AUTO_COMPACT, AI4J_COMPACT_CONTEXT_WINDOW_TOKENS, AI4J_COMPACT_RESERVE_TOKENS"); terminal.println(" AI4J_COMPACT_KEEP_RECENT_TOKENS, AI4J_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS"); terminal.println(" Provider-specific keys also work, for example ZHIPU_API_KEY / OPENAI_API_KEY\n"); terminal.println("Interactive commands:"); terminal.println(" /help Show in-session help"); terminal.println(" /status Show current session status"); terminal.println(" /session Show current session metadata"); terminal.println(" /theme [name] Show or switch the active TUI theme"); terminal.println(" /save Persist the current session state"); terminal.println(" /providers List saved provider profiles"); terminal.println(" /provider Show current provider/profile state"); terminal.println(" /provider use Switch workspace to a saved provider profile"); terminal.println(" /provider save Save the current runtime as a provider profile"); terminal.println(" /provider add [options] Create a provider profile from explicit fields"); terminal.println(" /provider edit [options] Update a saved provider profile"); terminal.println(" /provider default Set or clear the global default profile"); terminal.println(" /provider remove Delete a saved provider profile"); terminal.println(" /model Show current model/profile state"); terminal.println(" /model Save a workspace model override"); terminal.println(" /model reset Clear the workspace model override"); terminal.println(" /skills [name] List discovered coding skills or inspect one skill"); terminal.println(" /commands List custom command templates"); terminal.println(" /palette Alias of /commands"); terminal.println(" /cmd [args] Run a custom command template"); terminal.println(" /sessions List saved sessions in the current store"); terminal.println(" /history [id] Show session lineage from root to target"); terminal.println(" /tree [id] Show the saved session tree"); terminal.println(" /events [n] Show the latest session ledger events"); terminal.println(" /replay [n] Replay recent turns from the event ledger"); terminal.println(" /team Show the current agent team board by member lane"); terminal.println(" /team list|status [team-id]|messages [team-id] [limit]|resume [team-id] Manage persisted team snapshots"); terminal.println(" /stream [on|off] Show or switch model request streaming"); terminal.println(" /processes List active and restored process metadata"); terminal.println(" /process status Show metadata for one process"); terminal.println(" /process follow [limit] Show metadata with buffered logs"); terminal.println(" /process logs [limit] Read buffered process logs"); terminal.println(" /process write Write to process stdin"); terminal.println(" /process stop Stop a live process"); terminal.println(" /checkpoint Show the current structured checkpoint summary"); terminal.println(" /resume Resume a saved session"); terminal.println(" /load Alias of /resume"); terminal.println(" /fork [new-id] or /fork Fork a session branch"); terminal.println(" /compact [summary] Compact current session memory"); terminal.println(" /clear Print a new screen section"); terminal.println(" /exit Exit the session"); terminal.println(" /quit Exit the session"); terminal.println(" TUI keys: / opens command list, Tab accepts completion, Ctrl+P palette, Ctrl+R replay, /team opens the team board, Enter submit, Esc interrupts an active raw-TUI turn or clears input\n"); terminal.println("Examples:"); terminal.println(" ai4j-cli code --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace ."); terminal.println(" ai4j-cli code --ui tui --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace ."); terminal.println(" ai4j-cli code --provider openai --protocol responses --model gpt-5-mini --prompt \"Read README and summarize the project structure\""); terminal.println(" ai4j-cli code --provider openai --base-url https://api.deepseek.com --protocol chat --model deepseek-chat"); } private void printMcpStartupWarnings(TerminalIO terminal, CliMcpRuntimeManager runtimeManager) { if (terminal == null || runtimeManager == null) { return; } List warnings = runtimeManager.buildStartupWarnings(); if (warnings == null || warnings.isEmpty()) { return; } boolean ansi = terminal.supportsAnsi(); for (String warning : warnings) { terminal.println(CliAnsi.warning("Warning: " + safeMessage(warning), ansi)); } } private String safeMessage(Throwable throwable) { String message = throwable == null ? null : throwable.getMessage(); return message == null || message.trim().isEmpty() ? throwable.getClass().getSimpleName() : message; } private String safeMessage(String value) { return value == null || value.trim().isEmpty() ? "unknown warning" : value.trim(); } private String stackTraceOf(Throwable throwable) { StringWriter writer = new StringWriter(); PrintWriter printWriter = new PrintWriter(writer); throwable.printStackTrace(printWriter); printWriter.flush(); return writer.toString(); } private CodingSessionManager createSessionManager(CodeCommandOptions options) { if (options.isNoSession()) { Path directory = Paths.get(options.getWorkspace()).resolve(".ai4j").resolve("memory-sessions"); return new DefaultCodingSessionManager( new InMemoryCodingSessionStore(directory), new InMemorySessionEventStore() ); } Path sessionDirectory = Paths.get(options.getSessionStoreDir()); return new DefaultCodingSessionManager( new FileCodingSessionStore(sessionDirectory), new FileSessionEventStore(sessionDirectory.resolve("events")) ); } private InteractiveBackend resolveInteractiveBackend(CodeCommandOptions options, TerminalIO terminal) { if (options == null || terminal == null) { return InteractiveBackend.LEGACY; } if (options.getUiMode() != CliUiMode.TUI || !isBlank(options.getPrompt())) { return InteractiveBackend.LEGACY; } if (!(terminal instanceof JlineTerminalIO)) { return InteractiveBackend.LEGACY; } String backend = firstNonBlank( System.getProperty("ai4j.tui.backend"), env == null ? null : env.get("AI4J_TUI_BACKEND") ); if (isBlank(backend)) { return InteractiveBackend.JLINE; } String normalized = backend.trim().toLowerCase(); if ("legacy".equals(normalized) || "append-only".equals(normalized)) { return InteractiveBackend.LEGACY; } return InteractiveBackend.JLINE; } private void closeQuietly(TerminalIO terminal) { if (terminal == null) { return; } try { terminal.close(); } catch (Exception ignored) { } } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CodeCommandOptions.java ================================================ package io.github.lnyocly.ai4j.cli.command; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.CliUiMode; import io.github.lnyocly.ai4j.service.PlatformType; public final class CodeCommandOptions { private static final ApprovalMode DEFAULT_APPROVAL_MODE = ApprovalMode.AUTO; private static final boolean DEFAULT_NO_SESSION = true; private static final boolean DEFAULT_AUTO_SAVE_SESSION = true; private static final boolean DEFAULT_AUTO_COMPACT = true; private static final int DEFAULT_COMPACT_CONTEXT_WINDOW_TOKENS = 128000; private static final int DEFAULT_COMPACT_RESERVE_TOKENS = 16384; private static final int DEFAULT_COMPACT_KEEP_RECENT_TOKENS = 20000; private static final int DEFAULT_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS = 400; private static final boolean DEFAULT_STREAM = true; private final boolean help; private final CliUiMode uiMode; private final PlatformType provider; private final CliProtocol protocol; private final String model; private final String apiKey; private final String baseUrl; private final String workspace; private final String workspaceDescription; private final String systemPrompt; private final String instructions; private final String prompt; private final int maxSteps; private final Double temperature; private final Double topP; private final Integer maxOutputTokens; private final Boolean parallelToolCalls; private final boolean allowOutsideWorkspace; private final String sessionId; private final String resumeSessionId; private final String forkSessionId; private final String sessionStoreDir; private final String theme; private final ApprovalMode approvalMode; private final boolean noSession; private final boolean autoSaveSession; private final boolean autoCompact; private final int compactContextWindowTokens; private final int compactReserveTokens; private final int compactKeepRecentTokens; private final int compactSummaryMaxOutputTokens; private final boolean stream; private final boolean verbose; public CodeCommandOptions(boolean help, CliUiMode uiMode, PlatformType provider, CliProtocol protocol, String model, String apiKey, String baseUrl, String workspace, String workspaceDescription, String systemPrompt, String instructions, String prompt, int maxSteps, Double temperature, Double topP, Integer maxOutputTokens, Boolean parallelToolCalls, boolean allowOutsideWorkspace, boolean verbose) { this(help, uiMode, provider, protocol, model, apiKey, baseUrl, workspace, workspaceDescription, systemPrompt, instructions, prompt, maxSteps, temperature, topP, maxOutputTokens, parallelToolCalls, allowOutsideWorkspace, null, null, null, null, null, DEFAULT_APPROVAL_MODE, DEFAULT_NO_SESSION, DEFAULT_AUTO_SAVE_SESSION, DEFAULT_AUTO_COMPACT, DEFAULT_COMPACT_CONTEXT_WINDOW_TOKENS, DEFAULT_COMPACT_RESERVE_TOKENS, DEFAULT_COMPACT_KEEP_RECENT_TOKENS, DEFAULT_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS, DEFAULT_STREAM, verbose); } public CodeCommandOptions(boolean help, CliUiMode uiMode, PlatformType provider, CliProtocol protocol, String model, String apiKey, String baseUrl, String workspace, String workspaceDescription, String systemPrompt, String instructions, String prompt, int maxSteps, Double temperature, Double topP, Integer maxOutputTokens, Boolean parallelToolCalls, boolean allowOutsideWorkspace, String sessionId, String resumeSessionId, String sessionStoreDir, String theme, boolean autoSaveSession, boolean autoCompact, int compactContextWindowTokens, int compactReserveTokens, int compactKeepRecentTokens, int compactSummaryMaxOutputTokens, boolean verbose) { this(help, uiMode, provider, protocol, model, apiKey, baseUrl, workspace, workspaceDescription, systemPrompt, instructions, prompt, maxSteps, temperature, topP, maxOutputTokens, parallelToolCalls, allowOutsideWorkspace, sessionId, resumeSessionId, null, sessionStoreDir, theme, DEFAULT_APPROVAL_MODE, DEFAULT_NO_SESSION, autoSaveSession, autoCompact, compactContextWindowTokens, compactReserveTokens, compactKeepRecentTokens, compactSummaryMaxOutputTokens, false, verbose); } public CodeCommandOptions(boolean help, CliUiMode uiMode, PlatformType provider, CliProtocol protocol, String model, String apiKey, String baseUrl, String workspace, String workspaceDescription, String systemPrompt, String instructions, String prompt, int maxSteps, Double temperature, Double topP, Integer maxOutputTokens, Boolean parallelToolCalls, boolean allowOutsideWorkspace, String sessionId, String resumeSessionId, String forkSessionId, String sessionStoreDir, String theme, ApprovalMode approvalMode, boolean noSession, boolean autoSaveSession, boolean autoCompact, int compactContextWindowTokens, int compactReserveTokens, int compactKeepRecentTokens, int compactSummaryMaxOutputTokens, boolean verbose) { this(help, uiMode, provider, protocol, model, apiKey, baseUrl, workspace, workspaceDescription, systemPrompt, instructions, prompt, maxSteps, temperature, topP, maxOutputTokens, parallelToolCalls, allowOutsideWorkspace, sessionId, resumeSessionId, forkSessionId, sessionStoreDir, theme, approvalMode, noSession, autoSaveSession, autoCompact, compactContextWindowTokens, compactReserveTokens, compactKeepRecentTokens, compactSummaryMaxOutputTokens, false, verbose); } private CodeCommandOptions(boolean help, CliUiMode uiMode, PlatformType provider, CliProtocol protocol, String model, String apiKey, String baseUrl, String workspace, String workspaceDescription, String systemPrompt, String instructions, String prompt, int maxSteps, Double temperature, Double topP, Integer maxOutputTokens, Boolean parallelToolCalls, boolean allowOutsideWorkspace, String sessionId, String resumeSessionId, String forkSessionId, String sessionStoreDir, String theme, ApprovalMode approvalMode, boolean noSession, boolean autoSaveSession, boolean autoCompact, int compactContextWindowTokens, int compactReserveTokens, int compactKeepRecentTokens, int compactSummaryMaxOutputTokens, boolean stream, boolean verbose) { this.help = help; this.uiMode = uiMode; this.provider = provider; this.protocol = protocol; this.model = model; this.apiKey = apiKey; this.baseUrl = baseUrl; this.workspace = workspace; this.workspaceDescription = workspaceDescription; this.systemPrompt = systemPrompt; this.instructions = instructions; this.prompt = prompt; this.maxSteps = maxSteps; this.temperature = temperature; this.topP = topP; this.maxOutputTokens = maxOutputTokens; this.parallelToolCalls = parallelToolCalls; this.allowOutsideWorkspace = allowOutsideWorkspace; this.sessionId = sessionId; this.resumeSessionId = resumeSessionId; this.forkSessionId = forkSessionId; this.sessionStoreDir = sessionStoreDir; this.theme = theme; this.approvalMode = approvalMode == null ? DEFAULT_APPROVAL_MODE : approvalMode; this.noSession = noSession; this.autoSaveSession = autoSaveSession; this.autoCompact = autoCompact; this.compactContextWindowTokens = compactContextWindowTokens; this.compactReserveTokens = compactReserveTokens; this.compactKeepRecentTokens = compactKeepRecentTokens; this.compactSummaryMaxOutputTokens = compactSummaryMaxOutputTokens; this.stream = stream; this.verbose = verbose; } public boolean isHelp() { return help; } public CliUiMode getUiMode() { return uiMode; } public PlatformType getProvider() { return provider; } public CliProtocol getProtocol() { return protocol; } public String getModel() { return model; } public String getApiKey() { return apiKey; } public String getBaseUrl() { return baseUrl; } public String getWorkspace() { return workspace; } public String getWorkspaceDescription() { return workspaceDescription; } public String getSystemPrompt() { return systemPrompt; } public String getInstructions() { return instructions; } public String getPrompt() { return prompt; } public int getMaxSteps() { return maxSteps; } public Double getTemperature() { return temperature; } public Double getTopP() { return topP; } public Integer getMaxOutputTokens() { return maxOutputTokens; } public Boolean getParallelToolCalls() { return parallelToolCalls; } public boolean isAllowOutsideWorkspace() { return allowOutsideWorkspace; } public String getSessionId() { return sessionId; } public String getResumeSessionId() { return resumeSessionId; } public String getForkSessionId() { return forkSessionId; } public String getSessionStoreDir() { return sessionStoreDir; } public String getTheme() { return theme; } public ApprovalMode getApprovalMode() { return approvalMode; } public boolean isNoSession() { return noSession; } public boolean isAutoSaveSession() { return autoSaveSession; } public boolean isAutoCompact() { return autoCompact; } public int getCompactContextWindowTokens() { return compactContextWindowTokens; } public int getCompactReserveTokens() { return compactReserveTokens; } public int getCompactKeepRecentTokens() { return compactKeepRecentTokens; } public int getCompactSummaryMaxOutputTokens() { return compactSummaryMaxOutputTokens; } public boolean isStream() { return stream; } public boolean isVerbose() { return verbose; } public CodeCommandOptions withRuntime(PlatformType provider, CliProtocol protocol, String model, String apiKey, String baseUrl) { return copy( provider, protocol, model, apiKey, baseUrl, workspace, sessionId, resumeSessionId, sessionStoreDir, approvalMode, stream ); } public CodeCommandOptions withStream(boolean stream) { return copy( provider, protocol, model, apiKey, baseUrl, workspace, sessionId, resumeSessionId, sessionStoreDir, approvalMode, stream ); } public CodeCommandOptions withApprovalMode(ApprovalMode approvalMode) { return copy( provider, protocol, model, apiKey, baseUrl, workspace, sessionId, resumeSessionId, sessionStoreDir, approvalMode, stream ); } public CodeCommandOptions withSessionContext(String workspace, String sessionId, String resumeSessionId, String sessionStoreDir) { return copy( provider, protocol, model, apiKey, baseUrl, workspace, sessionId, resumeSessionId, sessionStoreDir, approvalMode, stream ); } private CodeCommandOptions copy(PlatformType provider, CliProtocol protocol, String model, String apiKey, String baseUrl, String workspace, String sessionId, String resumeSessionId, String sessionStoreDir, ApprovalMode approvalMode, boolean stream) { return new CodeCommandOptions( help, uiMode, provider, protocol, model, apiKey, baseUrl, workspace, workspaceDescription, systemPrompt, instructions, prompt, maxSteps, temperature, topP, maxOutputTokens, parallelToolCalls, allowOutsideWorkspace, sessionId, resumeSessionId, forkSessionId, sessionStoreDir, theme, approvalMode == null ? this.approvalMode : approvalMode, noSession, autoSaveSession, autoCompact, compactContextWindowTokens, compactReserveTokens, compactKeepRecentTokens, compactSummaryMaxOutputTokens, stream, verbose ); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CodeCommandOptionsParser.java ================================================ package io.github.lnyocly.ai4j.cli.command; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.CliUiMode; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig; import io.github.lnyocly.ai4j.service.PlatformType; import java.nio.file.Path; import java.nio.file.Paths; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; public class CodeCommandOptionsParser { public CodeCommandOptions parse(List args, Map env, Properties properties, Path currentDirectory) { Map values = new LinkedHashMap(); boolean help = false; boolean allowOutsideWorkspace = false; boolean noSession = false; boolean noSessionSpecified = false; boolean verbose = false; if (args != null) { for (int i = 0; i < args.size(); i++) { String arg = args.get(i); if ("-h".equals(arg) || "--help".equals(arg)) { help = true; continue; } if (!arg.startsWith("--")) { throw new IllegalArgumentException("Unsupported argument: " + arg); } String option = arg.substring(2); String value = null; int equalsIndex = option.indexOf('='); if (equalsIndex >= 0) { value = option.substring(equalsIndex + 1); option = option.substring(0, equalsIndex); } if ("allow-outside-workspace".equals(option)) { if (value == null && i + 1 < args.size() && !args.get(i + 1).startsWith("--")) { value = args.get(++i); } allowOutsideWorkspace = value == null || parseBoolean(value); continue; } if ("no-session".equals(option)) { if (value == null && i + 1 < args.size() && !args.get(i + 1).startsWith("--")) { value = args.get(++i); } noSession = value == null || parseBoolean(value); noSessionSpecified = true; continue; } if ("verbose".equals(option)) { if (value == null && i + 1 < args.size() && !args.get(i + 1).startsWith("--")) { value = args.get(++i); } verbose = value == null || parseBoolean(value); continue; } if (value == null) { if (i + 1 >= args.size() || args.get(i + 1).startsWith("--")) { throw new IllegalArgumentException("Missing value for --" + option); } value = args.get(++i); } values.put(normalizeOptionName(option), value); } } if (help) { return new CodeCommandOptions( true, resolveUiMode(values, env, properties, CliUiMode.CLI), PlatformType.OPENAI, CliProtocol.defaultProtocol(PlatformType.OPENAI, null), null, null, null, currentDirectory.toAbsolutePath().normalize().toString(), null, null, null, null, 0, null, null, null, Boolean.FALSE, allowOutsideWorkspace, null, null, null, null, true, true, 128000, 16384, 20000, 400, verbose ); } CliUiMode uiMode = resolveUiMode(values, env, properties, CliUiMode.CLI); String cliProtocolOverride = values.get("protocol"); if ("auto".equalsIgnoreCase(trimToNull(cliProtocolOverride))) { throw new IllegalArgumentException("Unsupported protocol: auto. Expected: chat, responses"); } String workspace = firstNonBlank( values.get("workspace"), envValue(env, "AI4J_WORKSPACE"), propertyValue(properties, "ai4j.workspace"), currentDirectory.toAbsolutePath().normalize().toString() ); CliProviderConfigManager providerConfigManager = new CliProviderConfigManager(Paths.get(workspace)); CliResolvedProviderConfig resolvedProviderConfig = providerConfigManager.resolve( values.get("provider"), values.get("protocol"), values.get("model"), values.get("api-key"), values.get("base-url"), env, properties ); PlatformType provider = resolvedProviderConfig.getProvider(); String model = resolvedProviderConfig.getModel(); if (isBlank(model)) { throw new IllegalArgumentException("model is required"); } CliProtocol protocol = resolvedProviderConfig.getProtocol(); String sessionId = firstNonBlank( values.get("session-id"), envValue(env, "AI4J_SESSION_ID"), propertyValue(properties, "ai4j.session.id") ); String resumeSessionId = firstNonBlank( values.get("resume"), values.get("load"), envValue(env, "AI4J_RESUME_SESSION"), propertyValue(properties, "ai4j.session.resume") ); String forkSessionId = firstNonBlank( values.get("fork"), envValue(env, "AI4J_FORK_SESSION"), propertyValue(properties, "ai4j.session.fork") ); if (!noSessionSpecified) { noSession = parseBooleanOrDefault(firstNonBlank( envValue(env, "AI4J_NO_SESSION"), propertyValue(properties, "ai4j.session.no-session") ), false); } String sessionStoreDir = firstNonBlank( values.get("session-dir"), envValue(env, "AI4J_SESSION_DIR"), propertyValue(properties, "ai4j.session.dir"), Paths.get(workspace).resolve(".ai4j").resolve("sessions").toAbsolutePath().normalize().toString() ); String theme = firstNonBlank( values.get("theme"), envValue(env, "AI4J_THEME"), propertyValue(properties, "ai4j.theme") ); ApprovalMode approvalMode = ApprovalMode.parse(firstNonBlank( values.get("approval"), envValue(env, "AI4J_APPROVAL"), propertyValue(properties, "ai4j.approval"), ApprovalMode.AUTO.getValue() )); String apiKey = resolvedProviderConfig.getApiKey(); String baseUrl = resolvedProviderConfig.getBaseUrl(); String workspaceDescription = firstNonBlank( values.get("workspace-description"), envValue(env, "AI4J_WORKSPACE_DESCRIPTION"), propertyValue(properties, "ai4j.workspace.description") ); String systemPrompt = firstNonBlank( values.get("system"), envValue(env, "AI4J_SYSTEM_PROMPT"), propertyValue(properties, "ai4j.system") ); String instructions = firstNonBlank( values.get("instructions"), envValue(env, "AI4J_INSTRUCTIONS"), propertyValue(properties, "ai4j.instructions") ); String prompt = firstNonBlank( values.get("prompt"), envValue(env, "AI4J_PROMPT"), propertyValue(properties, "ai4j.prompt") ); int maxSteps = parseInteger(firstNonBlank( values.get("max-steps"), envValue(env, "AI4J_MAX_STEPS"), propertyValue(properties, "ai4j.max.steps"), "0" ), "max-steps"); Double temperature = parseDoubleOrNull(firstNonBlank( values.get("temperature"), envValue(env, "AI4J_TEMPERATURE"), propertyValue(properties, "ai4j.temperature") ), "temperature"); Double topP = parseDoubleOrNull(firstNonBlank( values.get("top-p"), envValue(env, "AI4J_TOP_P"), propertyValue(properties, "ai4j.top.p") ), "top-p"); Integer maxOutputTokens = parseIntegerOrNull(firstNonBlank( values.get("max-output-tokens"), envValue(env, "AI4J_MAX_OUTPUT_TOKENS"), propertyValue(properties, "ai4j.max.output.tokens") ), "max-output-tokens"); Boolean parallelToolCalls = parseBooleanOrDefault(firstNonBlank( values.get("parallel-tool-calls"), envValue(env, "AI4J_PARALLEL_TOOL_CALLS"), propertyValue(properties, "ai4j.parallel.tool.calls") ), false); boolean stream = parseBooleanOrDefault(firstNonBlank( values.get("stream"), envValue(env, "AI4J_STREAM"), propertyValue(properties, "ai4j.stream") ), true); boolean autoSaveSession = parseBooleanOrDefault(firstNonBlank( values.get("auto-save-session"), envValue(env, "AI4J_AUTO_SAVE_SESSION"), propertyValue(properties, "ai4j.session.auto-save") ), true); boolean autoCompact = parseBooleanOrDefault(firstNonBlank( values.get("auto-compact"), envValue(env, "AI4J_AUTO_COMPACT"), propertyValue(properties, "ai4j.compact.auto") ), true); int compactContextWindowTokens = parseInteger(firstNonBlank( values.get("compact-context-window-tokens"), envValue(env, "AI4J_COMPACT_CONTEXT_WINDOW_TOKENS"), propertyValue(properties, "ai4j.compact.context-window-tokens"), "128000" ), "compact-context-window-tokens"); int compactReserveTokens = parseInteger(firstNonBlank( values.get("compact-reserve-tokens"), envValue(env, "AI4J_COMPACT_RESERVE_TOKENS"), propertyValue(properties, "ai4j.compact.reserve-tokens"), "16384" ), "compact-reserve-tokens"); int compactKeepRecentTokens = parseInteger(firstNonBlank( values.get("compact-keep-recent-tokens"), envValue(env, "AI4J_COMPACT_KEEP_RECENT_TOKENS"), propertyValue(properties, "ai4j.compact.keep-recent-tokens"), "20000" ), "compact-keep-recent-tokens"); int compactSummaryMaxOutputTokens = parseInteger(firstNonBlank( values.get("compact-summary-max-output-tokens"), envValue(env, "AI4J_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS"), propertyValue(properties, "ai4j.compact.summary-max-output-tokens"), "400" ), "compact-summary-max-output-tokens"); if (!isBlank(resumeSessionId) && !isBlank(forkSessionId)) { throw new IllegalArgumentException("--resume and --fork cannot be used together"); } if (noSession && !isBlank(resumeSessionId)) { throw new IllegalArgumentException("--no-session cannot be combined with --resume"); } if (noSession && !isBlank(forkSessionId)) { throw new IllegalArgumentException("--no-session cannot be combined with --fork"); } return new CodeCommandOptions( false, uiMode, provider, protocol, model, apiKey, baseUrl, workspace, workspaceDescription, systemPrompt, instructions, prompt, maxSteps, temperature, topP, maxOutputTokens, parallelToolCalls, allowOutsideWorkspace, sessionId, resumeSessionId, forkSessionId, sessionStoreDir, theme, approvalMode, noSession, autoSaveSession, autoCompact, compactContextWindowTokens, compactReserveTokens, compactKeepRecentTokens, compactSummaryMaxOutputTokens, verbose ).withStream(stream); } private CliUiMode resolveUiMode(Map values, Map env, Properties properties, CliUiMode defaultValue) { return CliUiMode.parse(firstNonBlank( values.get("ui"), envValue(env, "AI4J_UI"), propertyValue(properties, "ai4j.ui"), defaultValue.getValue() )); } private String envValue(Map env, String name) { return env == null ? null : env.get(name); } private String propertyValue(Properties properties, String name) { return properties == null ? null : properties.getProperty(name); } private String normalizeOptionName(String option) { if (option == null) { return null; } return option.trim().toLowerCase(Locale.ROOT); } private int parseInteger(String value, String name) { try { return Integer.parseInt(value); } catch (NumberFormatException ex) { throw new IllegalArgumentException("Invalid integer for --" + name + ": " + value, ex); } } private Integer parseIntegerOrNull(String value, String name) { if (isBlank(value)) { return null; } return parseInteger(value, name); } private Double parseDoubleOrNull(String value, String name) { if (isBlank(value)) { return null; } try { return Double.parseDouble(value); } catch (NumberFormatException ex) { throw new IllegalArgumentException("Invalid number for --" + name + ": " + value, ex); } } private Boolean parseBooleanOrDefault(String value, boolean defaultValue) { if (isBlank(value)) { return defaultValue; } return parseBoolean(value); } private boolean parseBoolean(String value) { if ("true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value)) { return true; } if ("false".equalsIgnoreCase(value) || "0".equals(value) || "no".equalsIgnoreCase(value)) { return false; } throw new IllegalArgumentException("Invalid boolean value: " + value); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String trimToNull(String value) { return isBlank(value) ? null : value.trim(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CustomCommandRegistry.java ================================================ package io.github.lnyocly.ai4j.cli.command; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class CustomCommandRegistry { private final Path workspaceRoot; public CustomCommandRegistry(Path workspaceRoot) { this.workspaceRoot = workspaceRoot; } public List list() { Map commands = new LinkedHashMap(); loadDirectory(homeCommandsDirectory(), commands); loadDirectory(workspaceCommandsDirectory(), commands); List values = new ArrayList(commands.values()); Collections.sort(values, java.util.Comparator.comparing(CustomCommandTemplate::getName)); return values; } public CustomCommandTemplate find(String name) { if (isBlank(name)) { return null; } for (CustomCommandTemplate command : list()) { if (name.equalsIgnoreCase(command.getName())) { return command; } } return null; } private void loadDirectory(Path directory, Map commands) { if (directory == null || !Files.isDirectory(directory)) { return; } try (DirectoryStream stream = Files.newDirectoryStream(directory)) { for (Path file : stream) { if (!hasSupportedExtension(file)) { continue; } CustomCommandTemplate command = loadTemplate(file); if (command != null) { commands.put(command.getName(), command); } } } catch (IOException ignored) { } } private CustomCommandTemplate loadTemplate(Path file) { if (file == null || !Files.isRegularFile(file)) { return null; } try { String raw = new String(Files.readAllBytes(file), StandardCharsets.UTF_8).trim(); if (raw.isEmpty()) { return null; } String fileName = file.getFileName().toString(); int dot = fileName.lastIndexOf('.'); String name = dot > 0 ? fileName.substring(0, dot) : fileName; String description = null; String template = raw; int newline = raw.indexOf('\n'); if (raw.startsWith("#") && newline > 0) { description = raw.substring(1, newline).trim(); template = raw.substring(newline + 1).trim(); } return new CustomCommandTemplate(name, description, template, file.toAbsolutePath().normalize().toString()); } catch (IOException ex) { return null; } } private Path workspaceCommandsDirectory() { return workspaceRoot == null ? null : workspaceRoot.resolve(".ai4j").resolve("commands"); } private Path homeCommandsDirectory() { String home = System.getProperty("user.home"); if (isBlank(home)) { return null; } return Paths.get(home).resolve(".ai4j").resolve("commands"); } private boolean hasSupportedExtension(Path file) { if (file == null || file.getFileName() == null) { return false; } String fileName = file.getFileName().toString().toLowerCase(); return fileName.endsWith(".md") || fileName.endsWith(".txt") || fileName.endsWith(".prompt"); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CustomCommandTemplate.java ================================================ package io.github.lnyocly.ai4j.cli.command; import java.util.Map; public class CustomCommandTemplate { private final String name; private final String description; private final String template; private final String source; public CustomCommandTemplate(String name, String description, String template, String source) { this.name = name; this.description = description; this.template = template; this.source = source; } public String getName() { return name; } public String getDescription() { return description; } public String getTemplate() { return template; } public String getSource() { return source; } public String render(Map variables) { String result = template == null ? "" : template; if (variables == null) { return result; } for (Map.Entry entry : variables.entrySet()) { String key = entry.getKey(); if (key == null) { continue; } result = result.replace("$" + key, entry.getValue() == null ? "" : entry.getValue()); } return result; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/config/CliWorkspaceConfig.java ================================================ package io.github.lnyocly.ai4j.cli.config; import java.util.ArrayList; import java.util.List; public class CliWorkspaceConfig { private String activeProfile; private String modelOverride; private Boolean experimentalSubagentsEnabled; private Boolean experimentalAgentTeamsEnabled; private List enabledMcpServers; private List skillDirectories; private List agentDirectories; public CliWorkspaceConfig() { } public CliWorkspaceConfig(String activeProfile, String modelOverride, Boolean experimentalSubagentsEnabled, Boolean experimentalAgentTeamsEnabled, List enabledMcpServers, List skillDirectories, List agentDirectories) { this.activeProfile = activeProfile; this.modelOverride = modelOverride; this.experimentalSubagentsEnabled = experimentalSubagentsEnabled; this.experimentalAgentTeamsEnabled = experimentalAgentTeamsEnabled; this.enabledMcpServers = copy(enabledMcpServers); this.skillDirectories = copy(skillDirectories); this.agentDirectories = copy(agentDirectories); } public static Builder builder() { return new Builder(); } public Builder toBuilder() { return new Builder() .activeProfile(activeProfile) .modelOverride(modelOverride) .experimentalSubagentsEnabled(experimentalSubagentsEnabled) .experimentalAgentTeamsEnabled(experimentalAgentTeamsEnabled) .enabledMcpServers(enabledMcpServers) .skillDirectories(skillDirectories) .agentDirectories(agentDirectories); } public String getActiveProfile() { return activeProfile; } public void setActiveProfile(String activeProfile) { this.activeProfile = activeProfile; } public String getModelOverride() { return modelOverride; } public void setModelOverride(String modelOverride) { this.modelOverride = modelOverride; } public Boolean getExperimentalSubagentsEnabled() { return experimentalSubagentsEnabled; } public void setExperimentalSubagentsEnabled(Boolean experimentalSubagentsEnabled) { this.experimentalSubagentsEnabled = experimentalSubagentsEnabled; } public Boolean getExperimentalAgentTeamsEnabled() { return experimentalAgentTeamsEnabled; } public void setExperimentalAgentTeamsEnabled(Boolean experimentalAgentTeamsEnabled) { this.experimentalAgentTeamsEnabled = experimentalAgentTeamsEnabled; } public List getEnabledMcpServers() { return copy(enabledMcpServers); } public void setEnabledMcpServers(List enabledMcpServers) { this.enabledMcpServers = copy(enabledMcpServers); } public List getSkillDirectories() { return copy(skillDirectories); } public void setSkillDirectories(List skillDirectories) { this.skillDirectories = copy(skillDirectories); } public List getAgentDirectories() { return copy(agentDirectories); } public void setAgentDirectories(List agentDirectories) { this.agentDirectories = copy(agentDirectories); } private List copy(List values) { return values == null ? null : new ArrayList(values); } public static final class Builder { private String activeProfile; private String modelOverride; private Boolean experimentalSubagentsEnabled; private Boolean experimentalAgentTeamsEnabled; private List enabledMcpServers; private List skillDirectories; private List agentDirectories; private Builder() { } public Builder activeProfile(String activeProfile) { this.activeProfile = activeProfile; return this; } public Builder modelOverride(String modelOverride) { this.modelOverride = modelOverride; return this; } public Builder experimentalSubagentsEnabled(Boolean experimentalSubagentsEnabled) { this.experimentalSubagentsEnabled = experimentalSubagentsEnabled; return this; } public Builder experimentalAgentTeamsEnabled(Boolean experimentalAgentTeamsEnabled) { this.experimentalAgentTeamsEnabled = experimentalAgentTeamsEnabled; return this; } public Builder enabledMcpServers(List enabledMcpServers) { this.enabledMcpServers = enabledMcpServers == null ? null : new ArrayList(enabledMcpServers); return this; } public Builder skillDirectories(List skillDirectories) { this.skillDirectories = skillDirectories == null ? null : new ArrayList(skillDirectories); return this; } public Builder agentDirectories(List agentDirectories) { this.agentDirectories = agentDirectories == null ? null : new ArrayList(agentDirectories); return this; } public CliWorkspaceConfig build() { return new CliWorkspaceConfig( activeProfile, modelOverride, experimentalSubagentsEnabled, experimentalAgentTeamsEnabled, enabledMcpServers, skillDirectories, agentDirectories ); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/CodingCliAgentFactory.java ================================================ package io.github.lnyocly.ai4j.cli.factory; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import java.util.Collection; public interface CodingCliAgentFactory { PreparedCodingAgent prepare(CodeCommandOptions options) throws Exception; default PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) throws Exception { return prepare(options); } default PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) throws Exception { return prepare(options, terminal); } default PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState, Collection pausedMcpServers) throws Exception { return prepare(options, terminal, interactionState); } final class PreparedCodingAgent { private final CodingAgent agent; private final CliProtocol protocol; private final CliMcpRuntimeManager mcpRuntimeManager; public PreparedCodingAgent(CodingAgent agent, CliProtocol protocol) { this(agent, protocol, null); } public PreparedCodingAgent(CodingAgent agent, CliProtocol protocol, CliMcpRuntimeManager mcpRuntimeManager) { this.agent = agent; this.protocol = protocol; this.mcpRuntimeManager = mcpRuntimeManager; } public CodingAgent getAgent() { return agent; } public CliProtocol getProtocol() { return protocol; } public CliMcpRuntimeManager getMcpRuntimeManager() { return mcpRuntimeManager; } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/CodingCliTuiFactory.java ================================================ package io.github.lnyocly.ai4j.cli.factory; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.runtime.CodingCliTuiSupport; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiConfigManager; public interface CodingCliTuiFactory { CodingCliTuiSupport create(CodeCommandOptions options, TerminalIO terminal, TuiConfigManager configManager); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/DefaultCodingCliAgentFactory.java ================================================ package io.github.lnyocly.ai4j.cli.factory; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.agent.CliCodingAgentRegistry; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.cli.render.CliAnsi; import io.github.lnyocly.ai4j.cli.runtime.CliToolApprovalDecorator; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.coding.CodingAgents; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.CodingAgentBuilder; import io.github.lnyocly.ai4j.coding.definition.BuiltInCodingAgentDefinitions; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.definition.CompositeCodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.prompt.CodingContextPromptAssembler; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator; import io.github.lnyocly.ai4j.config.BaichuanConfig; import io.github.lnyocly.ai4j.config.DashScopeConfig; import io.github.lnyocly.ai4j.config.DeepSeekConfig; import io.github.lnyocly.ai4j.config.DoubaoConfig; import io.github.lnyocly.ai4j.config.HunyuanConfig; import io.github.lnyocly.ai4j.config.LingyiConfig; import io.github.lnyocly.ai4j.config.MinimaxConfig; import io.github.lnyocly.ai4j.config.MoonshotConfig; import io.github.lnyocly.ai4j.config.OllamaConfig; import io.github.lnyocly.ai4j.config.OpenAiConfig; import io.github.lnyocly.ai4j.config.ZhipuConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.listener.StreamExecutionOptions; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collection; import java.util.Collections; import java.util.concurrent.TimeUnit; public class DefaultCodingCliAgentFactory implements CodingCliAgentFactory { static final long CLI_STREAM_FIRST_TOKEN_TIMEOUT_MS = 30000L; static final int CLI_STREAM_MAX_RETRIES = 2; static final long CLI_STREAM_RETRY_BACKOFF_MS = 1500L; static final String EXPERIMENTAL_SUBAGENT_TOOL_NAME = "subagent_background_worker"; static final String EXPERIMENTAL_TEAM_TOOL_NAME = "subagent_delivery_team"; static final String EXPERIMENTAL_TEAM_ID = "experimental-delivery-team"; @Override public PreparedCodingAgent prepare(CodeCommandOptions options) throws Exception { return prepare(options, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) throws Exception { return prepare(options, terminal, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) throws Exception { return prepare(options, terminal, interactionState, Collections.emptySet()); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState, Collection pausedMcpServers) throws Exception { CliProtocol protocol = resolveProtocol(options); AgentModelClient modelClient = createModelClient(options, protocol); CliMcpRuntimeManager mcpRuntimeManager = prepareMcpRuntime(options, pausedMcpServers, terminal); CodingAgent agent = buildAgent(options, terminal, interactionState, modelClient, mcpRuntimeManager); return new PreparedCodingAgent(agent, protocol, mcpRuntimeManager); } CliProtocol resolveProtocol(CodeCommandOptions options) { CliProtocol requested = options.getProtocol(); if (requested != null) { assertSupportedProtocol(options.getProvider(), requested); return requested; } CliProtocol resolved = CliProtocol.defaultProtocol(options.getProvider(), options.getBaseUrl()); assertSupportedProtocol(options.getProvider(), resolved); return resolved; } protected AgentModelClient createModelClient(CodeCommandOptions options, CliProtocol protocol) { Configuration configuration = createConfiguration(options); AiService aiService = new AiService(configuration); String runtimeBaseUrl = normalizeRuntimeBaseUrl(options); if (protocol == CliProtocol.RESPONSES) { return new ResponsesModelClient( aiService.getResponsesService(options.getProvider()), runtimeBaseUrl, options.getApiKey() ); } return new ChatModelClient( aiService.getChatService(options.getProvider()), runtimeBaseUrl, options.getApiKey() ); } private Configuration createConfiguration(CodeCommandOptions options) { Configuration configuration = new Configuration(); configuration.setOkHttpClient(createHttpClient(options.isVerbose())); applyProviderConfig(configuration, options.getProvider(), options.getBaseUrl(), options.getApiKey()); return configuration; } protected CliMcpRuntimeManager prepareMcpRuntime(CodeCommandOptions options, Collection pausedMcpServers, TerminalIO terminal) { try { return CliMcpRuntimeManager.initialize( Paths.get(options.getWorkspace()), pausedMcpServers == null ? Collections.emptySet() : pausedMcpServers ); } catch (Exception ex) { if (terminal != null) { terminal.println(CliAnsi.warning( "Warning: MCP runtime unavailable: " + safeMessage(ex), terminal.supportsAnsi() )); } return null; } } private CodingAgent buildAgent(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState, AgentModelClient modelClient, CliMcpRuntimeManager mcpRuntimeManager) { String workspace = options == null ? "." : defaultIfBlank(options.getWorkspace(), "."); CliWorkspaceConfig workspaceConfig = loadWorkspaceConfig(workspace); WorkspaceContext workspaceContext = buildWorkspaceContext(options, workspaceConfig); CodingAgentDefinitionRegistry definitionRegistry = loadDefinitionRegistry(options, workspaceConfig); CodingAgentOptions codingOptions = buildCodingOptions(options, terminal, interactionState); AgentOptions agentOptions = buildAgentOptions(options); io.github.lnyocly.ai4j.coding.CodingAgentBuilder builder = CodingAgents.builder() .modelClient(modelClient) .model(options.getModel()) .workspaceContext(workspaceContext) .definitionRegistry(definitionRegistry) .codingOptions(codingOptions) .systemPrompt(options.getSystemPrompt()) .instructions(options.getInstructions()) .temperature(options.getTemperature()) .topP(options.getTopP()) .maxOutputTokens(options.getMaxOutputTokens()) .parallelToolCalls(options.getParallelToolCalls()) .store(Boolean.FALSE) .agentOptions(agentOptions); attachMcpRuntime(builder, mcpRuntimeManager); attachExperimentalAgents(builder, options, workspaceConfig, modelClient, workspaceContext, codingOptions, agentOptions); return builder.build(); } protected ToolExecutorDecorator createToolExecutorDecorator(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) { return new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState); } WorkspaceContext buildWorkspaceContext(CodeCommandOptions options) { String workspace = options == null ? "." : defaultIfBlank(options.getWorkspace(), "."); return buildWorkspaceContext(options, loadWorkspaceConfig(workspace)); } WorkspaceContext buildWorkspaceContext(CodeCommandOptions options, CliWorkspaceConfig workspaceConfig) { String workspace = options == null ? "." : defaultIfBlank(options.getWorkspace(), "."); return WorkspaceContext.builder() .rootPath(workspace) .description(options == null ? null : options.getWorkspaceDescription()) .allowOutsideWorkspace(options != null && options.isAllowOutsideWorkspace()) .skillDirectories(workspaceConfig == null ? null : workspaceConfig.getSkillDirectories()) .build(); } private CliWorkspaceConfig loadWorkspaceConfig(String workspace) { return new CliProviderConfigManager(Paths.get(defaultIfBlank(workspace, "."))).loadWorkspaceConfig(); } CodingAgentDefinitionRegistry loadDefinitionRegistry(CodeCommandOptions options) { String workspace = options == null ? "." : defaultIfBlank(options.getWorkspace(), "."); return loadDefinitionRegistry(options, loadWorkspaceConfig(workspace)); } CodingAgentDefinitionRegistry loadDefinitionRegistry(CodeCommandOptions options, CliWorkspaceConfig workspaceConfig) { String workspace = options == null ? "." : defaultIfBlank(options.getWorkspace(), "."); CliCodingAgentRegistry customRegistry = new CliCodingAgentRegistry( Paths.get(workspace), workspaceConfig == null ? null : workspaceConfig.getAgentDirectories() ); CodingAgentDefinitionRegistry loadedRegistry = customRegistry.loadRegistry(); if (loadedRegistry == null || loadedRegistry.listDefinitions() == null || loadedRegistry.listDefinitions().isEmpty()) { return BuiltInCodingAgentDefinitions.registry(); } return new CompositeCodingAgentDefinitionRegistry( BuiltInCodingAgentDefinitions.registry(), loadedRegistry ); } private CodingAgentOptions buildCodingOptions(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) { return CodingAgentOptions.builder() .autoCompactEnabled(options.isAutoCompact()) .compactContextWindowTokens(options.getCompactContextWindowTokens()) .compactReserveTokens(options.getCompactReserveTokens()) .compactKeepRecentTokens(options.getCompactKeepRecentTokens()) .compactSummaryMaxOutputTokens(options.getCompactSummaryMaxOutputTokens()) .toolExecutorDecorator(createToolExecutorDecorator(options, terminal, interactionState)) .build(); } private AgentOptions buildAgentOptions(CodeCommandOptions options) { return AgentOptions.builder() .maxSteps(options.getMaxSteps()) .stream(options.isStream()) .streamExecution(buildStreamExecutionOptions()) .build(); } private StreamExecutionOptions buildStreamExecutionOptions() { return StreamExecutionOptions.builder() .firstTokenTimeoutMs(CLI_STREAM_FIRST_TOKEN_TIMEOUT_MS) .maxRetries(CLI_STREAM_MAX_RETRIES) .retryBackoffMs(CLI_STREAM_RETRY_BACKOFF_MS) .build(); } private void attachMcpRuntime(io.github.lnyocly.ai4j.coding.CodingAgentBuilder builder, CliMcpRuntimeManager mcpRuntimeManager) { if (mcpRuntimeManager == null || mcpRuntimeManager.getToolRegistry() == null || mcpRuntimeManager.getToolExecutor() == null) { return; } builder.toolRegistry(mcpRuntimeManager.getToolRegistry()); builder.toolExecutor(mcpRuntimeManager.getToolExecutor()); } private void attachExperimentalAgents(io.github.lnyocly.ai4j.coding.CodingAgentBuilder builder, CodeCommandOptions options, CliWorkspaceConfig workspaceConfig, AgentModelClient modelClient, WorkspaceContext workspaceContext, CodingAgentOptions codingOptions, AgentOptions agentOptions) { if (builder == null || options == null || modelClient == null || workspaceContext == null || codingOptions == null) { return; } if (isExperimentalSubagentsEnabled(workspaceConfig)) { builder.subAgent(buildBackgroundWorkerSubAgent(options, modelClient, workspaceContext, codingOptions, agentOptions)); } if (isExperimentalAgentTeamsEnabled(workspaceConfig)) { builder.subAgent(buildDeliveryTeamSubAgent(options, modelClient, workspaceContext, codingOptions, agentOptions)); } } private SubAgentDefinition buildBackgroundWorkerSubAgent(CodeCommandOptions options, AgentModelClient modelClient, WorkspaceContext workspaceContext, CodingAgentOptions codingOptions, AgentOptions agentOptions) { return SubAgentDefinition.builder() .name("background-worker") .toolName(EXPERIMENTAL_SUBAGENT_TOOL_NAME) .description("Delegate long-running shell work, repository scans, builds, test runs, and process monitoring to a focused background worker.") .agent(buildExperimentalCodingAgent( modelClient, options == null ? null : options.getModel(), workspaceContext, codingOptions, agentOptions, "You are the background worker subagent.\n" + "Use this role for scoped tasks that may take time, require repeated shell inspection, or need background process management.\n" + "Prefer bash action=start for long-running commands, then use bash action=status/logs/write/stop to drive them.\n" + "Return concise, concrete findings and avoid broad unrelated edits." )) .build(); } private SubAgentDefinition buildDeliveryTeamSubAgent(CodeCommandOptions options, AgentModelClient modelClient, WorkspaceContext workspaceContext, CodingAgentOptions codingOptions, AgentOptions agentOptions) { Path storageDirectory = resolveExperimentalTeamStorageDirectory(workspaceContext); Agent teamAgent = Agents.team() .teamId(EXPERIMENTAL_TEAM_ID) .storageDirectory(storageDirectory) .planner((objective, members, teamOptions) -> AgentTeamPlan.builder() .rawPlanText("experimental-delivery-team-plan") .tasks(Arrays.asList( AgentTeamTask.builder() .id("architecture") .memberId("architect") .task("Design the demo application architecture, delivery plan, and workspace layout.") .context("Write a concrete implementation plan. Tell backend and frontend where they should work inside this workspace. Objective: " + safeText(objective)) .build(), AgentTeamTask.builder() .id("backend") .memberId("backend") .task("Implement backend or service-side functionality for the requested demo application.") .context("Prefer backend/, server/, api/, or src/main/java style locations when creating new server-side code. Objective: " + safeText(objective)) .dependsOn(Arrays.asList("architecture")) .build(), AgentTeamTask.builder() .id("frontend") .memberId("frontend") .task("Implement frontend or client-side functionality for the requested demo application.") .context("Prefer frontend/, web/, ui/, or src/ style locations when creating new client-side code. Coordinate contracts with backend if needed. Objective: " + safeText(objective)) .dependsOn(Arrays.asList("architecture")) .build(), AgentTeamTask.builder() .id("qa") .memberId("qa") .task("Validate the resulting demo application, run tests or smoke checks, and report concrete risks.") .context("Inspect what architect/backend/frontend produced, run the most relevant verification commands, and report gaps. Objective: " + safeText(objective)) .dependsOn(Arrays.asList("backend", "frontend")) .build() )) .build()) .synthesizer((objective, plan, memberResults, teamOptions) -> AgentResult.builder() .outputText(renderDeliveryTeamSummary(objective, memberResults)) .build()) .member(teamMember( "architect", "Architect", "System design, delivery plan, workspace layout, and API boundaries.", "You are the architect in a delivery team.\n" + "Define the implementation approach, directory layout, and interface boundaries before others proceed.\n" + "Use team_send_message or team_broadcast when another member needs concrete guidance.\n" + "Do not try to finish everyone else's work yourself.", modelClient, options == null ? null : options.getModel(), workspaceContext, codingOptions, agentOptions )) .member(teamMember( "backend", "Backend", "Server-side implementation, APIs, persistence, and service integration.", "You are the backend engineer in a delivery team.\n" + "Implement the server-side portion of the task, keep changes scoped, and communicate API contracts or blockers to the frontend and QA members.\n" + "Use team_send_message when contracts, payloads, or test hooks need coordination.", modelClient, options == null ? null : options.getModel(), workspaceContext, codingOptions, agentOptions )) .member(teamMember( "frontend", "Frontend", "UI implementation, client integration, and user-facing polish.", "You are the frontend engineer in a delivery team.\n" + "Implement the client-facing portion of the task, keep the UI runnable, and coordinate API assumptions with backend and QA.\n" + "Use team_send_message when you need contract confirmation or test setup details.", modelClient, options == null ? null : options.getModel(), workspaceContext, codingOptions, agentOptions )) .member(teamMember( "qa", "QA", "Verification, test execution, regression checks, and release risk review.", "You are the QA engineer in a delivery team.\n" + "Run the most relevant verification steps, summarize failures precisely, and report concrete release risks.\n" + "Use team_send_message or team_broadcast when other members need to fix a blocker before sign-off.", modelClient, options == null ? null : options.getModel(), workspaceContext, codingOptions, agentOptions )) .options(AgentTeamOptions.builder() .parallelDispatch(true) .maxConcurrency(2) .enableMessageBus(true) .includeMessageHistoryInDispatch(true) .enableMemberTeamTools(true) .build()) .buildAgent(); return SubAgentDefinition.builder() .name("delivery-team") .toolName(EXPERIMENTAL_TEAM_TOOL_NAME) .description("Delegate medium or large implementation work to an architect/backend/frontend/qa delivery team that coordinates through team tools.") .agent(teamAgent) .build(); } private Path resolveExperimentalTeamStorageDirectory(WorkspaceContext workspaceContext) { if (workspaceContext == null || workspaceContext.getRoot() == null) { return Paths.get(".").toAbsolutePath().normalize().resolve(".ai4j").resolve("teams"); } return workspaceContext.getRoot().resolve(".ai4j").resolve("teams"); } private AgentTeamMember teamMember(String id, String name, String description, String rolePrompt, AgentModelClient modelClient, String model, WorkspaceContext workspaceContext, CodingAgentOptions codingOptions, AgentOptions agentOptions) { return AgentTeamMember.builder() .id(id) .name(name) .description(description) .agent(buildExperimentalCodingAgent( modelClient, model, workspaceContext, codingOptions, agentOptions, rolePrompt )) .build(); } private Agent buildExperimentalCodingAgent(AgentModelClient modelClient, String model, WorkspaceContext workspaceContext, CodingAgentOptions codingOptions, AgentOptions agentOptions, String systemPrompt) { SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, codingOptions); AgentToolRegistry toolRegistry = CodingAgentBuilder.createBuiltInRegistry(codingOptions); ToolExecutor toolExecutor = CodingAgentBuilder.createBuiltInToolExecutor( workspaceContext, codingOptions, processRegistry ); return Agents.react() .modelClient(modelClient) .model(model) .systemPrompt(CodingContextPromptAssembler.mergeSystemPrompt(systemPrompt, workspaceContext)) .toolRegistry(toolRegistry) .toolExecutor(toolExecutor) .options(agentOptions) .build(); } private String renderDeliveryTeamSummary(String objective, List memberResults) { Map outputs = new LinkedHashMap(); if (memberResults != null) { for (AgentTeamMemberResult memberResult : memberResults) { if (memberResult == null || isBlank(memberResult.getMemberId())) { continue; } outputs.put( memberResult.getMemberId(), memberResult.isSuccess() ? safeText(memberResult.getOutput()) : "FAILED: " + safeText(memberResult.getError()) ); } } StringBuilder builder = new StringBuilder(); builder.append("Delivery Team Summary\n"); builder.append("Objective: ").append(safeText(objective)).append("\n\n"); appendTeamMemberSummary(builder, "Architect", outputs.get("architect")); appendTeamMemberSummary(builder, "Backend", outputs.get("backend")); appendTeamMemberSummary(builder, "Frontend", outputs.get("frontend")); appendTeamMemberSummary(builder, "QA", outputs.get("qa")); return builder.toString().trim(); } private void appendTeamMemberSummary(StringBuilder builder, String name, String output) { if (builder == null) { return; } builder.append('[').append(name).append("]\n"); builder.append(safeText(output)).append("\n\n"); } public static boolean isExperimentalSubagentsEnabled(CliWorkspaceConfig workspaceConfig) { return workspaceConfig == null || workspaceConfig.getExperimentalSubagentsEnabled() == null || workspaceConfig.getExperimentalSubagentsEnabled().booleanValue(); } public static boolean isExperimentalAgentTeamsEnabled(CliWorkspaceConfig workspaceConfig) { return workspaceConfig == null || workspaceConfig.getExperimentalAgentTeamsEnabled() == null || workspaceConfig.getExperimentalAgentTeamsEnabled().booleanValue(); } private OkHttpClient createHttpClient(boolean verbose) { OkHttpClient.Builder builder = new OkHttpClient.Builder() .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS); if (verbose) { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BASIC); builder.addInterceptor(logging); } return builder.build(); } private void applyProviderConfig(Configuration configuration, PlatformType provider, String baseUrl, String apiKey) { switch (provider) { case OPENAI: OpenAiConfig openAiConfig = new OpenAiConfig(); openAiConfig.setApiHost(defaultIfBlank(baseUrl, openAiConfig.getApiHost())); openAiConfig.setApiKey(defaultIfBlank(apiKey, openAiConfig.getApiKey())); configuration.setOpenAiConfig(openAiConfig); return; case ZHIPU: ZhipuConfig zhipuConfig = new ZhipuConfig(); zhipuConfig.setApiHost(defaultIfBlank(normalizeZhipuBaseUrl(baseUrl), zhipuConfig.getApiHost())); zhipuConfig.setApiKey(defaultIfBlank(apiKey, zhipuConfig.getApiKey())); configuration.setZhipuConfig(zhipuConfig); return; case DEEPSEEK: DeepSeekConfig deepSeekConfig = new DeepSeekConfig(); deepSeekConfig.setApiHost(defaultIfBlank(baseUrl, deepSeekConfig.getApiHost())); deepSeekConfig.setApiKey(defaultIfBlank(apiKey, deepSeekConfig.getApiKey())); configuration.setDeepSeekConfig(deepSeekConfig); return; case MOONSHOT: MoonshotConfig moonshotConfig = new MoonshotConfig(); moonshotConfig.setApiHost(defaultIfBlank(baseUrl, moonshotConfig.getApiHost())); moonshotConfig.setApiKey(defaultIfBlank(apiKey, moonshotConfig.getApiKey())); configuration.setMoonshotConfig(moonshotConfig); return; case HUNYUAN: HunyuanConfig hunyuanConfig = new HunyuanConfig(); hunyuanConfig.setApiHost(defaultIfBlank(baseUrl, hunyuanConfig.getApiHost())); hunyuanConfig.setApiKey(defaultIfBlank(apiKey, hunyuanConfig.getApiKey())); configuration.setHunyuanConfig(hunyuanConfig); return; case LINGYI: LingyiConfig lingyiConfig = new LingyiConfig(); lingyiConfig.setApiHost(defaultIfBlank(baseUrl, lingyiConfig.getApiHost())); lingyiConfig.setApiKey(defaultIfBlank(apiKey, lingyiConfig.getApiKey())); configuration.setLingyiConfig(lingyiConfig); return; case OLLAMA: OllamaConfig ollamaConfig = new OllamaConfig(); ollamaConfig.setApiHost(defaultIfBlank(baseUrl, ollamaConfig.getApiHost())); ollamaConfig.setApiKey(defaultIfBlank(apiKey, ollamaConfig.getApiKey())); configuration.setOllamaConfig(ollamaConfig); return; case MINIMAX: MinimaxConfig minimaxConfig = new MinimaxConfig(); minimaxConfig.setApiHost(defaultIfBlank(baseUrl, minimaxConfig.getApiHost())); minimaxConfig.setApiKey(defaultIfBlank(apiKey, minimaxConfig.getApiKey())); configuration.setMinimaxConfig(minimaxConfig); return; case BAICHUAN: BaichuanConfig baichuanConfig = new BaichuanConfig(); baichuanConfig.setApiHost(defaultIfBlank(baseUrl, baichuanConfig.getApiHost())); baichuanConfig.setApiKey(defaultIfBlank(apiKey, baichuanConfig.getApiKey())); configuration.setBaichuanConfig(baichuanConfig); return; case DASHSCOPE: DashScopeConfig dashScopeConfig = new DashScopeConfig(); dashScopeConfig.setApiHost(defaultIfBlank(baseUrl, dashScopeConfig.getApiHost())); dashScopeConfig.setApiKey(defaultIfBlank(apiKey, dashScopeConfig.getApiKey())); configuration.setDashScopeConfig(dashScopeConfig); return; case DOUBAO: DoubaoConfig doubaoConfig = new DoubaoConfig(); doubaoConfig.setApiHost(defaultIfBlank(baseUrl, doubaoConfig.getApiHost())); doubaoConfig.setApiKey(defaultIfBlank(apiKey, doubaoConfig.getApiKey())); configuration.setDoubaoConfig(doubaoConfig); return; default: throw new IllegalArgumentException("Unsupported provider: " + provider); } } private void assertSupportedProtocol(PlatformType provider, CliProtocol protocol) { if (protocol == CliProtocol.RESPONSES) { if (provider != PlatformType.OPENAI && provider != PlatformType.DOUBAO && provider != PlatformType.DASHSCOPE) { throw new IllegalArgumentException( "Provider " + provider.getPlatform() + " does not support responses protocol in ai4j-cli yet" ); } } } private String defaultIfBlank(String value, String defaultValue) { return isBlank(value) ? defaultValue : value; } private String safeText(String value) { return isBlank(value) ? "(none)" : value.trim(); } String normalizeZhipuBaseUrl(String baseUrl) { if (isBlank(baseUrl)) { return baseUrl; } String normalized = baseUrl.trim(); normalized = stripSuffixIgnoreCase(normalized, "/v4/chat/completions"); normalized = stripSuffixIgnoreCase(normalized, "/chat/completions"); normalized = stripSuffixIgnoreCase(normalized, "/v4"); if (!normalized.endsWith("/")) { normalized = normalized + "/"; } return normalized; } private String normalizeRuntimeBaseUrl(CodeCommandOptions options) { if (options == null) { return null; } if (options.getProvider() == PlatformType.ZHIPU) { return normalizeZhipuBaseUrl(options.getBaseUrl()); } return options.getBaseUrl(); } private String stripSuffixIgnoreCase(String value, String suffix) { if (isBlank(value) || isBlank(suffix)) { return value; } String lowerValue = value.toLowerCase(); String lowerSuffix = suffix.toLowerCase(); if (lowerValue.endsWith(lowerSuffix)) { return value.substring(0, value.length() - suffix.length()); } return value; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String safeMessage(Throwable throwable) { String message = throwable == null ? null : throwable.getMessage(); if (isBlank(message) && throwable != null && throwable.getCause() != null) { message = throwable.getCause().getMessage(); } return isBlank(message) ? (throwable == null ? "unknown MCP error" : throwable.getClass().getSimpleName()) : message.trim(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/DefaultCodingCliTuiFactory.java ================================================ package io.github.lnyocly.ai4j.cli.factory; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.runtime.CodingCliTuiSupport; import io.github.lnyocly.ai4j.tui.AnsiTuiRuntime; import io.github.lnyocly.ai4j.tui.AppendOnlyTuiRuntime; import io.github.lnyocly.ai4j.tui.JlineTerminalIO; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiConfig; import io.github.lnyocly.ai4j.tui.TuiConfigManager; import io.github.lnyocly.ai4j.tui.TuiRenderer; import io.github.lnyocly.ai4j.tui.TuiRuntime; import io.github.lnyocly.ai4j.tui.TuiSessionView; import io.github.lnyocly.ai4j.tui.TuiTheme; public class DefaultCodingCliTuiFactory implements CodingCliTuiFactory { @Override public CodingCliTuiSupport create(CodeCommandOptions options, TerminalIO terminal, TuiConfigManager configManager) { TuiConfig config = configManager == null ? new TuiConfig() : configManager.load(options == null ? null : options.getTheme()); TuiTheme theme = configManager == null ? new TuiTheme() : configManager.resolveTheme(config.getTheme()); TuiRenderer renderer = new TuiSessionView(config, theme, terminal != null && terminal.supportsAnsi()); boolean useAlternateScreen = config != null && config.isUseAlternateScreen(); TuiRuntime runtime = !useAlternateScreen && terminal instanceof JlineTerminalIO ? new AppendOnlyTuiRuntime(terminal) : new AnsiTuiRuntime(terminal, renderer, useAlternateScreen); return new CodingCliTuiSupport(config, theme, renderer, runtime); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpConfig.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import java.util.LinkedHashMap; import java.util.Map; public class CliMcpConfig { private Map mcpServers = new LinkedHashMap(); public CliMcpConfig() { } public CliMcpConfig(Map mcpServers) { this.mcpServers = mcpServers == null ? new LinkedHashMap() : new LinkedHashMap(mcpServers); } public static Builder builder() { return new Builder(); } public Builder toBuilder() { return new Builder().mcpServers(mcpServers); } public Map getMcpServers() { return mcpServers; } public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers == null ? new LinkedHashMap() : new LinkedHashMap(mcpServers); } public static final class Builder { private Map mcpServers = new LinkedHashMap(); private Builder() { } public Builder mcpServers(Map mcpServers) { this.mcpServers = mcpServers == null ? new LinkedHashMap() : new LinkedHashMap(mcpServers); return this; } public CliMcpConfig build() { return new CliMcpConfig(mcpServers); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpConfigManager.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONWriter; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; public class CliMcpConfigManager { private final Path workspaceRoot; public CliMcpConfigManager(Path workspaceRoot) { this.workspaceRoot = workspaceRoot == null ? Paths.get(".").toAbsolutePath().normalize() : workspaceRoot.toAbsolutePath().normalize(); } public CliMcpConfig loadGlobalConfig() { CliMcpConfig config = loadConfig(globalMcpPath(), CliMcpConfig.class); if (config == null) { config = new CliMcpConfig(); } boolean changed = normalize(config); if (changed) { persistNormalizedGlobalConfig(config); } return config; } public CliMcpConfig saveGlobalConfig(CliMcpConfig config) throws IOException { CliMcpConfig normalized = config == null ? new CliMcpConfig() : config; normalize(normalized); Path path = globalMcpPath(); if (path == null) { throw new IOException("user home directory is unavailable"); } Files.createDirectories(path.getParent()); Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); return normalized; } public CliWorkspaceConfig loadWorkspaceConfig() { CliWorkspaceConfig config = loadConfig(workspaceConfigPath(), CliWorkspaceConfig.class); if (config == null) { config = new CliWorkspaceConfig(); } normalize(config); return config; } public CliWorkspaceConfig saveWorkspaceConfig(CliWorkspaceConfig config) throws IOException { CliWorkspaceConfig normalized = config == null ? new CliWorkspaceConfig() : config; normalize(normalized); Path path = workspaceConfigPath(); Files.createDirectories(path.getParent()); Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); return normalized; } public CliResolvedMcpConfig resolve(Collection pausedServerNames) { CliMcpConfig globalConfig = loadGlobalConfig(); CliWorkspaceConfig workspaceConfig = loadWorkspaceConfig(); Set enabledNames = toOrderedSet(workspaceConfig.getEnabledMcpServers()); Set pausedNames = toOrderedSet(pausedServerNames); Map resolvedServers = new LinkedHashMap(); List effectiveEnabledNames = new ArrayList(); List unknownEnabledNames = new ArrayList(); Map definitions = globalConfig.getMcpServers(); if (definitions == null) { definitions = Collections.emptyMap(); } for (Map.Entry entry : definitions.entrySet()) { String name = entry.getKey(); CliMcpServerDefinition definition = entry.getValue(); boolean workspaceEnabled = enabledNames.contains(name); boolean sessionPaused = pausedNames.contains(name); String validationError = validate(definition); boolean active = workspaceEnabled && !sessionPaused && validationError == null; if (workspaceEnabled) { effectiveEnabledNames.add(name); } resolvedServers.put(name, new CliResolvedMcpServer( name, definition == null ? null : definition.getType(), workspaceEnabled, sessionPaused, active, validationError, definition )); } for (String enabledName : enabledNames) { if (!definitions.containsKey(enabledName)) { unknownEnabledNames.add(enabledName); } } List effectivePausedNames = new ArrayList(); for (String pausedName : pausedNames) { if (definitions.containsKey(pausedName)) { effectivePausedNames.add(pausedName); } } return new CliResolvedMcpConfig( resolvedServers, effectiveEnabledNames, effectivePausedNames, unknownEnabledNames ); } public Path globalMcpPath() { String userHome = System.getProperty("user.home"); return isBlank(userHome) ? null : Paths.get(userHome).resolve(".ai4j").resolve("mcp.json"); } public Path workspaceConfigPath() { return workspaceRoot.resolve(".ai4j").resolve("workspace.json"); } private T loadConfig(Path path, Class type) { if (path == null || type == null || !Files.exists(path)) { return null; } try { return JSON.parseObject(Files.readAllBytes(path), type); } catch (IOException ex) { return null; } } private void persistNormalizedGlobalConfig(CliMcpConfig config) { Path path = globalMcpPath(); if (path == null) { return; } try { Files.createDirectories(path.getParent()); Files.write(path, JSON.toJSONString(config, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); } catch (IOException ignored) { } } private boolean normalize(CliMcpConfig config) { if (config == null) { return false; } boolean changed = false; Map currentServers = config.getMcpServers(); if (currentServers == null) { config.setMcpServers(new LinkedHashMap()); return true; } Map normalizedServers = new LinkedHashMap(); for (Map.Entry entry : currentServers.entrySet()) { String normalizedName = normalizeName(entry.getKey()); if (isBlank(normalizedName)) { changed = true; continue; } CliMcpServerDefinition definition = entry.getValue(); CliMcpServerDefinition normalizedDefinition = normalize(definition); normalizedServers.put(normalizedName, normalizedDefinition); if (!normalizedName.equals(entry.getKey()) || normalizedDefinition != definition) { changed = true; } } if (!normalizedServers.equals(currentServers)) { config.setMcpServers(normalizedServers); changed = true; } return changed; } private void normalize(CliWorkspaceConfig config) { if (config == null) { return; } config.setActiveProfile(normalizeName(config.getActiveProfile())); config.setModelOverride(normalizeValue(config.getModelOverride())); config.setEnabledMcpServers(normalizeNames(config.getEnabledMcpServers())); config.setSkillDirectories(normalizeNames(config.getSkillDirectories())); config.setAgentDirectories(normalizeNames(config.getAgentDirectories())); } private CliMcpServerDefinition normalize(CliMcpServerDefinition definition) { String command = definition == null ? null : normalizeValue(definition.getCommand()); return CliMcpServerDefinition.builder() .type(normalizeTransportType(definition == null ? null : definition.getType(), command)) .url(definition == null ? null : normalizeValue(definition.getUrl())) .command(command) .args(definition == null ? null : normalizeNames(definition.getArgs())) .env(definition == null ? null : normalizeMap(definition.getEnv())) .cwd(definition == null ? null : normalizeValue(definition.getCwd())) .headers(definition == null ? null : normalizeMap(definition.getHeaders())) .build(); } private String validate(CliMcpServerDefinition definition) { if (definition == null) { return "missing MCP server definition"; } String type = normalizeValue(definition.getType()); if (isBlank(type)) { return "missing MCP transport type"; } if ("stdio".equals(type)) { return isBlank(definition.getCommand()) ? "stdio transport requires command" : null; } if ("sse".equals(type) || "streamable_http".equals(type)) { return isBlank(definition.getUrl()) ? type + " transport requires url" : null; } return "unsupported MCP transport: " + type; } private String normalizeTransportType(String rawType, String command) { String normalizedType = normalizeValue(rawType); if (isBlank(normalizedType)) { return isBlank(command) ? null : "stdio"; } String lowerCaseType = normalizedType.toLowerCase(Locale.ROOT); if ("http".equals(lowerCaseType)) { return "streamable_http"; } return lowerCaseType; } private Set toOrderedSet(Collection values) { if (values == null || values.isEmpty()) { return Collections.emptySet(); } LinkedHashSet normalized = new LinkedHashSet(); for (String value : values) { String candidate = normalizeName(value); if (!isBlank(candidate)) { normalized.add(candidate); } } return normalized; } private List normalizeNames(List values) { if (values == null || values.isEmpty()) { return null; } LinkedHashSet normalized = new LinkedHashSet(); for (String value : values) { String candidate = normalizeName(value); if (!isBlank(candidate)) { normalized.add(candidate); } } return normalized.isEmpty() ? null : new ArrayList(normalized); } private Map normalizeMap(Map values) { if (values == null || values.isEmpty()) { return null; } LinkedHashMap normalized = new LinkedHashMap(); for (Map.Entry entry : values.entrySet()) { String key = normalizeName(entry.getKey()); if (isBlank(key)) { continue; } normalized.put(key, normalizeValue(entry.getValue())); } return normalized.isEmpty() ? null : normalized; } private String normalizeName(String value) { return normalizeValue(value); } private String normalizeValue(String value) { return isBlank(value) ? null : value.trim(); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpConnectionHandle.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class CliMcpConnectionHandle { private final CliResolvedMcpServer server; private CliMcpRuntimeManager.ClientSession clientSession; private String state; private String errorSummary; private List tools = Collections.emptyList(); CliMcpConnectionHandle(CliResolvedMcpServer server) { this.server = server; } String getServerName() { return server == null ? null : server.getName(); } String getTransportType() { return server == null ? null : server.getTransportType(); } CliResolvedMcpServer getServer() { return server; } CliMcpRuntimeManager.ClientSession getClientSession() { return clientSession; } void setClientSession(CliMcpRuntimeManager.ClientSession clientSession) { this.clientSession = clientSession; } String getState() { return state; } void setState(String state) { this.state = state; } String getErrorSummary() { return errorSummary; } void setErrorSummary(String errorSummary) { this.errorSummary = errorSummary; } List getTools() { return tools; } void setTools(List tools) { if (tools == null || tools.isEmpty()) { this.tools = Collections.emptyList(); return; } this.tools = Collections.unmodifiableList(new ArrayList(tools)); } CliMcpStatusSnapshot toStatusSnapshot() { return new CliMcpStatusSnapshot( getServerName(), getTransportType(), state, tools == null ? 0 : tools.size(), errorSummary, server != null && server.isWorkspaceEnabled(), server != null && server.isSessionPaused() ); } void closeQuietly() { CliMcpRuntimeManager.ClientSession session = clientSession; clientSession = null; if (session == null) { return; } try { session.close(); } catch (Exception ignored) { } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpRuntimeManager.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.mcp.client.McpClient; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import io.github.lnyocly.ai4j.mcp.transport.McpTransport; import io.github.lnyocly.ai4j.mcp.transport.McpTransportFactory; import io.github.lnyocly.ai4j.mcp.transport.TransportConfig; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; public final class CliMcpRuntimeManager implements AutoCloseable { public static final String STATE_CONNECTED = "connected"; public static final String STATE_DISABLED = "disabled"; public static final String STATE_PAUSED = "paused"; public static final String STATE_ERROR = "error"; public static final String STATE_MISSING = "missing"; private static final Set RESERVED_TOOL_NAMES = new LinkedHashSet(Arrays.asList( CodingToolNames.BASH, CodingToolNames.READ_FILE, CodingToolNames.WRITE_FILE, CodingToolNames.APPLY_PATCH )); private final CliResolvedMcpConfig resolvedConfig; private final ClientFactory clientFactory; private final Map handlesByServerName = new LinkedHashMap(); private final Map handlesByToolName = new LinkedHashMap(); private List statuses = Collections.emptyList(); private AgentToolRegistry toolRegistry; private ToolExecutor toolExecutor; public static CliMcpRuntimeManager initialize(Path workspaceRoot, Collection pausedServerNames) { CliResolvedMcpConfig resolvedConfig = new CliMcpConfigManager(workspaceRoot).resolve(pausedServerNames); return initialize(resolvedConfig); } public static CliMcpRuntimeManager initialize(CliResolvedMcpConfig resolvedConfig) { if (!hasRelevantConfiguration(resolvedConfig)) { return null; } CliMcpRuntimeManager runtimeManager = new CliMcpRuntimeManager(resolvedConfig); runtimeManager.start(); return runtimeManager; } CliMcpRuntimeManager(CliResolvedMcpConfig resolvedConfig) { this(resolvedConfig, new DefaultClientFactory()); } CliMcpRuntimeManager(CliResolvedMcpConfig resolvedConfig, ClientFactory clientFactory) { this.resolvedConfig = resolvedConfig == null ? new CliResolvedMcpConfig(null, null, null, null) : resolvedConfig; this.clientFactory = clientFactory == null ? new DefaultClientFactory() : clientFactory; } public void start() { close(); List nextStatuses = new ArrayList(); Map claimedToolOwners = new LinkedHashMap(); for (CliResolvedMcpServer server : resolvedConfig.getServers().values()) { CliMcpConnectionHandle handle = new CliMcpConnectionHandle(server); handlesByServerName.put(server.getName(), handle); if (!server.isWorkspaceEnabled()) { handle.setState(STATE_DISABLED); nextStatuses.add(handle.toStatusSnapshot()); continue; } if (server.isSessionPaused()) { handle.setState(STATE_PAUSED); nextStatuses.add(handle.toStatusSnapshot()); continue; } if (!server.isValid()) { handle.setState(STATE_ERROR); handle.setErrorSummary(server.getValidationError()); nextStatuses.add(handle.toStatusSnapshot()); continue; } try { ClientSession clientSession = clientFactory.create(server); handle.setClientSession(clientSession); clientSession.connect(); List toolDefinitions = clientSession.listTools(); validateToolNames(server, toolDefinitions, claimedToolOwners); List tools = convertTools(toolDefinitions); handle.setTools(tools); handle.setState(STATE_CONNECTED); for (Tool tool : tools) { if (tool == null || tool.getFunction() == null || isBlank(tool.getFunction().getName())) { continue; } String toolName = tool.getFunction().getName().trim(); claimedToolOwners.put(toolName.toLowerCase(Locale.ROOT), server.getName()); handlesByToolName.put(toolName, handle); } } catch (Exception ex) { handle.setState(STATE_ERROR); handle.setErrorSummary(safeMessage(ex)); handle.closeQuietly(); } nextStatuses.add(handle.toStatusSnapshot()); } for (String missing : resolvedConfig.getUnknownEnabledServerNames()) { nextStatuses.add(new CliMcpStatusSnapshot(missing, null, STATE_MISSING, 0, "workspace references undefined MCP server", true, false)); } rebuildToolView(); statuses = Collections.unmodifiableList(nextStatuses); } public AgentToolRegistry getToolRegistry() { return toolRegistry; } public ToolExecutor getToolExecutor() { return toolExecutor; } public List getStatuses() { return statuses; } public List buildStartupWarnings() { if (statuses == null || statuses.isEmpty()) { return Collections.emptyList(); } List warnings = new ArrayList(); for (CliMcpStatusSnapshot status : statuses) { if (status == null) { continue; } if (!STATE_ERROR.equals(status.getState()) && !STATE_MISSING.equals(status.getState())) { continue; } warnings.add("MCP unavailable: " + firstNonBlank(status.getServerName(), "(unknown)") + " (" + firstNonBlank(status.getErrorSummary(), "unknown MCP error") + ")"); } return warnings.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(warnings); } public boolean hasStatuses() { return statuses != null && !statuses.isEmpty(); } public String findServerNameByToolName(String toolName) { if (isBlank(toolName)) { return null; } CliMcpConnectionHandle handle = handlesByToolName.get(toolName.trim()); return handle == null ? null : handle.getServerName(); } @Override public void close() { for (CliMcpConnectionHandle handle : handlesByServerName.values()) { if (handle != null) { handle.closeQuietly(); } } handlesByServerName.clear(); handlesByToolName.clear(); toolRegistry = null; toolExecutor = null; if (statuses == null) { statuses = Collections.emptyList(); } } private void rebuildToolView() { List tools = new ArrayList(); for (CliMcpConnectionHandle handle : handlesByServerName.values()) { if (!STATE_CONNECTED.equals(handle.getState()) || handle.getTools().isEmpty()) { continue; } tools.addAll(handle.getTools()); } if (tools.isEmpty()) { toolRegistry = null; toolExecutor = null; return; } toolRegistry = new StaticToolRegistry(tools); toolExecutor = new ToolExecutor() { @Override public String execute(AgentToolCall call) throws Exception { if (call == null || isBlank(call.getName())) { throw new IllegalArgumentException("MCP tool call is missing tool name"); } CliMcpConnectionHandle handle = handlesByToolName.get(call.getName()); if (handle == null || handle.getClientSession() == null) { throw new IllegalArgumentException("Unknown MCP tool: " + call.getName()); } Object arguments = parseArguments(call.getArguments()); return handle.getClientSession().callTool(call.getName(), arguments); } }; } private void validateToolNames(CliResolvedMcpServer server, List toolDefinitions, Map claimedToolOwners) { Set localToolNames = new LinkedHashSet(); if (toolDefinitions == null) { return; } for (McpToolDefinition toolDefinition : toolDefinitions) { String toolName = toolDefinition == null ? null : normalizeToolName(toolDefinition.getName()); if (isBlank(toolName)) { throw new IllegalStateException("MCP server " + server.getName() + " returned a tool without a name"); } String normalizedToolName = toolName.toLowerCase(Locale.ROOT); if (RESERVED_TOOL_NAMES.contains(normalizedToolName)) { throw new IllegalStateException("MCP tool name conflicts with built-in tool: " + toolName); } if (!localToolNames.add(normalizedToolName)) { throw new IllegalStateException("MCP server " + server.getName() + " returned duplicate tool: " + toolName); } String existingOwner = claimedToolOwners.get(normalizedToolName); if (!isBlank(existingOwner)) { throw new IllegalStateException("MCP tool name conflict: " + toolName + " already provided by " + existingOwner); } } } private List convertTools(List toolDefinitions) { if (toolDefinitions == null || toolDefinitions.isEmpty()) { return Collections.emptyList(); } List tools = new ArrayList(); for (McpToolDefinition toolDefinition : toolDefinitions) { if (toolDefinition == null || isBlank(toolDefinition.getName())) { continue; } Tool tool = new Tool(); tool.setType("function"); Tool.Function function = new Tool.Function(); function.setName(normalizeToolName(toolDefinition.getName())); function.setDescription(toolDefinition.getDescription()); function.setParameters(convertInputSchema(toolDefinition.getInputSchema())); tool.setFunction(function); tools.add(tool); } return tools; } private Tool.Function.Parameter convertInputSchema(Map inputSchema) { Tool.Function.Parameter parameter = new Tool.Function.Parameter(); parameter.setType("object"); if (inputSchema == null || inputSchema.isEmpty()) { return parameter; } Object properties = inputSchema.get("properties"); if (properties instanceof Map) { @SuppressWarnings("unchecked") Map propertyMap = (Map) properties; Map convertedProperties = new LinkedHashMap(); for (Map.Entry entry : propertyMap.entrySet()) { if (isBlank(entry.getKey()) || !(entry.getValue() instanceof Map)) { continue; } @SuppressWarnings("unchecked") Map rawProperty = (Map) entry.getValue(); convertedProperties.put(entry.getKey(), convertProperty(rawProperty)); } parameter.setProperties(convertedProperties); } Object required = inputSchema.get("required"); if (required instanceof List) { @SuppressWarnings("unchecked") List requiredNames = (List) required; parameter.setRequired(requiredNames); } return parameter; } private Tool.Function.Property convertProperty(Map rawProperty) { Tool.Function.Property property = new Tool.Function.Property(); property.setType(asString(rawProperty.get("type"))); property.setDescription(asString(rawProperty.get("description"))); Object enumValues = rawProperty.get("enum"); if (enumValues instanceof List) { @SuppressWarnings("unchecked") List values = (List) enumValues; property.setEnumValues(values); } Object items = rawProperty.get("items"); if (items instanceof Map) { @SuppressWarnings("unchecked") Map rawItems = (Map) items; property.setItems(convertProperty(rawItems)); } return property; } private Object parseArguments(String rawArguments) { if (isBlank(rawArguments)) { return Collections.emptyMap(); } try { return JSON.parseObject(rawArguments, Map.class); } catch (Exception ex) { throw new IllegalArgumentException("Invalid MCP tool arguments: " + safeMessage(ex), ex); } } private String normalizeToolName(String value) { return isBlank(value) ? null : value.trim(); } private String asString(Object value) { return value == null ? null : String.valueOf(value); } private String safeMessage(Throwable throwable) { String message = null; Throwable last = throwable; Throwable current = throwable; while (current != null) { if (!isBlank(current.getMessage())) { message = current.getMessage().trim(); } last = current; current = current.getCause(); } return isBlank(message) ? (last == null ? "unknown MCP error" : last.getClass().getSimpleName()) : message; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private static boolean hasRelevantConfiguration(CliResolvedMcpConfig resolvedConfig) { if (resolvedConfig == null) { return false; } if (!resolvedConfig.getServers().isEmpty()) { return true; } return !resolvedConfig.getUnknownEnabledServerNames().isEmpty(); } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } interface ClientFactory { ClientSession create(CliResolvedMcpServer server) throws Exception; } interface ClientSession extends AutoCloseable { void connect() throws Exception; List listTools() throws Exception; String callTool(String toolName, Object arguments) throws Exception; @Override void close() throws Exception; } private static final class DefaultClientFactory implements ClientFactory { @Override public ClientSession create(CliResolvedMcpServer server) throws Exception { CliMcpServerDefinition definition = server == null ? null : server.getDefinition(); if (definition == null) { throw new IllegalArgumentException("missing MCP definition"); } TransportConfig config = new TransportConfig(); config.setType(definition.getType()); config.setUrl(definition.getUrl()); config.setCommand(definition.getCommand()); config.setArgs(definition.getArgs()); config.setEnv(definition.getEnv()); config.setHeaders(definition.getHeaders()); McpTransport transport = McpTransportFactory.createTransport(definition.getType(), config); McpClient client = new McpClient("ai4j-cli-" + server.getName(), "2.1.0", transport, false); return new McpClientSessionAdapter(client); } } private static final class McpClientSessionAdapter implements ClientSession { private final McpClient delegate; private McpClientSessionAdapter(McpClient delegate) { this.delegate = delegate; } @Override public void connect() { delegate.connect().join(); } @Override public List listTools() { return delegate.getAvailableTools().join(); } @Override public String callTool(String toolName, Object arguments) { return delegate.callTool(toolName, arguments).join(); } @Override public void close() { delegate.disconnect().join(); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpServerDefinition.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class CliMcpServerDefinition { private String type; private String url; private String command; private List args; private Map env; private String cwd; private Map headers; public CliMcpServerDefinition() { } public CliMcpServerDefinition(String type, String url, String command, List args, Map env, String cwd, Map headers) { this.type = type; this.url = url; this.command = command; this.args = copyList(args); this.env = copyMap(env); this.cwd = cwd; this.headers = copyMap(headers); } public static Builder builder() { return new Builder(); } public Builder toBuilder() { return new Builder() .type(type) .url(url) .command(command) .args(args) .env(env) .cwd(cwd) .headers(headers); } public String getType() { return type; } public void setType(String type) { this.type = type; } public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public String getCommand() { return command; } public void setCommand(String command) { this.command = command; } public List getArgs() { return copyList(args); } public void setArgs(List args) { this.args = copyList(args); } public Map getEnv() { return copyMap(env); } public void setEnv(Map env) { this.env = copyMap(env); } public String getCwd() { return cwd; } public void setCwd(String cwd) { this.cwd = cwd; } public Map getHeaders() { return copyMap(headers); } public void setHeaders(Map headers) { this.headers = copyMap(headers); } private List copyList(List values) { return values == null ? null : new ArrayList(values); } private Map copyMap(Map values) { return values == null ? null : new LinkedHashMap(values); } public static final class Builder { private String type; private String url; private String command; private List args; private Map env; private String cwd; private Map headers; private Builder() { } public Builder type(String type) { this.type = type; return this; } public Builder url(String url) { this.url = url; return this; } public Builder command(String command) { this.command = command; return this; } public Builder args(List args) { this.args = args == null ? null : new ArrayList(args); return this; } public Builder env(Map env) { this.env = env == null ? null : new LinkedHashMap(env); return this; } public Builder cwd(String cwd) { this.cwd = cwd; return this; } public Builder headers(Map headers) { this.headers = headers == null ? null : new LinkedHashMap(headers); return this; } public CliMcpServerDefinition build() { return new CliMcpServerDefinition(type, url, command, args, env, cwd, headers); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpStatusSnapshot.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; public final class CliMcpStatusSnapshot { private final String serverName; private final String transportType; private final String state; private final int toolCount; private final String errorSummary; private final boolean workspaceEnabled; private final boolean sessionPaused; public CliMcpStatusSnapshot(String serverName, String transportType, String state, int toolCount, String errorSummary, boolean workspaceEnabled, boolean sessionPaused) { this.serverName = serverName; this.transportType = transportType; this.state = state; this.toolCount = toolCount; this.errorSummary = errorSummary; this.workspaceEnabled = workspaceEnabled; this.sessionPaused = sessionPaused; } public String getServerName() { return serverName; } public String getTransportType() { return transportType; } public String getState() { return state; } public int getToolCount() { return toolCount; } public String getErrorSummary() { return errorSummary; } public boolean isWorkspaceEnabled() { return workspaceEnabled; } public boolean isSessionPaused() { return sessionPaused; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliResolvedMcpConfig.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public final class CliResolvedMcpConfig { private final Map servers; private final List enabledServerNames; private final List pausedServerNames; private final List unknownEnabledServerNames; public CliResolvedMcpConfig(Map servers, List enabledServerNames, List pausedServerNames, List unknownEnabledServerNames) { this.servers = servers == null ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap(servers)); this.enabledServerNames = enabledServerNames == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList(enabledServerNames)); this.pausedServerNames = pausedServerNames == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList(pausedServerNames)); this.unknownEnabledServerNames = unknownEnabledServerNames == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList(unknownEnabledServerNames)); } public Map getServers() { return servers; } public List getEnabledServerNames() { return enabledServerNames; } public List getPausedServerNames() { return pausedServerNames; } public List getUnknownEnabledServerNames() { return unknownEnabledServerNames; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliResolvedMcpServer.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; public final class CliResolvedMcpServer { private final String name; private final String transportType; private final boolean workspaceEnabled; private final boolean sessionPaused; private final boolean active; private final String validationError; private final CliMcpServerDefinition definition; public CliResolvedMcpServer(String name, String transportType, boolean workspaceEnabled, boolean sessionPaused, boolean active, String validationError, CliMcpServerDefinition definition) { this.name = name; this.transportType = transportType; this.workspaceEnabled = workspaceEnabled; this.sessionPaused = sessionPaused; this.active = active; this.validationError = validationError; this.definition = definition; } public String getName() { return name; } public String getTransportType() { return transportType; } public boolean isWorkspaceEnabled() { return workspaceEnabled; } public boolean isSessionPaused() { return sessionPaused; } public boolean isActive() { return active; } public String getValidationError() { return validationError; } public CliMcpServerDefinition getDefinition() { return definition; } public boolean isValid() { return validationError == null || validationError.isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliProviderConfigManager.java ================================================ package io.github.lnyocly.ai4j.cli.provider; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONWriter; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.service.PlatformType; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; public class CliProviderConfigManager { private final Path workspaceRoot; public CliProviderConfigManager(Path workspaceRoot) { this.workspaceRoot = workspaceRoot == null ? Paths.get(".").toAbsolutePath().normalize() : workspaceRoot.toAbsolutePath().normalize(); } public CliProvidersConfig loadProvidersConfig() { CliProvidersConfig config = loadConfig(globalProvidersPath(), CliProvidersConfig.class); if (config == null) { config = new CliProvidersConfig(); } boolean changed = normalize(config); if (changed) { persistNormalizedProvidersConfig(config); } return config; } public CliProvidersConfig saveProvidersConfig(CliProvidersConfig config) throws IOException { CliProvidersConfig normalized = config == null ? new CliProvidersConfig() : config; normalize(normalized); Path path = globalProvidersPath(); if (path == null) { throw new IOException("user home directory is unavailable"); } Files.createDirectories(path.getParent()); Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); return normalized; } public CliWorkspaceConfig loadWorkspaceConfig() { CliWorkspaceConfig config = loadConfig(workspaceConfigPath(), CliWorkspaceConfig.class); if (config == null) { config = new CliWorkspaceConfig(); } normalize(config); return config; } public CliWorkspaceConfig saveWorkspaceConfig(CliWorkspaceConfig config) throws IOException { CliWorkspaceConfig normalized = config == null ? new CliWorkspaceConfig() : config; normalize(normalized); Path path = workspaceConfigPath(); Files.createDirectories(path.getParent()); Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); return normalized; } public List listProfileNames() { Map profiles = loadProvidersConfig().getProfiles(); if (profiles == null || profiles.isEmpty()) { return Collections.emptyList(); } List names = new ArrayList(profiles.keySet()); Collections.sort(names); return names; } public CliProviderProfile getProfile(String name) { if (isBlank(name)) { return null; } return loadProvidersConfig().getProfiles().get(name.trim()); } public CliResolvedProviderConfig resolve(String providerOverride, String protocolOverride, String modelOverride, String apiKeyOverride, String baseUrlOverride, Map env, Properties properties) { CliProvidersConfig providersConfig = loadProvidersConfig(); CliWorkspaceConfig workspaceConfig = loadWorkspaceConfig(); CliProviderProfile activeProfile = resolveProfile(providersConfig, workspaceConfig.getActiveProfile()); CliProviderProfile defaultProfile = resolveProfile(providersConfig, providersConfig.getDefaultProfile()); PlatformType explicitProvider = isBlank(providerOverride) ? null : resolveProvider(providerOverride); if (explicitProvider != null) { activeProfile = alignProfileWithProvider(activeProfile, explicitProvider); defaultProfile = alignProfileWithProvider(defaultProfile, explicitProvider); } String effectiveProfile = !isBlank(workspaceConfig.getActiveProfile()) && activeProfile != null ? workspaceConfig.getActiveProfile().trim() : (!isBlank(providersConfig.getDefaultProfile()) && defaultProfile != null ? providersConfig.getDefaultProfile().trim() : null); PlatformType provider = resolveProvider(firstNonBlank( providerOverride, activeProfile == null ? null : activeProfile.getProvider(), defaultProfile == null ? null : defaultProfile.getProvider(), envValue(env, "AI4J_PROVIDER"), propertyValue(properties, "ai4j.provider"), "openai" )); String baseUrl = firstNonBlank( baseUrlOverride, activeProfile == null ? null : activeProfile.getBaseUrl(), defaultProfile == null ? null : defaultProfile.getBaseUrl(), envValue(env, "AI4J_BASE_URL"), propertyValue(properties, "ai4j.base-url") ); CliProtocol protocol = CliProtocol.resolveConfigured(firstNonBlank( protocolOverride, activeProfile == null ? null : activeProfile.getProtocol(), defaultProfile == null ? null : defaultProfile.getProtocol(), envValue(env, "AI4J_PROTOCOL"), propertyValue(properties, "ai4j.protocol") ), provider, baseUrl); String model = firstNonBlank( modelOverride, workspaceConfig.getModelOverride(), activeProfile == null ? null : activeProfile.getModel(), defaultProfile == null ? null : defaultProfile.getModel(), envValue(env, "AI4J_MODEL"), propertyValue(properties, "ai4j.model") ); String apiKey = firstNonBlank( apiKeyOverride, activeProfile == null ? null : activeProfile.getApiKey(), defaultProfile == null ? null : defaultProfile.getApiKey(), envValue(env, "AI4J_API_KEY"), propertyValue(properties, "ai4j.api.key"), providerApiKeyEnv(env, provider) ); return new CliResolvedProviderConfig( normalizeName(workspaceConfig.getActiveProfile()), normalizeName(providersConfig.getDefaultProfile()), effectiveProfile, normalizeValue(workspaceConfig.getModelOverride()), provider, protocol, normalizeValue(model), normalizeValue(apiKey), normalizeValue(baseUrl) ); } public Path globalProvidersPath() { String userHome = System.getProperty("user.home"); return isBlank(userHome) ? null : Paths.get(userHome).resolve(".ai4j").resolve("providers.json"); } public Path workspaceConfigPath() { return workspaceRoot.resolve(".ai4j").resolve("workspace.json"); } private T loadConfig(Path path, Class type) { if (path == null || type == null || !Files.exists(path)) { return null; } try { return JSON.parseObject(Files.readAllBytes(path), type); } catch (IOException ex) { return null; } } private CliProviderProfile resolveProfile(CliProvidersConfig config, String name) { if (config == null || config.getProfiles() == null || isBlank(name)) { return null; } return config.getProfiles().get(name.trim()); } private CliProviderProfile alignProfileWithProvider(CliProviderProfile profile, PlatformType provider) { if (profile == null || provider == null) { return profile; } String profileProvider = normalizeValue(profile.getProvider()); if (isBlank(profileProvider)) { return null; } return provider.getPlatform().equalsIgnoreCase(profileProvider) ? profile : null; } private PlatformType resolveProvider(String raw) { if (isBlank(raw)) { return PlatformType.OPENAI; } for (PlatformType platformType : PlatformType.values()) { if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) { return platformType; } } throw new IllegalArgumentException("Unsupported provider: " + raw); } private String providerApiKeyEnv(Map env, PlatformType provider) { if (provider == null || env == null) { return null; } String envName = provider.name().toUpperCase(Locale.ROOT) + "_API_KEY"; return envValue(env, envName); } private String envValue(Map env, String name) { return env == null ? null : env.get(name); } private String propertyValue(Properties properties, String name) { return properties == null ? null : properties.getProperty(name); } private void persistNormalizedProvidersConfig(CliProvidersConfig config) { Path path = globalProvidersPath(); if (path == null) { return; } try { Files.createDirectories(path.getParent()); Files.write(path, JSON.toJSONString(config, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); } catch (IOException ignored) { } } private boolean normalize(CliProvidersConfig config) { if (config == null) { return false; } boolean changed = false; Map profiles = config.getProfiles(); if (profiles == null) { profiles = new LinkedHashMap(); config.setProfiles(profiles); changed = true; } List invalidNames = new ArrayList(); for (Map.Entry entry : profiles.entrySet()) { String normalizedName = normalizeName(entry.getKey()); if (isBlank(normalizedName)) { invalidNames.add(entry.getKey()); changed = true; continue; } changed = normalize(entry.getValue()) || changed; } for (String invalidName : invalidNames) { profiles.remove(invalidName); } String normalizedDefaultProfile = normalizeName(config.getDefaultProfile()); if (!equalsValue(normalizedDefaultProfile, config.getDefaultProfile())) { config.setDefaultProfile(normalizedDefaultProfile); changed = true; } if (!isBlank(config.getDefaultProfile()) && !profiles.containsKey(config.getDefaultProfile())) { config.setDefaultProfile(null); changed = true; } return changed; } private void normalize(CliWorkspaceConfig config) { if (config == null) { return; } config.setActiveProfile(normalizeName(config.getActiveProfile())); config.setModelOverride(normalizeValue(config.getModelOverride())); config.setEnabledMcpServers(normalizeNames(config.getEnabledMcpServers())); config.setSkillDirectories(normalizeNames(config.getSkillDirectories())); config.setAgentDirectories(normalizeNames(config.getAgentDirectories())); } private boolean normalize(CliProviderProfile profile) { if (profile == null) { return false; } boolean changed = false; String provider = normalizeValue(profile.getProvider()); if (!equalsValue(provider, profile.getProvider())) { profile.setProvider(provider); changed = true; } String baseUrl = normalizeValue(profile.getBaseUrl()); if (!equalsValue(baseUrl, profile.getBaseUrl())) { profile.setBaseUrl(baseUrl); changed = true; } String protocol = normalizeProtocol(profile.getProtocol(), provider, baseUrl); if (!equalsValue(protocol, profile.getProtocol())) { profile.setProtocol(protocol); changed = true; } String model = normalizeValue(profile.getModel()); if (!equalsValue(model, profile.getModel())) { profile.setModel(model); changed = true; } String apiKey = normalizeValue(profile.getApiKey()); if (!equalsValue(apiKey, profile.getApiKey())) { profile.setApiKey(apiKey); changed = true; } return changed; } private String normalizeProtocol(String value, String providerValue, String baseUrl) { String normalized = normalizeValue(value); if (isBlank(normalized)) { return null; } PlatformType provider = resolveProviderOrNull(providerValue); if ("auto".equalsIgnoreCase(normalized)) { return provider == null ? normalized : CliProtocol.defaultProtocol(provider, baseUrl).getValue(); } try { return CliProtocol.parse(normalized).getValue(); } catch (IllegalArgumentException ignored) { return normalized; } } private String normalizeName(String value) { return normalizeValue(value); } private String normalizeValue(String value) { return isBlank(value) ? null : value.trim(); } private PlatformType resolveProviderOrNull(String raw) { if (isBlank(raw)) { return null; } for (PlatformType platformType : PlatformType.values()) { if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) { return platformType; } } return null; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private boolean equalsValue(String left, String right) { if (left == null) { return right == null; } return left.equals(right); } private List normalizeNames(List values) { if (values == null || values.isEmpty()) { return null; } LinkedHashSet normalized = new LinkedHashSet(); for (String value : values) { String candidate = normalizeName(value); if (!isBlank(candidate)) { normalized.add(candidate); } } return normalized.isEmpty() ? null : new ArrayList(normalized); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliProviderProfile.java ================================================ package io.github.lnyocly.ai4j.cli.provider; public class CliProviderProfile { private String provider; private String protocol; private String model; private String baseUrl; private String apiKey; public CliProviderProfile() { } public CliProviderProfile(String provider, String protocol, String model, String baseUrl, String apiKey) { this.provider = provider; this.protocol = protocol; this.model = model; this.baseUrl = baseUrl; this.apiKey = apiKey; } public static Builder builder() { return new Builder(); } public Builder toBuilder() { return new Builder() .provider(provider) .protocol(protocol) .model(model) .baseUrl(baseUrl) .apiKey(apiKey); } public String getProvider() { return provider; } public void setProvider(String provider) { this.provider = provider; } public String getProtocol() { return protocol; } public void setProtocol(String protocol) { this.protocol = protocol; } public String getModel() { return model; } public void setModel(String model) { this.model = model; } public String getBaseUrl() { return baseUrl; } public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } public String getApiKey() { return apiKey; } public void setApiKey(String apiKey) { this.apiKey = apiKey; } public static final class Builder { private String provider; private String protocol; private String model; private String baseUrl; private String apiKey; private Builder() { } public Builder provider(String provider) { this.provider = provider; return this; } public Builder protocol(String protocol) { this.protocol = protocol; return this; } public Builder model(String model) { this.model = model; return this; } public Builder baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; } public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; } public CliProviderProfile build() { return new CliProviderProfile(provider, protocol, model, baseUrl, apiKey); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliProvidersConfig.java ================================================ package io.github.lnyocly.ai4j.cli.provider; import java.util.LinkedHashMap; import java.util.Map; public class CliProvidersConfig { private String defaultProfile; private Map profiles = new LinkedHashMap(); public CliProvidersConfig() { } public CliProvidersConfig(String defaultProfile, Map profiles) { this.defaultProfile = defaultProfile; this.profiles = profiles == null ? new LinkedHashMap() : new LinkedHashMap(profiles); } public static Builder builder() { return new Builder(); } public Builder toBuilder() { return new Builder() .defaultProfile(defaultProfile) .profiles(profiles); } public String getDefaultProfile() { return defaultProfile; } public void setDefaultProfile(String defaultProfile) { this.defaultProfile = defaultProfile; } public Map getProfiles() { return profiles; } public void setProfiles(Map profiles) { this.profiles = profiles == null ? new LinkedHashMap() : new LinkedHashMap(profiles); } public static final class Builder { private String defaultProfile; private Map profiles = new LinkedHashMap(); private Builder() { } public Builder defaultProfile(String defaultProfile) { this.defaultProfile = defaultProfile; return this; } public Builder profiles(Map profiles) { this.profiles = profiles == null ? new LinkedHashMap() : new LinkedHashMap(profiles); return this; } public CliProvidersConfig build() { return new CliProvidersConfig(defaultProfile, profiles); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliResolvedProviderConfig.java ================================================ package io.github.lnyocly.ai4j.cli.provider; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.service.PlatformType; public final class CliResolvedProviderConfig { private final String activeProfile; private final String defaultProfile; private final String effectiveProfile; private final String modelOverride; private final PlatformType provider; private final CliProtocol protocol; private final String model; private final String apiKey; private final String baseUrl; public CliResolvedProviderConfig(String activeProfile, String defaultProfile, String effectiveProfile, String modelOverride, PlatformType provider, CliProtocol protocol, String model, String apiKey, String baseUrl) { this.activeProfile = activeProfile; this.defaultProfile = defaultProfile; this.effectiveProfile = effectiveProfile; this.modelOverride = modelOverride; this.provider = provider; this.protocol = protocol; this.model = model; this.apiKey = apiKey; this.baseUrl = baseUrl; } public String getActiveProfile() { return activeProfile; } public String getDefaultProfile() { return defaultProfile; } public String getEffectiveProfile() { return effectiveProfile; } public String getModelOverride() { return modelOverride; } public PlatformType getProvider() { return provider; } public CliProtocol getProtocol() { return protocol; } public String getModel() { return model; } public String getApiKey() { return apiKey; } public String getBaseUrl() { return baseUrl; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/AssistantTranscriptRenderer.java ================================================ package io.github.lnyocly.ai4j.cli.render; import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class AssistantTranscriptRenderer { public List render(String markdown) { List rawLines = trimBlankEdges(splitLines(markdown)); if (rawLines.isEmpty()) { return Collections.emptyList(); } List lines = new ArrayList(); boolean insideCodeBlock = false; String codeBlockLanguage = null; for (String rawLine : rawLines) { if (isCodeFenceLine(rawLine)) { if (!insideCodeBlock) { insideCodeBlock = true; codeBlockLanguage = codeFenceLanguage(rawLine); } else { insideCodeBlock = false; codeBlockLanguage = null; } continue; } if (insideCodeBlock) { lines.add(Line.code(formatCodeContentLine(rawLine), codeBlockLanguage)); } else { lines.add(Line.text(rawLine == null ? "" : rawLine)); } } return trimBlankEdgeLines(lines); } public List plainLines(String markdown) { List lines = render(markdown); if (lines.isEmpty()) { return Collections.emptyList(); } List plain = new ArrayList(lines.size()); for (Line line : lines) { plain.add(line == null ? "" : line.text()); } return plain; } public String styleBlock(String markdown, CliThemeStyler themeStyler) { if (themeStyler == null) { return ""; } List lines = render(markdown); if (lines.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (int index = 0; index < lines.size(); index++) { if (index > 0) { builder.append('\n'); } Line line = lines.get(index); if (line == null) { continue; } builder.append(line.code() ? themeStyler.styleTranscriptCodeLine(line.text(), line.language()) : themeStyler.styleTranscriptLine(line.text(), new CliThemeStyler.TranscriptStyleState())); } return builder.toString(); } public int rowCount(String markdown, int terminalWidth) { List lines = render(markdown); if (lines.isEmpty()) { return 0; } int rows = 0; int width = Math.max(1, terminalWidth); for (Line line : lines) { String text = line == null ? "" : line.text(); if (text.isEmpty()) { rows += 1; continue; } String wrapped = CliDisplayWidth.wrapAnsi(text, width, 0).text(); rows += Math.max(1, wrapped.split("\n", -1).length); } return rows; } private List splitLines(String markdown) { if (markdown == null) { return Collections.emptyList(); } String normalized = markdown.replace("\r", ""); String[] rawLines = normalized.split("\n", -1); List lines = new ArrayList(rawLines.length); for (String rawLine : rawLines) { lines.add(rawLine == null ? "" : rawLine); } return lines; } private List trimBlankEdges(List rawLines) { if (rawLines == null || rawLines.isEmpty()) { return Collections.emptyList(); } int start = 0; int end = rawLines.size() - 1; while (start <= end && isBlank(rawLines.get(start))) { start++; } while (end >= start && isBlank(rawLines.get(end))) { end--; } if (start > end) { return Collections.emptyList(); } List lines = new ArrayList(end - start + 1); for (int index = start; index <= end; index++) { lines.add(rawLines.get(index) == null ? "" : rawLines.get(index)); } return lines; } private List trimBlankEdgeLines(List rawLines) { if (rawLines == null || rawLines.isEmpty()) { return Collections.emptyList(); } int start = 0; int end = rawLines.size() - 1; while (start <= end && isBlank(rawLines.get(start) == null ? null : rawLines.get(start).text())) { start++; } while (end >= start && isBlank(rawLines.get(end) == null ? null : rawLines.get(end).text())) { end--; } if (start > end) { return Collections.emptyList(); } List lines = new ArrayList(end - start + 1); for (int index = start; index <= end; index++) { lines.add(rawLines.get(index)); } return lines; } private boolean isCodeFenceLine(String rawLine) { return rawLine != null && rawLine.trim().startsWith("```"); } private String codeFenceLanguage(String rawLine) { if (!isCodeFenceLine(rawLine)) { return null; } String value = rawLine.trim().substring(3).trim(); return isBlank(value) ? null : value; } private String formatCodeContentLine(String rawLine) { if (rawLine == null || rawLine.isEmpty()) { return CodexStyleBlockFormatter.CODE_BLOCK_EMPTY_LINE; } return CodexStyleBlockFormatter.CODE_BLOCK_LINE_PREFIX + rawLine; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } public static final class Line { private final String text; private final boolean code; private final String language; private Line(String text, boolean code, String language) { this.text = text == null ? "" : text; this.code = code; this.language = language; } public static Line text(String text) { return new Line(text, false, null); } public static Line code(String text, String language) { return new Line(text, true, language); } public String text() { return text; } public boolean code() { return code; } public String language() { return language; } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CliAnsi.java ================================================ package io.github.lnyocly.ai4j.cli.render; public final class CliAnsi { private static final String ESC = "\u001b["; private static final String RESET = ESC + "0m"; private CliAnsi() { } public static String warning(String value, boolean ansi) { return style(value, "#f4d35e", null, ansi, true); } static String colorize(String value, String foreground, boolean ansi, boolean bold) { return style(value, foreground, null, ansi, bold); } static String style(String value, String foreground, String background, boolean ansi, boolean bold) { if (!ansi || isEmpty(value)) { return value; } StringBuilder codes = new StringBuilder(); if (bold) { codes.append('1'); } if (!isBlank(foreground)) { appendColorCode(codes, "38;2;", parseHex(foreground)); } if (!isBlank(background)) { appendColorCode(codes, "48;2;", parseHex(background)); } if (codes.length() == 0) { return value; } return ESC + codes + "m" + value + RESET; } private static void appendColorCode(StringBuilder codes, String prefix, int[] rgb) { if (codes == null || rgb == null || rgb.length < 3) { return; } if (codes.length() > 0) { codes.append(';'); } codes.append(prefix) .append(rgb[0]).append(';') .append(rgb[1]).append(';') .append(rgb[2]); } private static int[] parseHex(String hexColor) { String normalized = hexColor == null ? "" : hexColor.trim(); if (normalized.startsWith("#")) { normalized = normalized.substring(1); } if (normalized.length() != 6) { return new int[]{255, 255, 255}; } return new int[]{ Integer.parseInt(normalized.substring(0, 2), 16), Integer.parseInt(normalized.substring(2, 4), 16), Integer.parseInt(normalized.substring(4, 6), 16) }; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static boolean isEmpty(String value) { return value == null || value.isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CliDisplayWidth.java ================================================ package io.github.lnyocly.ai4j.cli.render; import org.jline.utils.AttributedString; import org.jline.utils.WCWidth; public final class CliDisplayWidth { private static final String LINE_HEAD_FORBIDDEN = ",.:;!?)]}>%},。!?;:、)》」』】〕〉》”’"; private CliDisplayWidth() { } public static String clip(String value, int maxWidth) { if (value == null) { return ""; } String normalized = value.replace('\r', ' ').replace('\n', ' ').trim(); if (maxWidth <= 0 || normalized.isEmpty()) { return maxWidth <= 0 ? "" : normalized; } if (displayWidth(normalized) <= maxWidth) { return normalized; } if (maxWidth <= 3) { return sliceByColumns(normalized, maxWidth); } return sliceByColumns(normalized, maxWidth - 3) + "..."; } public static int displayWidth(String text) { if (text == null || text.isEmpty()) { return 0; } int width = 0; for (int i = 0; i < text.length(); i++) { width += charWidth(text.charAt(i)); } return width; } public static String sliceByColumns(String text, int width) { if (text == null || text.isEmpty() || width <= 0) { return ""; } StringBuilder builder = new StringBuilder(); int used = 0; for (int i = 0; i < text.length(); i++) { char ch = text.charAt(i); int charWidth = charWidth(ch); if (used + charWidth > width) { break; } builder.append(ch); used += charWidth; } return builder.toString(); } public static WrappedAnsi wrapAnsi(String ansiText, int terminalWidth, int startColumn) { String safe = ansiText == null ? "" : ansiText.replace("\r", ""); if (safe.isEmpty() || terminalWidth <= 0) { return new WrappedAnsi(safe, startColumn); } int column = Math.max(0, startColumn); StringBuilder builder = new StringBuilder(); int start = 0; while (start <= safe.length()) { int newlineIndex = safe.indexOf('\n', start); String line = newlineIndex >= 0 ? safe.substring(start, newlineIndex) : safe.substring(start); if (!line.isEmpty()) { AttributedString attributed = AttributedString.fromAnsi(line); int offset = 0; int totalColumns = attributed.columnLength(); while (offset < totalColumns) { if (column >= terminalWidth) { builder.append('\n'); column = 0; } int remaining = Math.max(1, terminalWidth - column); int end = Math.min(totalColumns, offset + remaining); AttributedString fragment = attributed.columnSubSequence(offset, end); if (end < totalColumns && startsWithLineHeadForbidden(attributed.columnSubSequence(end, totalColumns).toString())) { int adjustedEnd = retreatBreak(attributed, offset, end, column); if (adjustedEnd <= offset && column > 0) { builder.append('\n'); column = 0; continue; } if (adjustedEnd > offset && adjustedEnd < end) { end = adjustedEnd; fragment = attributed.columnSubSequence(offset, end); } } builder.append(fragment.toAnsi()); int fragmentWidth = fragment.columnLength(); column += fragmentWidth; offset += fragmentWidth; if (offset < totalColumns && column >= terminalWidth) { builder.append('\n'); column = 0; } } } if (newlineIndex < 0) { break; } builder.append('\n'); column = 0; start = newlineIndex + 1; } return new WrappedAnsi(builder.toString(), column); } private static int charWidth(char ch) { int width = WCWidth.wcwidth(ch); return width <= 0 ? 1 : width; } private static int retreatBreak(AttributedString attributed, int start, int end, int currentColumn) { int adjustedEnd = end; while (adjustedEnd > start) { AttributedString fragment = attributed.columnSubSequence(start, adjustedEnd); String text = fragment.toString(); if (text.isEmpty()) { break; } char last = text.charAt(text.length() - 1); int lastWidth = charWidth(last); if (adjustedEnd - lastWidth < start) { break; } adjustedEnd -= lastWidth; if (adjustedEnd <= start) { return currentColumn > 0 ? adjustedEnd : end; } if (!startsWithLineHeadForbidden(attributed.columnSubSequence(adjustedEnd, attributed.columnLength()).toString())) { return adjustedEnd; } } return end; } private static boolean startsWithLineHeadForbidden(String text) { if (text == null || text.isEmpty()) { return false; } return LINE_HEAD_FORBIDDEN.indexOf(text.charAt(0)) >= 0; } public static final class WrappedAnsi { private final String text; private final int endColumn; public WrappedAnsi(String text, int endColumn) { this.text = text == null ? "" : text; this.endColumn = Math.max(0, endColumn); } public String text() { return text; } public int endColumn() { return endColumn; } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CliThemeStyler.java ================================================ package io.github.lnyocly.ai4j.cli.render; import io.github.lnyocly.ai4j.tui.TuiTheme; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Locale; import java.util.Set; public final class CliThemeStyler { private static final String FALLBACK_BRAND = "#7cc6fe"; private static final String FALLBACK_ACCENT = "#f5b14c"; private static final String FALLBACK_SUCCESS = "#8fd694"; private static final String FALLBACK_WARNING = "#f4d35e"; private static final String FALLBACK_DANGER = "#ef6f6c"; private static final String FALLBACK_TEXT = "#f3f4f6"; private static final String FALLBACK_MUTED = "#9ca3af"; private static final String FALLBACK_CODE_BACKGROUND = "#161b22"; private static final String FALLBACK_CODE_TEXT = "#c9d1d9"; private static final String FALLBACK_CODE_KEYWORD = "#ff7b72"; private static final String FALLBACK_CODE_STRING = "#a5d6ff"; private static final String FALLBACK_CODE_COMMENT = "#8b949e"; private static final String FALLBACK_CODE_NUMBER = "#79c0ff"; private static final Set JAVA_LIKE_KEYWORDS = new HashSet(Arrays.asList( "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", "default", "do", "double", "else", "enum", "extends", "final", "finally", "float", "for", "goto", "if", "implements", "import", "instanceof", "int", "interface", "long", "native", "new", "package", "private", "protected", "public", "record", "return", "sealed", "short", "static", "strictfp", "super", "switch", "synchronized", "this", "throw", "throws", "transient", "try", "var", "void", "volatile", "while", "yield" )); private static final Set SCRIPT_KEYWORDS = new HashSet(Arrays.asList( "as", "async", "await", "break", "case", "catch", "class", "const", "continue", "def", "default", "del", "do", "elif", "else", "esac", "except", "export", "extends", "fi", "finally", "for", "from", "function", "if", "import", "in", "lambda", "let", "local", "pass", "raise", "return", "then", "try", "var", "while", "with", "yield" )); private static final Set LITERAL_KEYWORDS = new HashSet(Arrays.asList( "true", "false", "null", "none", "undefined" )); private final boolean ansi; private volatile TuiTheme theme; public CliThemeStyler(TuiTheme theme, boolean ansi) { this.theme = theme == null ? new TuiTheme() : theme; this.ansi = ansi; } public void updateTheme(TuiTheme theme) { this.theme = theme == null ? new TuiTheme() : theme; } public String styleTranscriptLine(String line) { return styleTranscriptLine(line, new TranscriptStyleState()); } public String styleTranscriptLine(String line, TranscriptStyleState state) { if (line == null || line.isEmpty() || line.indexOf('\u001b') >= 0) { return line; } TranscriptStyleState renderState = state == null ? new TranscriptStyleState() : state; if (isCodeBlockHeaderLine(line)) { renderState.enterCodeBlock(codeBlockLanguage(line)); return styleCodeFenceHeader(line); } if (renderState.isInsideCodeBlock()) { if (isCodeBlockLine(line)) { return styleCodeBlockLine(line, renderState.getCodeBlockLanguage()); } renderState.exitCodeBlock(); } if (line.startsWith("Thinking: ")) { return styleReasoningFragment(line); } if (line.startsWith("• ")) { return CliAnsi.colorize(line, transcriptBulletColor(line), ansi, true); } if (isMarkdownHeadingLine(line)) { return styleMarkdownHeadingLine(line); } if (isMarkdownQuoteLine(line)) { return styleMarkdownQuoteLine(line); } if (line.startsWith(" └ ") || line.startsWith(" │ ") || line.startsWith(" ")) { return styleMutedFragment(line); } return styleInlineMarkdown(line, text(), false); } public String buildPrimaryStatusLine(String statusLabel, boolean spinnerActive, String spinner, String statusDetail) { String label = firstNonBlank(statusLabel, "Idle"); String color = statusColor(label); StringBuilder builder = new StringBuilder(); builder.append(CliAnsi.colorize("• " + label, color, ansi, true)); if (spinnerActive && !isBlank(spinner)) { builder.append(' ').append(CliAnsi.colorize(spinner, color, ansi, true)); } if (!isBlank(statusDetail)) { builder.append(" ").append(CliAnsi.colorize(statusDetail, "idle".equalsIgnoreCase(label) ? muted() : text(), ansi, false)); } return builder.toString(); } public String buildCompactStatusLine(String statusLabel, boolean spinnerActive, String spinner, String statusDetail, String model, String workspace, String hint) { StringBuilder builder = new StringBuilder(buildPrimaryStatusLine(statusLabel, spinnerActive, spinner, statusDetail)); if (!isBlank(model)) { builder.append(" ") .append(styleMutedFragment("model")) .append(' ') .append(CliAnsi.colorize(firstNonBlank(model, "(unknown)"), accent(), ansi, false)); } if (!isBlank(workspace)) { builder.append(" ") .append(styleMutedFragment("workspace")) .append(' ') .append(styleAssistantFragment(firstNonBlank(workspace, "."))); } if ("idle".equalsIgnoreCase(firstNonBlank(statusLabel, "Idle")) && !isBlank(hint)) { builder.append(" ") .append(styleMutedFragment("hint")) .append(' ') .append(styleMutedFragment(hint)); } return builder.toString(); } public String buildSessionLine(String sessionId, String model, String workspace) { return styleMutedFragment("session") + " " + CliAnsi.colorize(firstNonBlank(sessionId, "(new)"), brand(), ansi, true) + " " + styleMutedFragment("model") + " " + CliAnsi.colorize(firstNonBlank(model, "(unknown)"), accent(), ansi, false) + " " + styleMutedFragment("workspace") + " " + styleAssistantFragment(firstNonBlank(workspace, ".")); } public String buildHintLine(String hint) { return styleMutedFragment("hint") + " " + styleMutedFragment(firstNonBlank(hint, "Enter a prompt or /command")); } public String styleAssistantFragment(String text) { return CliAnsi.colorize(text, text(), ansi, false); } public String styleReasoningFragment(String text) { return CliAnsi.colorize(text, muted(), ansi, false); } public String styleMutedFragment(String text) { return CliAnsi.colorize(text, muted(), ansi, false); } public String styleTranscriptCodeLine(String line, String language) { return styleCodeBlockLine(line == null ? "" : line, language); } private String styleCodeFenceHeader(String line) { return CliAnsi.style(line, codeComment(), codeBackground(), ansi, true); } private String styleCodeBlockLine(String line, String language) { String prefix = CodexStyleBlockFormatter.CODE_BLOCK_LINE_PREFIX; String body = line.length() <= prefix.length() ? "" : line.substring(prefix.length()); if (!ansi) { return line; } return styleCodeTextFragment(prefix) + highlightCodeBody(body, language); } private String styleMarkdownHeadingLine(String line) { if (!ansi) { return stripInlineMarkdown(line); } return styleInlineMarkdown(line, brand(), true); } private String styleMarkdownQuoteLine(String line) { return styleInlineMarkdown(line, muted(), false); } private String styleInlineMarkdown(String line, String baseColor, boolean boldByDefault) { if (line == null) { return ""; } String normalized = stripMarkdownLinks(line); if (!ansi) { return stripInlineMarkdown(normalized); } StringBuilder builder = new StringBuilder(); StringBuilder buffer = new StringBuilder(); boolean bold = boldByDefault; boolean code = false; for (int index = 0; index < normalized.length(); index++) { char current = normalized.charAt(index); if ((current == '*' || current == '_') && index + 1 < normalized.length() && normalized.charAt(index + 1) == current) { appendStyledMarkdownSegment(builder, buffer.toString(), baseColor, bold, code); buffer.setLength(0); bold = !bold; index++; continue; } if (current == '`') { appendStyledMarkdownSegment(builder, buffer.toString(), baseColor, bold, code); buffer.setLength(0); code = !code; continue; } if ((current == '*' || current == '_') && isSingleMarkerToggle(normalized, index, current)) { continue; } buffer.append(current); } appendStyledMarkdownSegment(builder, buffer.toString(), baseColor, bold, code); return builder.toString(); } private String highlightCodeBody(String body, String language) { String normalizedLanguage = normalizeLanguage(language); if ("json".equals(normalizedLanguage)) { return highlightJson(body); } if ("xml".equals(normalizedLanguage) || "html".equals(normalizedLanguage)) { return highlightXml(body); } if ("bash".equals(normalizedLanguage)) { return highlightGenericCode(body, SCRIPT_KEYWORDS, true, true, false, true); } if ("python".equals(normalizedLanguage)) { return highlightGenericCode(body, SCRIPT_KEYWORDS, false, true, true, false); } if ("yaml".equals(normalizedLanguage)) { return highlightGenericCode(body, Collections.emptySet(), false, true, false, false); } if ("java".equals(normalizedLanguage) || "kotlin".equals(normalizedLanguage) || "javascript".equals(normalizedLanguage) || "typescript".equals(normalizedLanguage) || "csharp".equals(normalizedLanguage)) { return highlightGenericCode(body, JAVA_LIKE_KEYWORDS, true, false, true, false); } return highlightGenericCode(body, JAVA_LIKE_KEYWORDS, true, true, true, true); } private String highlightGenericCode(String code, Set keywords, boolean slashComment, boolean hashComment, boolean annotations, boolean variables) { if (code == null || code.isEmpty()) { return ""; } StringBuilder styled = new StringBuilder(); StringBuilder plain = new StringBuilder(); int index = 0; while (index < code.length()) { char ch = code.charAt(index); if (slashComment && index + 1 < code.length() && ch == '/' && code.charAt(index + 1) == '/') { flushPlain(styled, plain); styled.append(styleCodeCommentFragment(code.substring(index))); return styled.toString(); } if (slashComment && index + 1 < code.length() && ch == '/' && code.charAt(index + 1) == '*') { int end = code.indexOf("*/", index + 2); int stop = end >= 0 ? end + 2 : code.length(); flushPlain(styled, plain); styled.append(styleCodeCommentFragment(code.substring(index, stop))); index = stop; continue; } if (hashComment && ch == '#') { flushPlain(styled, plain); styled.append(styleCodeCommentFragment(code.substring(index))); return styled.toString(); } if (variables && ch == '$') { int stop = consumeVariable(code, index); if (stop > index + 1) { flushPlain(styled, plain); styled.append(styleCodeNumberFragment(code.substring(index, stop), false)); index = stop; continue; } } if (annotations && ch == '@') { int stop = consumeIdentifier(code, index + 1); if (stop > index + 1) { flushPlain(styled, plain); styled.append(styleCodeNumberFragment(code.substring(index, stop), false)); index = stop; continue; } } if (ch == '"' || ch == '\'') { int stop = consumeQuoted(code, index, ch); flushPlain(styled, plain); styled.append(styleCodeStringFragment(code.substring(index, stop))); index = stop; continue; } if (Character.isDigit(ch)) { int stop = consumeNumber(code, index); flushPlain(styled, plain); styled.append(styleCodeNumberFragment(code.substring(index, stop), false)); index = stop; continue; } if (isIdentifierStart(ch)) { int stop = consumeIdentifier(code, index + 1); String word = code.substring(index, stop); if (keywords.contains(word)) { flushPlain(styled, plain); styled.append(styleCodeKeywordFragment(word, true)); } else if (LITERAL_KEYWORDS.contains(word.toLowerCase(Locale.ROOT))) { flushPlain(styled, plain); styled.append(styleCodeNumberFragment(word, true)); } else { plain.append(word); } index = stop; continue; } if (isCodePunctuation(ch)) { flushPlain(styled, plain); styled.append(styleCodeTextFragment(String.valueOf(ch))); index++; continue; } plain.append(ch); index++; } flushPlain(styled, plain); return styled.toString(); } private String highlightJson(String code) { if (code == null || code.isEmpty()) { return ""; } StringBuilder styled = new StringBuilder(); StringBuilder plain = new StringBuilder(); int index = 0; while (index < code.length()) { char ch = code.charAt(index); if (ch == '"' || ch == '\'') { int stop = consumeQuoted(code, index, ch); String token = code.substring(index, stop); int next = skipWhitespace(code, stop); flushPlain(styled, plain); styled.append(next < code.length() && code.charAt(next) == ':' ? styleCodeKeywordFragment(token, true) : styleCodeStringFragment(token)); index = stop; continue; } if (Character.isDigit(ch) || (ch == '-' && index + 1 < code.length() && Character.isDigit(code.charAt(index + 1)))) { int stop = consumeNumber(code, index); flushPlain(styled, plain); styled.append(styleCodeNumberFragment(code.substring(index, stop), false)); index = stop; continue; } if (isIdentifierStart(ch)) { int stop = consumeIdentifier(code, index + 1); String word = code.substring(index, stop); if (LITERAL_KEYWORDS.contains(word.toLowerCase(Locale.ROOT))) { flushPlain(styled, plain); styled.append(styleCodeNumberFragment(word, true)); } else { plain.append(word); } index = stop; continue; } if (isCodePunctuation(ch) || ch == ':' || ch == ',') { flushPlain(styled, plain); styled.append(styleCodeTextFragment(String.valueOf(ch))); index++; continue; } plain.append(ch); index++; } flushPlain(styled, plain); return styled.toString(); } private String highlightXml(String code) { if (code == null || code.isEmpty()) { return ""; } StringBuilder styled = new StringBuilder(); StringBuilder plain = new StringBuilder(); int index = 0; while (index < code.length()) { if (code.startsWith("", index + 4); int stop = end >= 0 ? end + 3 : code.length(); flushPlain(styled, plain); styled.append(styleCodeCommentFragment(code.substring(index, stop))); index = stop; continue; } if (code.charAt(index) == '<') { int end = code.indexOf('>', index + 1); int stop = end >= 0 ? end + 1 : code.length(); flushPlain(styled, plain); styled.append(highlightXmlTag(code.substring(index, stop))); index = stop; continue; } plain.append(code.charAt(index)); index++; } flushPlain(styled, plain); return styled.toString(); } private String highlightXmlTag(String tag) { if (tag == null || tag.isEmpty()) { return ""; } StringBuilder styled = new StringBuilder(); StringBuilder plain = new StringBuilder(); int index = 0; while (index < tag.length()) { char ch = tag.charAt(index); if (ch == '"' || ch == '\'') { int stop = consumeQuoted(tag, index, ch); flushPlain(styled, plain); styled.append(styleCodeStringFragment(tag.substring(index, stop))); index = stop; continue; } if (ch == '<' || ch == '>' || ch == '/' || ch == '=') { flushPlain(styled, plain); styled.append(styleCodeTextFragment(String.valueOf(ch))); index++; continue; } if (isIdentifierStart(ch) || ch == ':') { int stop = consumeXmlIdentifier(tag, index + 1); String word = tag.substring(index, stop); flushPlain(styled, plain); styled.append(styleCodeKeywordFragment(word, true)); index = stop; continue; } plain.append(ch); index++; } flushPlain(styled, plain); return styled.toString(); } private void flushPlain(StringBuilder styled, StringBuilder plain) { if (plain.length() == 0) { return; } styled.append(styleCodeTextFragment(plain.toString())); plain.setLength(0); } private boolean isCodeBlockHeaderLine(String line) { return line != null && line.startsWith(CodexStyleBlockFormatter.CODE_BLOCK_HEADER_PREFIX) && line.endsWith(CodexStyleBlockFormatter.CODE_BLOCK_HEADER_SUFFIX); } private boolean isCodeBlockLine(String line) { return line != null && (line.startsWith(CodexStyleBlockFormatter.CODE_BLOCK_LINE_PREFIX) || CodexStyleBlockFormatter.CODE_BLOCK_EMPTY_LINE.equals(line)); } private String codeBlockLanguage(String line) { if (!isCodeBlockHeaderLine(line)) { return null; } return line.substring( CodexStyleBlockFormatter.CODE_BLOCK_HEADER_PREFIX.length(), Math.max(CodexStyleBlockFormatter.CODE_BLOCK_HEADER_PREFIX.length(), line.length() - CodexStyleBlockFormatter.CODE_BLOCK_HEADER_SUFFIX.length()) ).trim(); } private String styleCodeTextFragment(String text) { return CliAnsi.style(text, codeText(), codeBackground(), ansi, false); } private String styleCodeKeywordFragment(String text, boolean bold) { return CliAnsi.style(text, codeKeyword(), codeBackground(), ansi, bold); } private String styleCodeStringFragment(String text) { return CliAnsi.style(text, codeString(), codeBackground(), ansi, false); } private String styleCodeCommentFragment(String text) { return CliAnsi.style(text, codeComment(), codeBackground(), ansi, false); } private String styleCodeNumberFragment(String text, boolean bold) { return CliAnsi.style(text, codeNumber(), codeBackground(), ansi, bold); } private int consumeQuoted(String text, int start, char quote) { int index = start + 1; while (index < text.length()) { char current = text.charAt(index); if (current == '\\' && index + 1 < text.length()) { index += 2; continue; } if (current == quote) { return index + 1; } index++; } return text.length(); } private int consumeIdentifier(String text, int start) { int index = start; while (index < text.length()) { char current = text.charAt(index); if (!Character.isLetterOrDigit(current) && current != '_' && current != '$') { break; } index++; } return index; } private int consumeXmlIdentifier(String text, int start) { int index = start; while (index < text.length()) { char current = text.charAt(index); if (!Character.isLetterOrDigit(current) && current != '_' && current != '-' && current != ':' && current != '.') { break; } index++; } return index; } private int consumeNumber(String text, int start) { int index = start; if (index < text.length() && (text.charAt(index) == '-' || text.charAt(index) == '+')) { index++; } while (index < text.length()) { char current = text.charAt(index); if (!Character.isDigit(current) && current != '.' && current != '_' && current != 'x' && current != 'X' && current != 'b' && current != 'B' && current != 'o' && current != 'O' && current != 'e' && current != 'E' && current != '+' && current != '-') { break; } index++; } return index; } private int consumeVariable(String text, int start) { if (start + 1 >= text.length()) { return start + 1; } if (text.charAt(start + 1) == '{') { int end = text.indexOf('}', start + 2); return end >= 0 ? end + 1 : text.length(); } return consumeIdentifier(text, start + 1); } private int skipWhitespace(String text, int start) { int index = start; while (index < text.length() && Character.isWhitespace(text.charAt(index))) { index++; } return index; } private boolean isIdentifierStart(char ch) { return Character.isLetter(ch) || ch == '_' || ch == '$'; } private boolean isCodePunctuation(char ch) { return "{}[]()<>".indexOf(ch) >= 0; } private boolean isMarkdownHeadingLine(String line) { if (line == null) { return false; } String trimmed = line.trim(); if (!trimmed.startsWith("#")) { return false; } return trimmed.length() == 1 || (trimmed.length() > 1 && trimmed.charAt(1) == '#') || Character.isWhitespace(trimmed.charAt(1)); } private boolean isMarkdownQuoteLine(String line) { return line != null && line.trim().startsWith(">"); } private boolean isSingleMarkerToggle(String text, int index, char marker) { if (index > 0 && text.charAt(index - 1) == marker) { return false; } if (index + 1 < text.length() && text.charAt(index + 1) == marker) { return false; } return true; } private String normalizeLanguage(String language) { if (isBlank(language)) { return ""; } String normalized = language.trim().toLowerCase(Locale.ROOT); if ("sh".equals(normalized) || "shell".equals(normalized) || "zsh".equals(normalized) || "powershell".equals(normalized) || "ps1".equals(normalized)) { return "bash"; } if ("js".equals(normalized)) { return "javascript"; } if ("ts".equals(normalized)) { return "typescript"; } if ("kt".equals(normalized)) { return "kotlin"; } if ("py".equals(normalized)) { return "python"; } if ("yml".equals(normalized)) { return "yaml"; } if ("htm".equals(normalized)) { return "html"; } if ("c#".equals(normalized) || "cs".equals(normalized)) { return "csharp"; } return normalized; } private void appendStyledMarkdownSegment(StringBuilder builder, String segment, String baseColor, boolean bold, boolean code) { if (builder == null || segment == null || segment.isEmpty()) { return; } if (code) { builder.append(CliAnsi.style(segment, codeText(), codeBackground(), ansi, true)); return; } builder.append(CliAnsi.style(segment, baseColor, null, ansi, bold)); } private String stripMarkdownLinks(String text) { if (text == null || text.indexOf('[') < 0 || text.indexOf('(') < 0) { return text; } StringBuilder builder = new StringBuilder(); int index = 0; while (index < text.length()) { int openLabel = text.indexOf('[', index); if (openLabel < 0) { builder.append(text.substring(index)); break; } int closeLabel = text.indexOf(']', openLabel + 1); int openUrl = closeLabel >= 0 && closeLabel + 1 < text.length() && text.charAt(closeLabel + 1) == '(' ? closeLabel + 1 : -1; int closeUrl = openUrl >= 0 ? text.indexOf(')', openUrl + 1) : -1; if (closeLabel < 0 || openUrl < 0 || closeUrl < 0) { builder.append(text.substring(index)); break; } builder.append(text, index, openLabel); builder.append(text, openLabel + 1, closeLabel); index = closeUrl + 1; } return builder.toString(); } private String stripInlineMarkdown(String text) { if (text == null) { return ""; } return stripMarkdownLinks(text) .replace("**", "") .replace("__", "") .replace("`", ""); } private String transcriptBulletColor(String line) { String normalized = line.trim().toLowerCase(Locale.ROOT); if (normalized.startsWith("• error") || normalized.startsWith("• tool failed") || normalized.startsWith("• command failed") || normalized.startsWith("• rejected")) { return danger(); } if (normalized.startsWith("• approved")) { return success(); } if (normalized.startsWith("• approval required") || normalized.startsWith("• applying") || normalized.startsWith("• running") || normalized.startsWith("• reading") || normalized.startsWith("• writing") || normalized.startsWith("• checking") || normalized.startsWith("• stopping")) { return warning(); } if (normalized.startsWith("• auto-compacted") || normalized.startsWith("• compacted") || normalized.startsWith("• thinking") || normalized.startsWith("• responding") || normalized.startsWith("• working")) { return brand(); } return accent(); } private String statusColor(String statusLabel) { String normalized = firstNonBlank(statusLabel, "Idle").trim().toLowerCase(Locale.ROOT); if ("thinking".equals(normalized)) { return accent(); } if ("connecting".equals(normalized)) { return accent(); } if ("responding".equals(normalized)) { return brand(); } if ("retrying".equals(normalized)) { return warning(); } if ("working".equals(normalized)) { return warning(); } if ("waiting".equals(normalized)) { return accent(); } if ("stalled".equals(normalized)) { return warning(); } if ("error".equals(normalized)) { return danger(); } return muted(); } private String brand() { return firstNonBlank(theme == null ? null : theme.getBrand(), FALLBACK_BRAND); } private String accent() { return firstNonBlank(theme == null ? null : theme.getAccent(), FALLBACK_ACCENT); } private String success() { return firstNonBlank(theme == null ? null : theme.getSuccess(), FALLBACK_SUCCESS); } private String warning() { return firstNonBlank(theme == null ? null : theme.getWarning(), FALLBACK_WARNING); } private String danger() { return firstNonBlank(theme == null ? null : theme.getDanger(), FALLBACK_DANGER); } private String text() { return firstNonBlank(theme == null ? null : theme.getText(), FALLBACK_TEXT); } private String muted() { return firstNonBlank(theme == null ? null : theme.getMuted(), FALLBACK_MUTED); } private String codeBackground() { return firstNonBlank(theme == null ? null : theme.getCodeBackground(), FALLBACK_CODE_BACKGROUND); } private String codeText() { return firstNonBlank(theme == null ? null : theme.getCodeText(), FALLBACK_CODE_TEXT); } private String codeKeyword() { return firstNonBlank(theme == null ? null : theme.getCodeKeyword(), FALLBACK_CODE_KEYWORD); } private String codeString() { return firstNonBlank(theme == null ? null : theme.getCodeString(), FALLBACK_CODE_STRING); } private String codeComment() { return firstNonBlank(theme == null ? null : theme.getCodeComment(), FALLBACK_CODE_COMMENT); } private String codeNumber() { return firstNonBlank(theme == null ? null : theme.getCodeNumber(), FALLBACK_CODE_NUMBER); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } public static final class TranscriptStyleState { private String codeBlockLanguage; private boolean insideCodeBlock; public void enterCodeBlock(String language) { codeBlockLanguage = language; insideCodeBlock = true; } public void exitCodeBlock() { codeBlockLanguage = null; insideCodeBlock = false; } public void reset() { exitCodeBlock(); } public String getCodeBlockLanguage() { return codeBlockLanguage; } public boolean isInsideCodeBlock() { return insideCodeBlock; } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CodexStyleBlockFormatter.java ================================================ package io.github.lnyocly.ai4j.cli.render; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.tui.TuiAssistantToolView; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; public final class CodexStyleBlockFormatter { public static final String CODE_BLOCK_HEADER_PREFIX = " ["; public static final String CODE_BLOCK_HEADER_SUFFIX = "]"; public static final String CODE_BLOCK_LINE_PREFIX = " "; public static final String CODE_BLOCK_EMPTY_LINE = " "; private final int maxWidth; private final int maxToolPreviewLines; public CodexStyleBlockFormatter(int maxWidth, int maxToolPreviewLines) { this.maxWidth = Math.max(48, maxWidth); this.maxToolPreviewLines = Math.max(1, maxToolPreviewLines); } public List formatAssistant(String text) { List rawLines = trimBlankEdges(splitLines(text)); if (rawLines.isEmpty()) { return Collections.emptyList(); } List lines = new ArrayList(); boolean insideCodeBlock = false; for (String rawLine : rawLines) { if (isCodeFenceLine(rawLine)) { insideCodeBlock = !insideCodeBlock; continue; } if (insideCodeBlock) { lines.add(formatCodeContentLine(rawLine)); continue; } lines.add(rawLine == null ? "" : rawLine); } return trimBlankEdges(lines); } public boolean isCodeFenceLine(String rawLine) { if (rawLine == null) { return false; } String trimmed = rawLine.trim(); return trimmed.startsWith("```"); } public String formatCodeFenceOpen(String rawLine) { return null; } public String formatCodeFenceClose() { return null; } public String formatCodeContentLine(String rawLine) { if (rawLine == null || rawLine.isEmpty()) { return CODE_BLOCK_EMPTY_LINE; } return CODE_BLOCK_LINE_PREFIX + rawLine; } public String codeFenceLanguage(String rawLine) { if (rawLine == null) { return null; } String trimmed = rawLine.trim(); if (!trimmed.startsWith("```")) { return null; } String value = trimmed.substring(3).trim(); return isBlank(value) ? null : value; } public List formatTool(TuiAssistantToolView toolView) { if (toolView == null) { return Collections.emptyList(); } String toolName = firstNonBlank(toolView.getToolName(), "tool"); String status = firstNonBlank(toolView.getStatus(), "done").toLowerCase(Locale.ROOT); List lines = new ArrayList(formatToolPrimaryLines(toolName, toolView.getTitle(), status)); String detail = safeTrimToNull(toolView.getDetail()); if ("bash".equals(toolName) && !"timed out".equalsIgnoreCase(firstNonBlank(detail, ""))) { if (!"error".equalsIgnoreCase(firstNonBlank(toolView.getStatus(), ""))) { detail = null; } } if (!isBlank(detail)) { lines.addAll(wrapPrefixedText(" \u2514 ", " ", detail, maxWidth)); } List previewLines = normalizeToolPreviewLines(toolName, status, toolView.getPreviewLines()); int max = Math.min(previewLines.size(), maxToolPreviewLines); for (int i = 0; i < max; i++) { String prefix = i == 0 && isBlank(detail) ? " \u2514 " : " "; lines.addAll(wrapPrefixedText(prefix, " ", previewLines.get(i), maxWidth)); } if (previewLines.size() > max) { lines.add(" \u2026 +" + (previewLines.size() - max) + " lines"); } return trimBlankEdges(lines); } public String formatRunningStatus(TuiAssistantToolView toolView) { if (toolView == null) { return "Planning the next step"; } List primaryLines = formatToolPrimaryLines(toolView.getToolName(), toolView.getTitle(), "pending"); if (primaryLines.isEmpty()) { return "Planning the next step"; } return clip(stripLeadingBullet(primaryLines.get(0)), maxWidth); } public List formatCompact(CodingSessionCompactResult result) { if (result == null) { return Collections.emptyList(); } List lines = new ArrayList(); lines.add(result.isAutomatic() ? "\u2022 Auto-compacted session context" : "\u2022 Compacted session context"); StringBuilder metrics = new StringBuilder(); metrics.append("tokens ") .append(result.getEstimatedTokensBefore()) .append("->") .append(result.getEstimatedTokensAfter()) .append(", items ") .append(result.getBeforeItemCount()) .append("->") .append(result.getAfterItemCount()); if (result.isSplitTurn()) { metrics.append(", split turn"); } lines.addAll(wrapPrefixedText(" \u2514 ", " ", metrics.toString(), maxWidth)); String summary = safeTrimToNull(result.getSummary()); if (!isBlank(summary)) { lines.addAll(wrapPrefixedText(" ", " ", summary, maxWidth)); } return lines; } public List formatError(String message) { List lines = new ArrayList(); lines.add("\u2022 Error"); lines.addAll(wrapPrefixedText(" \u2514 ", " ", firstNonBlank(safeTrimToNull(message), "Agent run failed."), maxWidth)); return lines; } public List formatInfoBlock(String title, List rawLines) { List lines = new ArrayList(); lines.add("\u2022 " + firstNonBlank(safeTrimToNull(title), "Info")); boolean hasDetail = false; if (rawLines != null) { for (String rawLine : rawLines) { if (isBlank(rawLine)) { if (hasDetail) { lines.add(""); } continue; } String detail = normalizeInfoLine(rawLine); if (isBlank(detail)) { continue; } lines.addAll(wrapPrefixedText(hasDetail ? " " : " \u2514 ", " ", detail, maxWidth)); hasDetail = true; } } if (!hasDetail) { lines.add(" \u2514 (none)"); } return trimBlankEdges(lines); } public List formatOutput(String text) { List rawLines = trimBlankEdges(splitLines(text)); if (rawLines.isEmpty()) { return Collections.emptyList(); } ParsedInfoBlock parsed = parseOutputBlock(rawLines); if (parsed != null) { return formatInfoBlock(parsed.title, parsed.bodyLines); } return formatInfoBlock("Info", rawLines); } private List formatToolPrimaryLines(String toolName, String title, String status) { List lines = new ArrayList(); String normalizedTool = firstNonBlank(toolName, "tool"); String normalizedStatus = firstNonBlank(status, "done").toLowerCase(Locale.ROOT); String label = normalizeToolPrimaryLabel(firstNonBlank(title, normalizedTool)); if ("error".equals(normalizedStatus)) { if ("bash".equals(normalizedTool)) { return wrapPrefixedText("\u2022 Command failed ", " \u2502 ", label, maxWidth); } lines.add("\u2022 Tool failed " + clip(label, Math.max(24, maxWidth - 16))); return lines; } if ("apply_patch".equals(normalizedTool)) { lines.add("pending".equals(normalizedStatus) ? "\u2022 Applying patch" : "\u2022 Applied patch"); return lines; } return wrapPrefixedText(resolveToolPrimaryPrefix(normalizedTool, title, normalizedStatus), " \u2502 ", label, maxWidth); } private ParsedInfoBlock parseOutputBlock(List rawLines) { if (rawLines == null || rawLines.isEmpty()) { return null; } String firstLine = safeTrimToNull(rawLines.get(0)); if (isBlank(firstLine)) { return null; } if (firstLine.endsWith(":")) { return new ParsedInfoBlock(normalizeBlockTitle(firstLine.substring(0, firstLine.length() - 1)), rawLines.subList(1, rawLines.size())); } int colonIndex = firstLine.indexOf(':'); if (colonIndex <= 0 || colonIndex >= firstLine.length() - 1 || firstLine.contains("://")) { return null; } String rawTitle = firstLine.substring(0, colonIndex).trim(); if (!isStructuredOutputTitle(rawTitle)) { return null; } List bodyLines = new ArrayList(); String inlineDetail = safeTrimToNull(firstLine.substring(colonIndex + 1)); if (!isBlank(inlineDetail)) { bodyLines.add(inlineDetail); } for (int i = 1; i < rawLines.size(); i++) { bodyLines.add(rawLines.get(i)); } return new ParsedInfoBlock(normalizeBlockTitle(rawTitle), bodyLines); } private boolean isStructuredOutputTitle(String rawTitle) { if (isBlank(rawTitle)) { return false; } String normalized = rawTitle.trim().toLowerCase(Locale.ROOT); return "status".equals(normalized) || "session".equals(normalized) || "sessions".equals(normalized) || "history".equals(normalized) || "tree".equals(normalized) || "events".equals(normalized) || "replay".equals(normalized) || "compacts".equals(normalized) || "processes".equals(normalized) || "process status".equals(normalized) || "process logs".equals(normalized) || "process write".equals(normalized) || "process stopped".equals(normalized) || "checkpoint".equals(normalized) || "commands".equals(normalized) || "themes".equals(normalized) || "stream".equals(normalized) || "compact".equals(normalized); } private String normalizeBlockTitle(String rawTitle) { if (isBlank(rawTitle)) { return "Info"; } String title = rawTitle.trim(); if (title.length() == 1) { return title.toUpperCase(Locale.ROOT); } return Character.toUpperCase(title.charAt(0)) + title.substring(1); } private String normalizeInfoLine(String rawLine) { if (rawLine == null) { return null; } String value = rawLine.trim(); if (value.startsWith("- ")) { return value.substring(2).trim(); } return value; } private String resolveToolPrimaryPrefix(String toolName, String title, String status) { String normalizedTool = firstNonBlank(toolName, "tool"); String normalizedTitle = firstNonBlank(title, normalizedTool); boolean pending = "pending".equalsIgnoreCase(status); if ("read_file".equals(normalizedTool)) { return pending ? "\u2022 Reading " : "\u2022 Read "; } if ("write_file".equals(normalizedTool)) { return pending ? "\u2022 Writing " : "\u2022 Wrote "; } if ("bash".equals(normalizedTool)) { if (normalizedTitle.startsWith("bash logs ")) { return pending ? "\u2022 Reading logs " : "\u2022 Read logs "; } if (normalizedTitle.startsWith("bash status ")) { return pending ? "\u2022 Checking " : "\u2022 Checked "; } if (normalizedTitle.startsWith("bash write ")) { return pending ? "\u2022 Writing to " : "\u2022 Wrote to "; } if (normalizedTitle.startsWith("bash stop ")) { return pending ? "\u2022 Stopping " : "\u2022 Stopped "; } } return pending ? "\u2022 Running " : "\u2022 Ran "; } private String normalizeToolPrimaryLabel(String title) { String normalizedTitle = firstNonBlank(title, "tool").trim(); if (normalizedTitle.startsWith("$ ")) { return normalizedTitle.substring(2).trim(); } if (normalizedTitle.startsWith("read ")) { return normalizedTitle.substring(5).trim(); } if (normalizedTitle.startsWith("write ")) { return normalizedTitle.substring(6).trim(); } if (normalizedTitle.startsWith("bash logs ")) { return normalizedTitle.substring("bash logs ".length()).trim(); } if (normalizedTitle.startsWith("bash status ")) { return normalizedTitle.substring("bash status ".length()).trim(); } if (normalizedTitle.startsWith("bash write ")) { return normalizedTitle.substring("bash write ".length()).trim(); } if (normalizedTitle.startsWith("bash stop ")) { return normalizedTitle.substring("bash stop ".length()).trim(); } return normalizedTitle; } private List normalizeToolPreviewLines(String toolName, String status, List previewLines) { if (previewLines == null || previewLines.isEmpty()) { return Collections.emptyList(); } List normalized = new ArrayList(); for (String previewLine : previewLines) { String candidate = stripPreviewLabel(previewLine); if (isBlank(candidate)) { continue; } if ("bash".equals(toolName)) { if ("pending".equalsIgnoreCase(status)) { continue; } if ("(no command output)".equalsIgnoreCase(candidate)) { continue; } } if ("apply_patch".equals(toolName) && "(no changed files)".equalsIgnoreCase(candidate)) { continue; } normalized.add(candidate); } return normalized; } private String stripPreviewLabel(String previewLine) { if (isBlank(previewLine)) { return null; } String value = previewLine.trim(); int separator = value.indexOf("> "); if (separator > 0) { String prefix = value.substring(0, separator).trim().toLowerCase(Locale.ROOT); if ("stdout".equals(prefix) || "stderr".equals(prefix) || "log".equals(prefix) || "file".equals(prefix) || "path".equals(prefix) || "cwd".equals(prefix) || "timeout".equals(prefix) || "process".equals(prefix) || "status".equals(prefix) || "command".equals(prefix) || "stdin".equals(prefix) || "meta".equals(prefix) || "out".equals(prefix)) { return value.substring(separator + 2).trim(); } } return value; } private List wrapPrefixedText(String firstPrefix, String continuationPrefix, String rawText, int maxWidth) { List lines = new ArrayList(); if (isBlank(rawText)) { return lines; } String first = firstPrefix == null ? "" : firstPrefix; String continuation = continuationPrefix == null ? "" : continuationPrefix; int firstWidth = Math.max(12, maxWidth - first.length()); int continuationWidth = Math.max(12, maxWidth - continuation.length()); boolean firstLine = true; String[] paragraphs = rawText.replace("\r", "").split("\n"); for (String paragraph : paragraphs) { String text = safeTrimToNull(paragraph); if (isBlank(text)) { continue; } while (!isBlank(text)) { int width = firstLine ? firstWidth : continuationWidth; int split = findWrapIndex(text, width); lines.add((firstLine ? first : continuation) + text.substring(0, split).trim()); text = text.substring(split).trim(); firstLine = false; } } return lines; } private int findWrapIndex(String text, int width) { if (isBlank(text) || text.length() <= width) { return text == null ? 0 : text.length(); } int whitespace = -1; for (int i = Math.min(width, text.length() - 1); i >= 0; i--) { if (Character.isWhitespace(text.charAt(i))) { whitespace = i; break; } } return whitespace > 0 ? whitespace : width; } private List splitLines(String text) { if (text == null) { return Collections.emptyList(); } List lines = new ArrayList(); String[] rawLines = text.replace("\r", "").split("\n", -1); for (String rawLine : rawLines) { lines.add(rawLine == null ? "" : rawLine); } return lines; } private List trimBlankEdges(List rawLines) { if (rawLines == null || rawLines.isEmpty()) { return Collections.emptyList(); } int start = 0; int end = rawLines.size() - 1; while (start <= end && isBlank(rawLines.get(start))) { start++; } while (end >= start && isBlank(rawLines.get(end))) { end--; } if (start > end) { return Collections.emptyList(); } List lines = new ArrayList(); for (int i = start; i <= end; i++) { lines.add(rawLines.get(i) == null ? "" : rawLines.get(i)); } return lines; } private String stripLeadingBullet(String text) { if (isBlank(text)) { return ""; } String value = text.trim(); if (value.startsWith("\u2022 ")) { return value.substring(2).trim(); } if (value.startsWith("\u2502 ")) { return value.substring(2).trim(); } return value; } private String clip(String value, int maxChars) { if (value == null) { return ""; } String normalized = value.replace('\r', ' ').replace('\n', ' ').trim(); if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, Math.max(0, maxChars)) + "..."; } private String safeTrimToNull(String value) { return isBlank(value) ? null : value.trim(); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static final class ParsedInfoBlock { private final String title; private final List bodyLines; private ParsedInfoBlock(String title, List bodyLines) { this.title = title; this.bodyLines = bodyLines == null ? Collections.emptyList() : new ArrayList(bodyLines); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/PatchSummaryFormatter.java ================================================ package io.github.lnyocly.ai4j.cli.render; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; public final class PatchSummaryFormatter { private static final String ADD_FILE = "*** Add File: "; private static final String ADD_FILE_ALIAS = "*** Add: "; private static final String UPDATE_FILE = "*** Update File: "; private static final String UPDATE_FILE_ALIAS = "*** Update: "; private static final String DELETE_FILE = "*** Delete File: "; private static final String DELETE_FILE_ALIAS = "*** Delete: "; private PatchSummaryFormatter() { } public static List summarizePatchRequest(String patch, int maxItems) { if (isBlank(patch) || maxItems <= 0) { return Collections.emptyList(); } String[] rawLines = patch.replace("\r", "").split("\n"); List lines = new ArrayList(); for (String rawLine : rawLines) { String line = rawLine == null ? "" : rawLine.trim(); String summary = summarizePatchDirective(line); if (!isBlank(summary)) { lines.add(summary); } if (lines.size() >= maxItems) { break; } } return lines; } public static List summarizePatchResult(JSONObject output, int maxItems) { if (output == null || maxItems <= 0) { return Collections.emptyList(); } List lines = new ArrayList(); JSONArray fileChanges = output.getJSONArray("fileChanges"); if (fileChanges != null) { for (int i = 0; i < fileChanges.size() && lines.size() < maxItems; i++) { String summary = formatPatchFileChange(fileChanges.getJSONObject(i)); if (!isBlank(summary)) { lines.add(summary); } } if (!lines.isEmpty()) { return lines; } } JSONArray changedFiles = output.getJSONArray("changedFiles"); if (changedFiles != null) { for (int i = 0; i < changedFiles.size() && lines.size() < maxItems; i++) { Object changedFile = changedFiles.get(i); if (changedFile != null) { lines.add("Edited " + String.valueOf(changedFile)); } } } return lines; } public static String formatPatchFileChange(JSONObject change) { if (change == null) { return null; } String path = safeTrimToNull(change.getString("path")); if (isBlank(path)) { return null; } String operation = firstNonBlank(change.getString("operation"), "update").toLowerCase(Locale.ROOT); int linesAdded = change.getIntValue("linesAdded"); int linesRemoved = change.getIntValue("linesRemoved"); String verb; if ("add".equals(operation)) { verb = "Created"; } else if ("delete".equals(operation)) { verb = "Deleted"; } else { verb = "Edited"; } StringBuilder builder = new StringBuilder(); builder.append(verb).append(" ").append(path); if (linesAdded > 0 || linesRemoved > 0) { builder.append(" (+").append(linesAdded).append(" -").append(linesRemoved).append(")"); } return builder.toString(); } private static String summarizePatchDirective(String line) { if (isBlank(line)) { return null; } if (line.startsWith(ADD_FILE)) { return "Add " + line.substring(ADD_FILE.length()).trim(); } if (line.startsWith(ADD_FILE_ALIAS)) { return "Add " + line.substring(ADD_FILE_ALIAS.length()).trim(); } if (line.startsWith(UPDATE_FILE)) { return "Update " + line.substring(UPDATE_FILE.length()).trim(); } if (line.startsWith(UPDATE_FILE_ALIAS)) { return "Update " + line.substring(UPDATE_FILE_ALIAS.length()).trim(); } if (line.startsWith(DELETE_FILE)) { return "Delete " + line.substring(DELETE_FILE.length()).trim(); } if (line.startsWith(DELETE_FILE_ALIAS)) { return "Delete " + line.substring(DELETE_FILE_ALIAS.length()).trim(); } return null; } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private static String safeTrimToNull(String value) { return isBlank(value) ? null : value.trim(); } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/TranscriptPrinter.java ================================================ package io.github.lnyocly.ai4j.cli.render; import io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO; import io.github.lnyocly.ai4j.tui.TerminalIO; import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class TranscriptPrinter { private final TerminalIO terminal; private boolean printedBlock; public TranscriptPrinter(TerminalIO terminal) { this.terminal = terminal; } public synchronized void printBlock(List rawLines) { List lines = trimBlankEdges(rawLines); if (lines.isEmpty()) { return; } separateFromPreviousBlockIfNeeded(); if (terminal instanceof JlineShellTerminalIO) { ((JlineShellTerminalIO) terminal).printTranscriptBlock(lines); printedBlock = true; return; } for (String line : lines) { terminal.println(line == null ? "" : line); } printedBlock = true; } public synchronized void beginStreamingBlock() { printedBlock = true; } public synchronized void printSectionBreak() { if (!printedBlock) { return; } terminal.println(""); printedBlock = false; } public synchronized void resetPrintedBlock() { printedBlock = false; } private List trimBlankEdges(List rawLines) { if (rawLines == null || rawLines.isEmpty()) { return Collections.emptyList(); } int start = 0; int end = rawLines.size() - 1; while (start <= end && isBlank(rawLines.get(start))) { start++; } while (end >= start && isBlank(rawLines.get(end))) { end--; } if (start > end) { return Collections.emptyList(); } List lines = new ArrayList(); for (int i = start; i <= end; i++) { lines.add(rawLines.get(i) == null ? "" : rawLines.get(i)); } return lines; } private void separateFromPreviousBlockIfNeeded() { if (printedBlock) { terminal.println(""); } } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/AgentHandoffSessionEventSupport.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public final class AgentHandoffSessionEventSupport { private AgentHandoffSessionEventSupport() { } public static boolean supports(AgentEvent event) { if (event == null || event.getType() == null) { return false; } return event.getType() == AgentEventType.HANDOFF_START || event.getType() == AgentEventType.HANDOFF_END; } public static SessionEventType resolveSessionEventType(AgentEvent event) { if (!supports(event)) { return null; } return event.getType() == AgentEventType.HANDOFF_START ? SessionEventType.TASK_CREATED : SessionEventType.TASK_UPDATED; } public static SessionEvent toSessionEvent(String sessionId, String turnId, AgentEvent event) { SessionEventType type = resolveSessionEventType(event); if (type == null || isBlank(sessionId)) { return null; } return SessionEvent.builder() .eventId(UUID.randomUUID().toString()) .sessionId(sessionId) .type(type) .timestamp(System.currentTimeMillis()) .turnId(turnId) .step(event == null ? null : event.getStep()) .summary(buildSummary(event)) .payload(buildPayload(event)) .build(); } public static String buildSummary(AgentEvent event) { Map payload = payload(event); String title = firstNonBlank(payloadString(payload, "title"), event == null ? null : event.getMessage(), "Subagent task"); String status = firstNonBlank(payloadString(payload, "status"), event == null || event.getType() == null ? null : event.getType().name().toLowerCase()); return title + " [" + status + "]"; } public static Map buildPayload(AgentEvent event) { Map source = payload(event); Map payload = new LinkedHashMap(); String handoffId = firstNonBlank(payloadString(source, "handoffId"), payloadString(source, "callId")); String detail = firstNonBlank(payloadString(source, "detail"), payloadString(source, "error"), payloadString(source, "output")); String output = trimToNull(payloadString(source, "output")); String error = trimToNull(payloadString(source, "error")); payload.put("taskId", handoffId); payload.put("callId", handoffId); payload.put("tool", payloadString(source, "tool")); payload.put("subagent", payloadString(source, "subagent")); payload.put("title", firstNonBlank(payloadString(source, "title"), "Subagent task")); payload.put("detail", detail); payload.put("status", payloadString(source, "status")); payload.put("sessionMode", payloadString(source, "sessionMode")); payload.put("depth", payloadValue(source, "depth")); payload.put("attempts", payloadValue(source, "attempts")); payload.put("durationMillis", payloadValue(source, "durationMillis")); payload.put("output", output); payload.put("error", error); payload.put("previewLines", previewLines(firstNonBlank(error, output, detail))); return payload; } private static Map payload(AgentEvent event) { Object payload = event == null ? null : event.getPayload(); if (payload instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) payload; return map; } return new LinkedHashMap(); } private static Object payloadValue(Map payload, String key) { return payload == null || key == null ? null : payload.get(key); } private static String payloadString(Map payload, String key) { Object value = payloadValue(payload, key); return value == null ? null : String.valueOf(value); } private static List previewLines(String raw) { List lines = new ArrayList(); if (isBlank(raw)) { return lines; } String[] split = raw.replace("\r", "").split("\n"); int max = Math.min(4, split.length); for (int i = 0; i < max; i++) { String line = trimToNull(split[i]); if (line != null) { lines.add(line); } } return lines; } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private static String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamMessageSessionEventSupport.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public final class AgentTeamMessageSessionEventSupport { private AgentTeamMessageSessionEventSupport() { } public static boolean supports(AgentEvent event) { return event != null && event.getType() == AgentEventType.TEAM_MESSAGE; } public static SessionEvent toSessionEvent(String sessionId, String turnId, AgentEvent event) { if (!supports(event) || isBlank(sessionId)) { return null; } return SessionEvent.builder() .eventId(UUID.randomUUID().toString()) .sessionId(sessionId) .type(SessionEventType.TEAM_MESSAGE) .timestamp(System.currentTimeMillis()) .turnId(turnId) .step(event.getStep()) .summary(buildSummary(event)) .payload(buildPayload(event)) .build(); } public static String buildSummary(AgentEvent event) { Map payload = payload(event); String messageType = firstNonBlank(payloadString(payload, "type"), "message"); String route = route(payloadString(payload, "fromMemberId"), payloadString(payload, "toMemberId")); if (isBlank(route)) { return "Team message [" + messageType + "]"; } return "Team message " + route + " [" + messageType + "]"; } public static Map buildPayload(AgentEvent event) { Map source = payload(event); Map payload = new LinkedHashMap(); String content = trimToNull(firstNonBlank(payloadString(source, "content"), payloadString(source, "detail"))); String messageType = firstNonBlank(payloadString(source, "type"), "message"); String taskId = trimToNull(payloadString(source, "taskId")); payload.put("messageId", payloadValue(source, "messageId")); payload.put("taskId", taskId); payload.put("callId", taskId == null ? null : "team-task:" + taskId); payload.put("fromMemberId", payloadValue(source, "fromMemberId")); payload.put("toMemberId", payloadValue(source, "toMemberId")); payload.put("messageType", messageType); payload.put("title", "Team message"); payload.put("detail", content); payload.put("content", content); payload.put("createdAt", payloadValue(source, "createdAt")); payload.put("previewLines", previewLines(content)); return payload; } private static Map payload(AgentEvent event) { Object payload = event == null ? null : event.getPayload(); if (payload instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) payload; return map; } return new LinkedHashMap(); } private static Object payloadValue(Map payload, String key) { return payload == null || key == null ? null : payload.get(key); } private static String payloadString(Map payload, String key) { Object value = payloadValue(payload, key); return value == null ? null : String.valueOf(value); } private static String route(String fromMemberId, String toMemberId) { String from = trimToNull(fromMemberId); String to = trimToNull(toMemberId); if (from == null && to == null) { return null; } return firstNonBlank(from, "?") + " -> " + firstNonBlank(to, "?"); } private static List previewLines(String raw) { List lines = new ArrayList(); if (isBlank(raw)) { return lines; } String[] split = raw.replace("\r", "").split("\n"); int max = Math.min(4, split.length); for (int i = 0; i < max; i++) { String line = trimToNull(split[i]); if (line != null) { lines.add(line); } } return lines; } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private static String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamSessionEventSupport.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public final class AgentTeamSessionEventSupport { private AgentTeamSessionEventSupport() { } public static boolean supports(AgentEvent event) { if (event == null || event.getType() == null) { return false; } return event.getType() == AgentEventType.TEAM_TASK_CREATED || event.getType() == AgentEventType.TEAM_TASK_UPDATED; } public static SessionEventType resolveSessionEventType(AgentEvent event) { if (!supports(event)) { return null; } return event.getType() == AgentEventType.TEAM_TASK_CREATED ? SessionEventType.TASK_CREATED : SessionEventType.TASK_UPDATED; } public static SessionEvent toSessionEvent(String sessionId, String turnId, AgentEvent event) { SessionEventType type = resolveSessionEventType(event); if (type == null || isBlank(sessionId)) { return null; } return SessionEvent.builder() .eventId(UUID.randomUUID().toString()) .sessionId(sessionId) .type(type) .timestamp(System.currentTimeMillis()) .turnId(turnId) .step(event == null ? null : event.getStep()) .summary(buildSummary(event)) .payload(buildPayload(event)) .build(); } public static String buildSummary(AgentEvent event) { Map payload = payload(event); String title = firstNonBlank(payloadString(payload, "title"), event == null ? null : event.getMessage(), "Team task"); String status = firstNonBlank(payloadString(payload, "status"), event == null || event.getType() == null ? null : event.getType().name().toLowerCase()); return title + " [" + status + "]"; } public static Map buildPayload(AgentEvent event) { Map source = payload(event); Map payload = new LinkedHashMap(); String taskId = firstNonBlank(payloadString(source, "taskId"), payloadString(source, "callId")); String callId = firstNonBlank(payloadString(source, "callId"), taskId); String detail = firstNonBlank(payloadString(source, "detail"), payloadString(source, "error"), payloadString(source, "output")); String output = trimToNull(payloadString(source, "output")); String error = trimToNull(payloadString(source, "error")); payload.put("taskId", taskId); payload.put("callId", callId); payload.put("tool", firstNonBlank(payloadString(source, "tool"), "team")); payload.put("title", firstNonBlank(payloadString(source, "title"), "Team task")); payload.put("detail", detail); payload.put("status", payloadString(source, "status")); payload.put("phase", payloadValue(source, "phase")); payload.put("percent", payloadValue(source, "percent")); payload.put("updatedAtEpochMs", payloadValue(source, "updatedAtEpochMs")); payload.put("heartbeatCount", payloadValue(source, "heartbeatCount")); payload.put("startTime", payloadValue(source, "startTime")); payload.put("endTime", payloadValue(source, "endTime")); payload.put("lastHeartbeatTime", payloadValue(source, "lastHeartbeatTime")); payload.put("memberId", payloadValue(source, "memberId")); payload.put("memberName", payloadValue(source, "memberName")); payload.put("task", payloadValue(source, "task")); payload.put("context", payloadValue(source, "context")); payload.put("dependsOn", payloadValue(source, "dependsOn")); payload.put("durationMillis", payloadValue(source, "durationMillis")); payload.put("output", output); payload.put("error", error); payload.put("previewLines", previewLines(firstNonBlank(error, output, detail))); return payload; } private static Map payload(AgentEvent event) { Object payload = event == null ? null : event.getPayload(); if (payload instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) payload; return map; } return new LinkedHashMap(); } private static Object payloadValue(Map payload, String key) { return payload == null || key == null ? null : payload.get(key); } private static String payloadString(Map payload, String key) { Object value = payloadValue(payload, key); return value == null ? null : String.valueOf(value); } private static List previewLines(String raw) { List lines = new ArrayList(); if (isBlank(raw)) { return lines; } String[] split = raw.replace("\r", "").split("\n"); int max = Math.min(4, split.length); for (int i = 0; i < max; i++) { String line = trimToNull(split[i]); if (line != null) { lines.add(line); } } return lines; } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private static String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliTeamStateManager.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.TimeZone; public final class CliTeamStateManager { private static final int DEFAULT_MESSAGE_LIMIT = 20; private final Path workspaceRoot; private final Path teamDirectory; private final FileAgentTeamStateStore stateStore; public CliTeamStateManager(Path workspaceRoot) { Path normalizedRoot = workspaceRoot == null ? Paths.get(".").toAbsolutePath().normalize() : workspaceRoot.toAbsolutePath().normalize(); this.workspaceRoot = normalizedRoot; this.teamDirectory = normalizedRoot.resolve(".ai4j").resolve("teams"); this.stateStore = new FileAgentTeamStateStore(teamDirectory.resolve("state")); } public List listKnownTeamIds() { List states = listStates(); if (states.isEmpty()) { return Collections.emptyList(); } List ids = new ArrayList(); for (AgentTeamState state : states) { if (state == null || isBlank(state.getTeamId())) { continue; } ids.add(state.getTeamId()); } return ids; } public String renderListOutput() { List states = listStates(); if (states.isEmpty()) { return "teams: (none)"; } StringBuilder builder = new StringBuilder("teams:\n"); for (AgentTeamState rawState : states) { AgentTeamState state = hydrate(rawState); TaskSummary summary = summarizeTasks(state == null ? null : state.getTaskStates()); int messageCount = state == null || state.getMessages() == null ? 0 : state.getMessages().size(); builder.append("- ").append(firstNonBlank(state == null ? null : state.getTeamId(), "(team)")) .append(" | updated=").append(formatTimestamp(state == null ? 0L : state.getUpdatedAt())) .append(" | active=").append(state != null && state.isRunActive() ? "yes" : "no") .append(" | tasks=").append(summary.total) .append(" (running=").append(summary.running) .append(", completed=").append(summary.completed) .append(", failed=").append(summary.failed) .append(", blocked=").append(summary.blocked).append(')') .append(" | messages=").append(messageCount); String objective = clip(state == null ? null : state.getObjective(), 64); if (!isBlank(objective)) { builder.append(" | objective=").append(objective); } builder.append('\n'); } return builder.toString().trim(); } public String renderStatusOutput(String requestedTeamId) { ResolvedTeamState resolved = resolveState(requestedTeamId); if (resolved == null || resolved.state == null) { return teamMissingOutput(requestedTeamId); } AgentTeamState state = resolved.state; TaskSummary summary = summarizeTasks(state.getTaskStates()); int messageCount = state.getMessages() == null ? 0 : state.getMessages().size(); int memberCount = state.getMembers() == null ? 0 : state.getMembers().size(); StringBuilder builder = new StringBuilder("team status:\n"); builder.append("- teamId=").append(resolved.teamId).append('\n'); builder.append("- storage=").append(teamDirectory).append('\n'); builder.append("- updated=").append(formatTimestamp(state.getUpdatedAt())) .append(", active=").append(state.isRunActive() ? "yes" : "no").append('\n'); builder.append("- objective=").append(firstNonBlank(clip(state.getObjective(), 220), "(none)")).append('\n'); builder.append("- members=").append(memberCount) .append(", rounds=").append(state.getLastRounds()) .append(", messages=").append(messageCount).append('\n'); builder.append("- tasks=").append(summary.total) .append(" (pending=").append(summary.pending) .append(", ready=").append(summary.ready) .append(", running=").append(summary.running) .append(", completed=").append(summary.completed) .append(", failed=").append(summary.failed) .append(", blocked=").append(summary.blocked).append(')').append('\n'); builder.append("- lastRunStartedAt=").append(formatTimestamp(state.getLastRunStartedAt())) .append(", lastRunCompletedAt=").append(formatTimestamp(state.getLastRunCompletedAt())).append('\n'); builder.append("- lastOutput=").append(firstNonBlank(clip(state.getLastOutput(), 220), "(none)")); return builder.toString().trim(); } public String renderMessagesOutput(String requestedTeamId, Integer limit) { ResolvedTeamState resolved = resolveState(requestedTeamId); if (resolved == null || resolved.state == null) { return teamMissingOutput(requestedTeamId); } int safeLimit = limit == null || limit.intValue() <= 0 ? DEFAULT_MESSAGE_LIMIT : limit.intValue(); List messages = loadMessages(resolved.teamId, resolved.state); if (messages.isEmpty()) { return "team messages:\n- teamId=" + resolved.teamId + "\n- count=0"; } Collections.sort(messages, new Comparator() { @Override public int compare(AgentTeamMessage left, AgentTeamMessage right) { long l = left == null ? 0L : left.getCreatedAt(); long r = right == null ? 0L : right.getCreatedAt(); return l == r ? 0 : (l < r ? 1 : -1); } }); if (messages.size() > safeLimit) { messages = new ArrayList(messages.subList(0, safeLimit)); } StringBuilder builder = new StringBuilder("team messages:\n"); builder.append("- teamId=").append(resolved.teamId).append('\n'); builder.append("- count=").append(messages.size()).append('\n'); for (AgentTeamMessage message : messages) { if (message == null) { continue; } builder.append("- ").append(formatTimestamp(message.getCreatedAt())) .append(" | ").append(firstNonBlank(message.getFromMemberId(), "?")) .append(" -> ").append(firstNonBlank(message.getToMemberId(), "*")); if (!isBlank(message.getType())) { builder.append(" [").append(message.getType()).append(']'); } if (!isBlank(message.getTaskId())) { builder.append(" | task=").append(message.getTaskId()); } if (!isBlank(message.getContent())) { builder.append(" | ").append(clip(singleLine(message.getContent()), 120)); } builder.append('\n'); } return builder.toString().trim(); } public ResolvedTeamState resolveState(String requestedTeamId) { String normalized = trimToNull(requestedTeamId); AgentTeamState state = normalized == null ? latestState() : stateStore.load(normalized); if (state == null || isBlank(state.getTeamId())) { return null; } AgentTeamState hydrated = hydrate(state); return new ResolvedTeamState(hydrated.getTeamId(), hydrated); } public List renderBoardLines(String requestedTeamId) { ResolvedTeamState resolved = resolveState(requestedTeamId); if (resolved == null || resolved.state == null) { return Collections.emptyList(); } return TeamBoardRenderSupport.renderBoardLines(resolved.state); } public String renderResumeOutput(String requestedTeamId) { ResolvedTeamState resolved = resolveState(requestedTeamId); if (resolved == null || resolved.state == null) { return teamMissingOutput(requestedTeamId); } List boardLines = TeamBoardRenderSupport.renderBoardLines(resolved.state); StringBuilder builder = new StringBuilder("team resumed: ").append(resolved.teamId); if (!boardLines.isEmpty()) { builder.append('\n').append(TeamBoardRenderSupport.renderBoardOutput(boardLines)); } return builder.toString().trim(); } private List listStates() { return stateStore.list(); } private AgentTeamState latestState() { List states = listStates(); return states.isEmpty() ? null : states.get(0); } private AgentTeamState hydrate(AgentTeamState state) { if (state == null || isBlank(state.getTeamId())) { return state; } return state.toBuilder() .messages(loadMessages(state.getTeamId(), state)) .build(); } private List loadMessages(String teamId, AgentTeamState fallbackState) { if (isBlank(teamId)) { return fallbackState == null || fallbackState.getMessages() == null ? Collections.emptyList() : new ArrayList(fallbackState.getMessages()); } Path mailboxFile = teamDirectory.resolve("mailbox").resolve(teamId + ".jsonl"); if (Files.exists(mailboxFile)) { return new FileAgentTeamMessageBus(mailboxFile).snapshot(); } return fallbackState == null || fallbackState.getMessages() == null ? Collections.emptyList() : new ArrayList(fallbackState.getMessages()); } private TaskSummary summarizeTasks(List taskStates) { TaskSummary summary = new TaskSummary(); if (taskStates == null || taskStates.isEmpty()) { return summary; } for (AgentTeamTaskState taskState : taskStates) { if (taskState == null || taskState.getStatus() == null) { continue; } summary.total++; AgentTeamTaskStatus status = taskState.getStatus(); if (status == AgentTeamTaskStatus.PENDING) { summary.pending++; } else if (status == AgentTeamTaskStatus.READY) { summary.ready++; } else if (status == AgentTeamTaskStatus.IN_PROGRESS) { summary.running++; } else if (status == AgentTeamTaskStatus.COMPLETED) { summary.completed++; } else if (status == AgentTeamTaskStatus.FAILED) { summary.failed++; } else if (status == AgentTeamTaskStatus.BLOCKED) { summary.blocked++; } } return summary; } private String teamMissingOutput(String requestedTeamId) { String normalized = trimToNull(requestedTeamId); return normalized == null ? "team: (none)" : "team not found: " + normalized; } private String formatTimestamp(long epochMillis) { if (epochMillis <= 0L) { return "(none)"; } SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT); format.setTimeZone(TimeZone.getDefault()); return format.format(new Date(epochMillis)); } private String singleLine(String value) { if (value == null) { return null; } return value.replace('\r', ' ').replace('\n', ' ').trim(); } private String clip(String value, int maxChars) { String normalized = trimToNull(value); if (normalized == null || normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, Math.max(0, maxChars)) + "..."; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } public static final class ResolvedTeamState { private final String teamId; private final AgentTeamState state; private ResolvedTeamState(String teamId, AgentTeamState state) { this.teamId = teamId; this.state = state; } public String getTeamId() { return teamId; } public AgentTeamState getState() { return state; } } private static final class TaskSummary { private int total; private int pending; private int ready; private int running; private int completed; private int failed; private int blocked; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliToolApprovalDecorator.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.render.CodexStyleBlockFormatter; import io.github.lnyocly.ai4j.cli.render.PatchSummaryFormatter; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import java.util.ArrayList; import java.util.List; public class CliToolApprovalDecorator implements ToolExecutorDecorator { public static final String APPROVAL_REJECTED_PREFIX = "[approval-rejected]"; private static final CodexStyleBlockFormatter APPROVAL_BLOCK_FORMATTER = new CodexStyleBlockFormatter(120, 4); private final ApprovalMode approvalMode; private final TerminalIO terminal; private final TuiInteractionState interactionState; public CliToolApprovalDecorator(ApprovalMode approvalMode, TerminalIO terminal) { this(approvalMode, terminal, null); } public CliToolApprovalDecorator(ApprovalMode approvalMode, TerminalIO terminal, TuiInteractionState interactionState) { this.approvalMode = approvalMode == null ? ApprovalMode.AUTO : approvalMode; this.terminal = terminal; this.interactionState = interactionState; } @Override public ToolExecutor decorate(final String toolName, final ToolExecutor delegate) { if (delegate == null || approvalMode == ApprovalMode.AUTO) { return delegate; } return new ToolExecutor() { @Override public String execute(AgentToolCall call) throws Exception { if (requiresApproval(toolName, call)) { requestApproval(toolName, call); } return delegate.execute(call); } }; } private boolean requiresApproval(String toolName, AgentToolCall call) { if (approvalMode == ApprovalMode.MANUAL) { return true; } if (CodingToolNames.APPLY_PATCH.equals(toolName)) { return true; } if (!CodingToolNames.BASH.equals(toolName)) { return false; } JSONObject arguments = parseArguments(call == null ? null : call.getArguments()); String action = arguments.getString("action"); if (isBlank(action)) { action = "exec"; } return "exec".equals(action) || "start".equals(action) || "stop".equals(action) || "write".equals(action); } private void requestApproval(String toolName, AgentToolCall call) { if (terminal == null) { throw new IllegalStateException("Tool call requires approval but no terminal is available"); } JSONObject arguments = parseArguments(call == null ? null : call.getArguments()); String summary = summarize(toolName, arguments); if (interactionState != null) { interactionState.showApproval(approvalMode.getValue(), toolName, summary); } printApprovalBlock(toolName, arguments, summary); String answer; try { answer = terminal.readLine("• Approve? [y/N] "); } catch (java.io.IOException ex) { if (interactionState != null) { interactionState.resolveApproval(toolName, false); } throw new IllegalStateException("Failed to read approval input", ex); } boolean approved = answer != null && (answer.trim().equalsIgnoreCase("y") || answer.trim().equalsIgnoreCase("yes")); if (interactionState != null) { interactionState.resolveApproval(toolName, approved); } printApprovalResolution(toolName, arguments, approved); if (!approved) { throw new IllegalStateException(buildApprovalRejectedMessage(toolName, arguments)); } } private void printApprovalBlock(String toolName, JSONObject arguments, String summary) { List lines = new ArrayList(); lines.add("• Approval required for " + firstNonBlank(toolName, "tool")); if (CodingToolNames.BASH.equals(toolName)) { appendBashApprovalLines(lines, arguments); } else if (CodingToolNames.APPLY_PATCH.equals(toolName)) { appendPatchApprovalLines(lines, arguments); } else { lines.add(" └ " + clip(summary, 120)); } for (String line : lines) { terminal.println(line); } } private void appendBashApprovalLines(List lines, JSONObject arguments) { String action = firstNonBlank(arguments.getString("action"), "exec"); String cwd = defaultText(arguments.getString("cwd"), "."); String command = safeTrimToNull(arguments.getString("command")); String processId = safeTrimToNull(arguments.getString("processId")); if ("exec".equals(action) || "start".equals(action)) { lines.add(" └ " + action + " in " + clip(cwd, 72)); if (!isBlank(command)) { lines.add(" " + clip(command, 120)); } return; } lines.add(" └ " + action + " process " + defaultText(processId, "(process)")); if (!isBlank(command)) { lines.add(" " + clip(command, 120)); } } private void appendPatchApprovalLines(List lines, JSONObject arguments) { String patch = arguments.getString("patch"); List changes = summarizePatchChanges(patch); if (changes.isEmpty()) { lines.add(" └ " + clip(defaultText(patch, "(empty patch)"), 120)); return; } for (int i = 0; i < changes.size(); i++) { lines.add((i == 0 ? " └ " : " ") + changes.get(i)); } } private String summarize(String toolName, JSONObject arguments) { if (CodingToolNames.BASH.equals(toolName)) { String action = arguments.getString("action"); if (isBlank(action)) { action = "exec"; } return "action=" + action + ", cwd=" + defaultText(arguments.getString("cwd"), ".") + ", command=" + clip(arguments.getString("command"), 120) + ", processId=" + defaultText(arguments.getString("processId"), "-"); } if (CodingToolNames.APPLY_PATCH.equals(toolName)) { return "patch=" + clip(arguments.getString("patch"), 120); } return "args=" + clip(arguments.toJSONString(), 120); } private void printApprovalResolution(String toolName, JSONObject arguments, boolean approved) { List lines = APPROVAL_BLOCK_FORMATTER.formatInfoBlock( approved ? "Approved" : "Rejected", summarizeApprovalResolution(toolName, arguments) ); for (String line : lines) { terminal.println(line); } } private List summarizeApprovalResolution(String toolName, JSONObject arguments) { List lines = new ArrayList(); if (CodingToolNames.BASH.equals(toolName)) { String action = firstNonBlank(arguments.getString("action"), "exec"); String command = safeTrimToNull(arguments.getString("command")); String processId = safeTrimToNull(arguments.getString("processId")); if ("exec".equals(action) || "start".equals(action)) { lines.add(isBlank(command) ? "bash " + action : command); return lines; } if ("stop".equals(action)) { lines.add("Stop process " + defaultText(processId, "(process)")); return lines; } if ("write".equals(action)) { lines.add("Write to process " + defaultText(processId, "(process)")); return lines; } lines.add("bash " + action); return lines; } if (CodingToolNames.APPLY_PATCH.equals(toolName)) { lines.addAll(summarizePatchChanges(arguments.getString("patch"))); if (!lines.isEmpty()) { return lines; } lines.add("apply_patch"); return lines; } lines.add(firstNonBlank(summarize(toolName, arguments), firstNonBlank(toolName, "tool"))); return lines; } private String buildApprovalRejectedMessage(String toolName, JSONObject arguments) { List lines = summarizeApprovalResolution(toolName, arguments); String detail = lines.isEmpty() ? firstNonBlank(toolName, "tool") : lines.get(0); return APPROVAL_REJECTED_PREFIX + " " + detail; } private List summarizePatchChanges(String patch) { return new ArrayList(PatchSummaryFormatter.summarizePatchRequest(patch, 4)); } private JSONObject parseArguments(String rawArguments) { if (isBlank(rawArguments)) { return new JSONObject(); } try { JSONObject arguments = JSON.parseObject(rawArguments); return arguments == null ? new JSONObject() : arguments; } catch (Exception ex) { return new JSONObject(); } } private String defaultText(String value, String defaultValue) { return isBlank(value) ? defaultValue : value; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private String safeTrimToNull(String value) { return isBlank(value) ? null : value.trim(); } private String clip(String value, int maxChars) { if (value == null) { return ""; } String normalized = value.replace('\r', ' ').replace('\n', ' ').trim(); if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, maxChars) + "..."; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CodingCliSessionRunner.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.CliUiMode; import io.github.lnyocly.ai4j.cli.SlashCommandController; import io.github.lnyocly.ai4j.cli.agent.CliCodingAgentRegistry; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry; import io.github.lnyocly.ai4j.cli.command.CustomCommandTemplate; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.factory.CodingCliTuiFactory; import io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliTuiFactory; import io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.mcp.CliMcpConfig; import io.github.lnyocly.ai4j.cli.mcp.CliMcpConfigManager; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.mcp.CliMcpServerDefinition; import io.github.lnyocly.ai4j.cli.mcp.CliMcpStatusSnapshot; import io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig; import io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpServer; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.cli.provider.CliProviderProfile; import io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig; import io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig; import io.github.lnyocly.ai4j.cli.render.AssistantTranscriptRenderer; import io.github.lnyocly.ai4j.cli.render.CliDisplayWidth; import io.github.lnyocly.ai4j.cli.render.CliThemeStyler; import io.github.lnyocly.ai4j.cli.render.CodexStyleBlockFormatter; import io.github.lnyocly.ai4j.cli.render.PatchSummaryFormatter; import io.github.lnyocly.ai4j.cli.render.TranscriptPrinter; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.cli.session.StoredCodingSession; import io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.coding.CodingAgentResult; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.coding.CodingAgentRequest; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpointFormatter; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.coding.CodingSessionSnapshot; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision; import io.github.lnyocly.ai4j.coding.loop.CodingStopReason; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.process.BashProcessStatus; import io.github.lnyocly.ai4j.coding.runtime.CodingRuntime; import io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiAssistantPhase; import io.github.lnyocly.ai4j.tui.TuiAssistantToolView; import io.github.lnyocly.ai4j.tui.TuiAssistantViewModel; import io.github.lnyocly.ai4j.tui.TuiConfig; import io.github.lnyocly.ai4j.tui.TuiConfigManager; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import io.github.lnyocly.ai4j.tui.TuiKeyStroke; import io.github.lnyocly.ai4j.tui.TuiKeyType; import io.github.lnyocly.ai4j.tui.TuiPaletteItem; import io.github.lnyocly.ai4j.tui.TuiRenderContext; import io.github.lnyocly.ai4j.tui.TuiRenderer; import io.github.lnyocly.ai4j.tui.TuiRuntime; import io.github.lnyocly.ai4j.tui.TuiScreenModel; import io.github.lnyocly.ai4j.tui.AnsiTuiRuntime; import io.github.lnyocly.ai4j.tui.AppendOnlyTuiRuntime; import io.github.lnyocly.ai4j.tui.TuiTheme; import io.github.lnyocly.ai4j.tui.TuiSessionView; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.LinkedHashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.TimeZone; import java.util.UUID; public class CodingCliSessionRunner { private static final int DEFAULT_EVENT_LIMIT = 12; private static final int DEFAULT_PROCESS_LOG_LIMIT = 800; private static final int DEFAULT_REPLAY_LIMIT = 40; private static final long PROCESS_FOLLOW_POLL_MS = 250L; private static final int PROCESS_FOLLOW_MAX_IDLE_POLLS = 8; private static final long TUI_TURN_ANIMATION_POLL_MS = 160L; private static final long NON_ALTERNATE_SCREEN_RENDER_THROTTLE_MS = 120L; private static final String TURN_INTERRUPTED_MESSAGE = "Conversation interrupted by user."; private CodingAgent agent; private CliProtocol protocol; private CodeCommandOptions options; private final TerminalIO terminal; private final CodingSessionManager sessionManager; private final TuiConfigManager tuiConfigManager; private final CliProviderConfigManager providerConfigManager; private final CliMcpConfigManager mcpConfigManager; private final CustomCommandRegistry customCommandRegistry; private final TuiInteractionState interactionState; private final TuiRenderer tuiRenderer; private final TuiRuntime tuiRuntime; private final CodingCliAgentFactory agentFactory; private final Map env; private final Properties properties; private TuiConfig tuiConfig; private TuiTheme tuiTheme; private final CodexStyleBlockFormatter codexStyleBlockFormatter = new CodexStyleBlockFormatter(120, 4); private final AssistantTranscriptRenderer assistantTranscriptRenderer = new AssistantTranscriptRenderer(); private final Set pausedMcpServers = new LinkedHashSet(); private List tuiSessions = new ArrayList(); private List tuiHistory = new ArrayList(); private List tuiTree = new ArrayList(); private List tuiCommands = new ArrayList(); private List tuiEvents = new ArrayList(); private List tuiReplay = new ArrayList(); private List tuiTeamBoard = new ArrayList(); private String selectedPersistedTeamId; private boolean tuiPersistedTeamBoard; private BashProcessInfo tuiInspectedProcess; private BashProcessLogChunk tuiInspectedProcessLogs; private String tuiAssistantOutput; private final TuiLiveTurnState tuiLiveTurnState = new TuiLiveTurnState(); private final MainBufferTurnPrinter mainBufferTurnPrinter = new MainBufferTurnPrinter(); private final Object mainBufferTurnInterruptLock = new Object(); private volatile boolean tuiTurnAnimationRunning; private Thread tuiTurnAnimationThread; private volatile long lastNonAlternateScreenRenderAtMs; private ManagedCodingSession activeSession; private CliMcpRuntimeManager mcpRuntimeManager; private boolean streamEnabled = false; private volatile ActiveTuiTurn activeTuiTurn; private volatile Thread activeMainBufferTurnThread; private volatile String activeMainBufferTurnId; private volatile boolean activeMainBufferTurnInterrupted; private CodingRuntime bridgedRuntime; private CodingTaskSessionEventBridge codingTaskEventBridge; public CodingCliSessionRunner(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, TerminalIO terminal, CodingSessionManager sessionManager, TuiInteractionState interactionState) { this(agent, protocol, options, terminal, sessionManager, interactionState, new DefaultCodingCliTuiFactory(), null, null, null, null); } public CodingCliSessionRunner(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, TerminalIO terminal, CodingSessionManager sessionManager, TuiInteractionState interactionState, SlashCommandController slashCommandController) { this(agent, protocol, options, terminal, sessionManager, interactionState, new DefaultCodingCliTuiFactory(), slashCommandController, null, null, null); } public CodingCliSessionRunner(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, TerminalIO terminal, CodingSessionManager sessionManager, TuiInteractionState interactionState, CodingCliTuiFactory tuiFactory) { this(agent, protocol, options, terminal, sessionManager, interactionState, tuiFactory, null, null, null, null); } public CodingCliSessionRunner(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, TerminalIO terminal, CodingSessionManager sessionManager, TuiInteractionState interactionState, CodingCliTuiFactory tuiFactory, SlashCommandController slashCommandController, CodingCliAgentFactory agentFactory, Map env, Properties properties) { this(agent, protocol, options, terminal, sessionManager, interactionState, tuiFactory, slashCommandController, agentFactory, env, properties, true); } private CodingCliSessionRunner(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, TerminalIO terminal, CodingSessionManager sessionManager, TuiInteractionState interactionState, CodingCliTuiFactory tuiFactory, SlashCommandController slashCommandController, CodingCliAgentFactory agentFactory, Map env, Properties properties, boolean unused) { this.agent = agent; this.protocol = protocol; this.options = options; this.streamEnabled = options != null && options.isStream(); this.terminal = terminal; this.sessionManager = sessionManager; this.tuiConfigManager = new TuiConfigManager(java.nio.file.Paths.get(options.getWorkspace())); this.providerConfigManager = new CliProviderConfigManager(java.nio.file.Paths.get(options.getWorkspace())); this.mcpConfigManager = new CliMcpConfigManager(java.nio.file.Paths.get(options.getWorkspace())); this.customCommandRegistry = new CustomCommandRegistry(java.nio.file.Paths.get(options.getWorkspace())); this.interactionState = interactionState == null ? new TuiInteractionState() : interactionState; this.agentFactory = agentFactory; this.env = env == null ? Collections.emptyMap() : new LinkedHashMap(env); this.properties = properties == null ? new Properties() : properties; attachCodingTaskEventBridge(); if (options.getUiMode() == CliUiMode.TUI) { CodingCliTuiSupport tuiSupport = (tuiFactory == null ? new DefaultCodingCliTuiFactory() : tuiFactory) .create(options, terminal, tuiConfigManager); this.tuiConfig = tuiSupport == null || tuiSupport.getConfig() == null ? new TuiConfig() : tuiSupport.getConfig(); this.tuiTheme = tuiSupport == null || tuiSupport.getTheme() == null ? new TuiTheme() : tuiSupport.getTheme(); this.tuiRenderer = tuiSupport == null ? null : tuiSupport.getRenderer(); this.tuiRuntime = tuiSupport == null ? null : tuiSupport.getRuntime(); this.interactionState.setRenderCallback(new Runnable() { @Override public void run() { if (activeSession != null) { renderTuiFromCache(activeSession); } } }); } else { this.tuiRenderer = null; this.tuiRuntime = null; } if (terminal instanceof JlineShellTerminalIO) { ((JlineShellTerminalIO) terminal).updateTheme(tuiTheme); } if (slashCommandController != null) { slashCommandController.setProcessCandidateSupplier( new java.util.function.Supplier>() { @Override public List get() { return buildProcessCompletionCandidates(); } } ); slashCommandController.setProfileCandidateSupplier(new java.util.function.Supplier>() { @Override public List get() { return providerConfigManager.listProfileNames(); } }); slashCommandController.setModelCandidateSupplier( new java.util.function.Supplier>() { @Override public List get() { return buildModelCompletionCandidates(); } } ); slashCommandController.setMcpServerCandidateSupplier(new java.util.function.Supplier>() { @Override public List get() { return listKnownMcpServerNames(); } }); slashCommandController.setSkillCandidateSupplier(new java.util.function.Supplier>() { @Override public List get() { return listKnownSkillNames(); } }); slashCommandController.setAgentCandidateSupplier(new java.util.function.Supplier>() { @Override public List get() { return listKnownAgentNames(); } }); slashCommandController.setTeamCandidateSupplier(new java.util.function.Supplier>() { @Override public List get() { return listKnownTeamIds(); } }); } } public int run() throws Exception { ManagedCodingSession session = openInitialSession(); activeSession = session; boolean mainBufferInteractive = useMainBufferInteractiveShell(); boolean interactiveTui = options.getUiMode() == CliUiMode.TUI && tuiRuntime != null && tuiRuntime.supportsRawInput() && !mainBufferInteractive && isBlank(options.getPrompt()); try { if (options.getUiMode() == CliUiMode.TUI && isBlank(options.getPrompt()) && !interactiveTui && !mainBufferInteractive) { terminal.errorln("Interactive TUI input is unavailable on this terminal, falling back to CLI mode."); } if (!interactiveTui) { printSessionHeader(session); } if (!isBlank(options.getPrompt())) { runTurn(session, options.getPrompt()); return 0; } if (mainBufferInteractive) { return runCliLoop(session); } if (interactiveTui) { return runTuiLoop(session); } terminal.println("Use /help for in-session commands, /exit or /quit to leave.\n"); return runCliLoop(session); } finally { detachCodingTaskEventBridge(); closeQuietly(activeSession); closeMcpRuntimeQuietly(mcpRuntimeManager); persistSession(activeSession, false); } } public void setMcpRuntimeManager(CliMcpRuntimeManager mcpRuntimeManager) { this.mcpRuntimeManager = mcpRuntimeManager; } private int runCliLoop(ManagedCodingSession session) throws Exception { while (true) { // Keep readLine and transcript output on the same thread. When the // prompt lived on a background thread, JLine could still report // itself as "reading" for a short window after Enter, which routed // the next assistant block through printAbove() and created the // blank region the user was seeing before the actual text. String input = terminal.readLine("> "); if (input == null) { terminal.println(""); return 0; } if (isBlank(input)) { continue; } JlineShellTerminalIO shellTerminal = terminal instanceof JlineShellTerminalIO ? (JlineShellTerminalIO) terminal : null; if (shellTerminal != null && useMainBufferInteractiveShell()) { shellTerminal.beginDirectOutputWindow(); } try { DispatchResult result = dispatchInteractiveInput(session, input); session = result.getSession(); activeSession = session; if (result.isExitRequested()) { if (!useMainBufferInteractiveShell()) { terminal.println("Session closed."); } return 0; } } finally { if (shellTerminal != null && useMainBufferInteractiveShell()) { shellTerminal.endDirectOutputWindow(); } } } } private int runTuiLoop(ManagedCodingSession session) throws Exception { tuiRuntime.enter(); try { setTuiAssistantOutput("Ask AI4J to inspect this repository\nOpen the command palette with /\nReplay recent history with Ctrl+R\nOpen the team board with /team"); renderTui(session); while (true) { session = reapCompletedTuiTurn(session); activeSession = session; TuiKeyStroke keyStroke = tuiRuntime.readKeyStroke(TUI_TURN_ANIMATION_POLL_MS); if (keyStroke == null) { if (hasActiveTuiTurn()) { continue; } if (terminal != null && terminal.isInputClosed()) { return 0; } if (shouldAnimateAppendOnlyFooter() && tuiLiveTurnState.advanceAnimationTick()) { renderTuiFromCache(session); continue; } if (shouldAutoRefresh(session)) { renderTui(session); } continue; } DispatchResult result = handleTuiKey(session, keyStroke); session = result.getSession(); activeSession = session; if (result.isExitRequested()) { return 0; } } } finally { tuiRuntime.exit(); } } private DispatchResult handleTuiKey(ManagedCodingSession session, TuiKeyStroke keyStroke) throws Exception { if (keyStroke == null) { return DispatchResult.stay(session); } if (hasActiveTuiTurn()) { return handleActiveTuiTurnKey(session, keyStroke); } TuiKeyType keyType = keyStroke.getType(); if (interactionState.isPaletteOpen()) { if (interactionState.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) { switch (keyType) { case ESCAPE: interactionState.closePalette(); return DispatchResult.stay(session); case ARROW_UP: interactionState.movePaletteSelection(-1); return DispatchResult.stay(session); case ARROW_DOWN: interactionState.movePaletteSelection(1); return DispatchResult.stay(session); case BACKSPACE: backspaceInputAndRefreshSlashPalette(); return DispatchResult.stay(session); case TAB: applySlashSelection(); return DispatchResult.stay(session); case ENTER: TuiPaletteItem slashItem = interactionState.getSelectedPaletteItem(); if (slashItem != null) { interactionState.replaceInputBufferAndClosePalette(slashItem.getCommand()); return DispatchResult.stay(session); } interactionState.closePaletteSilently(); String slashInput = interactionState.consumeInputBufferSilently(); if (isBlank(slashInput)) { return DispatchResult.stay(session); } return dispatchInteractiveInput(session, slashInput); case CHARACTER: appendInputAndRefreshSlashPalette(keyStroke.getText()); return DispatchResult.stay(session); default: return DispatchResult.stay(session); } } switch (keyType) { case ESCAPE: interactionState.closePalette(); return DispatchResult.stay(session); case ARROW_UP: interactionState.movePaletteSelection(-1); return DispatchResult.stay(session); case ARROW_DOWN: interactionState.movePaletteSelection(1); return DispatchResult.stay(session); case BACKSPACE: interactionState.backspacePaletteQuery(); return DispatchResult.stay(session); case ENTER: TuiPaletteItem item = interactionState.getSelectedPaletteItem(); interactionState.closePalette(); return item == null ? DispatchResult.stay(session) : dispatchInteractiveInput(session, item.getCommand()); case CHARACTER: interactionState.appendPaletteQuery(keyStroke.getText()); return DispatchResult.stay(session); default: return DispatchResult.stay(session); } } if (keyType == TuiKeyType.CTRL_P) { interactionState.openPalette(buildPaletteItems(session)); return DispatchResult.stay(session); } if (keyType == TuiKeyType.CTRL_R) { if (interactionState.isReplayViewerOpen()) { interactionState.closeReplayViewer(); } else { openReplayViewer(session, DEFAULT_REPLAY_LIMIT); } return DispatchResult.stay(session); } if (keyType == TuiKeyType.CTRL_L) { renderTui(session); return DispatchResult.stay(session); } if (interactionState.isTeamBoardOpen()) { return handleTeamBoardKey(session, keyType); } if (interactionState.isReplayViewerOpen()) { return handleReplayViewerKey(session, keyType); } if (interactionState.isProcessInspectorOpen()) { return handleProcessInspectorKey(session, keyStroke); } switch (keyType) { case TAB: return DispatchResult.stay(session); case ARROW_UP: interactionState.moveTranscriptScroll(1); return DispatchResult.stay(session); case ARROW_DOWN: interactionState.moveTranscriptScroll(-1); return DispatchResult.stay(session); case ESCAPE: interactionState.clearInputBuffer(); closeSlashPaletteIfNeeded(); return DispatchResult.stay(session); case BACKSPACE: backspaceInputAndRefreshSlashPalette(); return DispatchResult.stay(session); case ENTER: if (interactionState.getFocusedPanel() == io.github.lnyocly.ai4j.tui.TuiPanelId.PROCESSES) { String processId = firstNonBlank(interactionState.getSelectedProcessId(), firstProcessId(session)); if (!isBlank(processId)) { openProcessInspector(session, processId); return DispatchResult.stay(session); } } String input = interactionState.consumeInputBufferSilently(); closeSlashPaletteIfNeededSilently(); if (isBlank(input)) { return DispatchResult.stay(session); } return dispatchInteractiveInput(session, input); case CHARACTER: appendInputAndRefreshSlashPalette(keyStroke.getText()); return DispatchResult.stay(session); default: return DispatchResult.stay(session); } } private DispatchResult handleActiveTuiTurnKey(ManagedCodingSession session, TuiKeyStroke keyStroke) { if (keyStroke == null || keyStroke.getType() == null) { return DispatchResult.stay(session); } if (keyStroke.getType() == TuiKeyType.ESCAPE) { interruptActiveTuiTurn(session); } return DispatchResult.stay(session); } private DispatchResult handleReplayViewerKey(ManagedCodingSession session, TuiKeyType keyType) { switch (keyType) { case ESCAPE: interactionState.closeReplayViewer(); return DispatchResult.stay(session); case ARROW_UP: interactionState.moveReplayScroll(-1); return DispatchResult.stay(session); case ARROW_DOWN: interactionState.moveReplayScroll(1); return DispatchResult.stay(session); default: return DispatchResult.stay(session); } } private DispatchResult handleTeamBoardKey(ManagedCodingSession session, TuiKeyType keyType) { switch (keyType) { case ESCAPE: interactionState.closeTeamBoard(); return DispatchResult.stay(session); case ARROW_UP: interactionState.moveTeamBoardScroll(-1); return DispatchResult.stay(session); case ARROW_DOWN: interactionState.moveTeamBoardScroll(1); return DispatchResult.stay(session); default: return DispatchResult.stay(session); } } private void appendInputAndRefreshSlashPalette(String text) { interactionState.appendInputAndSyncSlashPalette(text, buildCommandPaletteItems()); } private void backspaceInputAndRefreshSlashPalette() { interactionState.backspaceInputAndSyncSlashPalette(buildCommandPaletteItems()); } private void refreshSlashPalette() { interactionState.syncSlashPalette(buildCommandPaletteItems()); } private void closeSlashPaletteIfNeeded() { if (interactionState.isPaletteOpen() && interactionState.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) { interactionState.closePalette(); } } private void closeSlashPaletteIfNeededSilently() { if (interactionState.isPaletteOpen() && interactionState.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) { interactionState.closePaletteSilently(); } } private void applySlashSelection() { TuiPaletteItem item = interactionState.getSelectedPaletteItem(); if (item == null) { return; } interactionState.replaceInputBufferAndClosePalette(item.getCommand()); } private DispatchResult handleProcessInspectorKey(ManagedCodingSession session, TuiKeyStroke keyStroke) { TuiKeyType keyType = keyStroke == null ? null : keyStroke.getType(); switch (keyType) { case ESCAPE: interactionState.closeProcessInspector(); return DispatchResult.stay(session); case ARROW_UP: interactionState.selectAdjacentProcess(resolveProcessIds(session), -1); return DispatchResult.stay(session); case ARROW_DOWN: interactionState.selectAdjacentProcess(resolveProcessIds(session), 1); return DispatchResult.stay(session); case BACKSPACE: interactionState.backspaceProcessInput(); return DispatchResult.stay(session); case ENTER: writeSelectedProcessInput(session); return DispatchResult.stay(session); case CHARACTER: interactionState.appendProcessInput(keyStroke.getText()); return DispatchResult.stay(session); default: return DispatchResult.stay(session); } } private DispatchResult moveFocusedSelection(ManagedCodingSession session, int delta) { if (interactionState.getFocusedPanel() == io.github.lnyocly.ai4j.tui.TuiPanelId.PROCESSES) { interactionState.selectAdjacentProcess(resolveProcessIds(session), delta); } return DispatchResult.stay(session); } private DispatchResult dispatchInteractiveInput(ManagedCodingSession session, String input) throws Exception { if (isBlank(input)) { return DispatchResult.stay(session); } interactionState.resetTranscriptScroll(); String normalized = input.trim(); if ("/exit".equalsIgnoreCase(normalized) || "/quit".equalsIgnoreCase(normalized)) { return DispatchResult.exit(session); } if ("/help".equalsIgnoreCase(normalized)) { printSessionHelp(); return DispatchResult.stay(session); } if ("/status".equalsIgnoreCase(normalized)) { printStatus(session); return DispatchResult.stay(session); } if ("/session".equalsIgnoreCase(normalized)) { printCurrentSession(session); return DispatchResult.stay(session); } if ("/theme".equalsIgnoreCase(normalized)) { printThemes(); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/theme ")) { applyTheme(extractCommandArgument(normalized), session); return DispatchResult.stay(session); } if ("/save".equalsIgnoreCase(normalized)) { persistSession(session, true); return DispatchResult.stay(session); } if ("/providers".equalsIgnoreCase(normalized)) { printProviders(); renderTui(session); return DispatchResult.stay(session); } if ("/provider".equalsIgnoreCase(normalized)) { printCurrentProvider(); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/provider ")) { ManagedCodingSession switched = handleProviderCommand(session, extractCommandArgument(normalized)); activeSession = switched; renderTui(switched); return DispatchResult.stay(switched); } if ("/model".equalsIgnoreCase(normalized) || normalized.startsWith("/model ")) { ManagedCodingSession switched = handleModelCommand(session, extractCommandArgument(normalized)); activeSession = switched; renderTui(switched); return DispatchResult.stay(switched); } if ("/experimental".equalsIgnoreCase(normalized) || normalized.startsWith("/experimental ")) { ManagedCodingSession switched = handleExperimentalCommand(session, extractCommandArgument(normalized)); activeSession = switched; renderTui(switched); return DispatchResult.stay(switched); } if ("/skills".equalsIgnoreCase(normalized) || normalized.startsWith("/skills ")) { printSkills(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if ("/agents".equalsIgnoreCase(normalized) || normalized.startsWith("/agents ")) { printAgents(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if ("/mcp".equalsIgnoreCase(normalized) || normalized.startsWith("/mcp ")) { ManagedCodingSession switched = handleMcpCommand(session, extractCommandArgument(normalized)); activeSession = switched; renderTui(switched); return DispatchResult.stay(switched); } if ("/commands".equalsIgnoreCase(normalized) || "/palette".equalsIgnoreCase(normalized)) { printCommands(); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/cmd ")) { runCustomCommand(session, extractCommandArgument(normalized)); return DispatchResult.stay(session); } if ("/sessions".equalsIgnoreCase(normalized)) { printSessions(); renderTui(session); return DispatchResult.stay(session); } if ("/history".equalsIgnoreCase(normalized) || normalized.startsWith("/history ")) { printHistory(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if ("/tree".equalsIgnoreCase(normalized) || normalized.startsWith("/tree ")) { printTree(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/events")) { printEvents(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/replay")) { printReplay(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if ("/team".equalsIgnoreCase(normalized) || normalized.startsWith("/team ")) { handleTeamCommand(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/compacts")) { printCompacts(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if ("/stream".equalsIgnoreCase(normalized) || normalized.startsWith("/stream ")) { ManagedCodingSession switched = handleStreamCommand(session, extractCommandArgument(normalized)); activeSession = switched; renderTui(switched); return DispatchResult.stay(switched); } if ("/processes".equalsIgnoreCase(normalized)) { printProcesses(session); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/process ")) { handleProcessCommand(session, extractCommandArgument(normalized)); renderTui(session); return DispatchResult.stay(session); } if (normalized.startsWith("/checkpoint")) { printCheckpoint(session); return DispatchResult.stay(session); } if ("/clear".equalsIgnoreCase(normalized)) { printClearMarker(session); return DispatchResult.stay(session); } if (normalized.startsWith("/resume ") || normalized.startsWith("/load ")) { ManagedCodingSession resumed = resumeSession(session, extractCommandArgument(normalized)); activeSession = resumed; return DispatchResult.stay(resumed); } if ("/fork".equalsIgnoreCase(normalized) || normalized.startsWith("/fork ")) { ManagedCodingSession forked = forkSession(session, extractCommandArgument(normalized)); activeSession = forked; return DispatchResult.stay(forked); } if (normalized.startsWith("/compact")) { String turnId = newTurnId(); CodingSessionCompactResult result = resolveCompact(session.getSession(), normalized); printCompactResult(result); appendCompactEvent(session, result, turnId); persistSession(session, false); renderTui(session); return DispatchResult.stay(session); } runAgentTurn(session, input); return DispatchResult.stay(session); } private void runTurn(ManagedCodingSession session, String input) throws Exception { runTurn(session, input, null, newTurnId()); } private void runTurn(ManagedCodingSession session, String input, ActiveTuiTurn activeTurn) throws Exception { runTurn(session, input, activeTurn, activeTurn == null ? newTurnId() : activeTurn.getTurnId()); } private void runTurn(ManagedCodingSession session, String input, ActiveTuiTurn activeTurn, String turnId) throws Exception { if (isTurnInterrupted(turnId, activeTurn)) { return; } beginTuiTurn(input); if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.beginTurn(input); } else { startTuiTurnAnimation(session); } appendEvent(session, SessionEventType.USER_MESSAGE, turnId, null, clip(input, 200), payloadOf( "input", clip(input, options.isVerbose() ? 4000 : 1200) )); renderTuiIfEnabled(session); CliAgentListener listener = new CliAgentListener(session, turnId, activeTurn); try { CodingAgentResult result = session.getSession().runStream(CodingAgentRequest.builder().input(input).build(), listener); if (activeTurn != null && activeTurn.isInterrupted()) { return; } if (isMainBufferTurnInterrupted(turnId)) { handleMainBufferTurnInterrupted(session, turnId); return; } listener.flushFinalOutput(); if (activeTurn != null && activeTurn.isInterrupted()) { return; } if (isMainBufferTurnInterrupted(turnId)) { handleMainBufferTurnInterrupted(session, turnId); return; } appendLoopDecisionEvents(session, turnId, result); printAutoCompactOutcome(session, turnId); renderTui(session); } catch (Exception ex) { if (activeTurn != null && activeTurn.isInterrupted()) { return; } if (isMainBufferTurnInterrupted(turnId)) { handleMainBufferTurnInterrupted(session, turnId); return; } tuiLiveTurnState.onError(null, safeMessage(ex)); renderTuiIfEnabled(session); appendEvent(session, SessionEventType.ERROR, turnId, null, safeMessage(ex), payloadOf( "error", safeMessage(ex) )); throw ex; } finally { listener.close(); try { stopTuiTurnAnimation(); } finally { mainBufferTurnPrinter.finishTurn(); } } } private void runAgentTurn(ManagedCodingSession session, String input) throws Exception { if (useMainBufferInteractiveShell()) { runMainBufferTurn(session, input); persistSession(session, false); return; } if (!shouldRunTurnsAsync()) { runTurn(session, input); persistSession(session, false); return; } startAsyncTuiTurn(session, input); } private void printSessionHelp() { StringBuilder builder = new StringBuilder(); builder.append("In-session commands:\n"); builder.append(" /help Show help\n"); builder.append(" /status Show current session status\n"); builder.append(" /session Show current session metadata\n"); builder.append(" /theme [name] Show or switch the active TUI theme\n"); builder.append(" /save Persist the current session state\n"); builder.append(" /providers List saved provider profiles\n"); builder.append(" /provider Show current provider/profile state\n"); builder.append(" /provider use Switch workspace to a saved provider profile\n"); builder.append(" /provider save Save the current runtime as a provider profile\n"); builder.append(" /provider add [options] Create a provider profile from explicit fields\n"); builder.append(" /provider edit [options] Update a saved provider profile\n"); builder.append(" /provider default Set or clear the global default profile\n"); builder.append(" /provider remove Delete a saved provider profile\n"); builder.append(" /model Show the current effective model and override state\n"); builder.append(" /model Save a workspace model override and switch immediately\n"); builder.append(" /model reset Clear the workspace model override\n"); builder.append(" /experimental Show experimental runtime feature state\n"); builder.append(" /experimental Toggle experimental subagent or team runtime tools\n"); builder.append(" /skills [name] List discovered coding skills or inspect one skill in detail\n"); builder.append(" /agents [name] List available coding agents or inspect one worker definition\n"); builder.append(" /mcp Show current MCP services and status\n"); builder.append(" /mcp add --transport Add a global MCP service\n"); builder.append(" /mcp enable|disable Toggle workspace MCP enablement\n"); builder.append(" /mcp pause|resume Toggle current session MCP activation\n"); builder.append(" /mcp retry Reconnect an enabled MCP service\n"); builder.append(" /mcp remove Delete a global MCP service\n"); builder.append(" /commands List available custom commands\n"); builder.append(" /palette Alias of /commands\n"); builder.append(" /cmd [args] Run a custom command template\n"); builder.append(" /sessions List saved sessions\n"); builder.append(" /history [id] Show session lineage from root to target\n"); builder.append(" /tree [id] Show the current session tree\n"); builder.append(" /events [n] Show the latest session ledger events\n"); builder.append(" /replay [n] Replay recent turns grouped from the event ledger\n"); builder.append(" /team Show the current agent team board grouped by member lane\n"); builder.append(" /team list|status [team-id]|messages [team-id] [limit]|resume [team-id] Manage persisted team snapshots\n"); builder.append(" /compacts [n] Show recent compact history from the event ledger\n"); builder.append(" /stream [on|off] Show or switch model request streaming\n"); builder.append(" /processes List active and restored process metadata\n"); builder.append(" /process status Show metadata for one process\n"); builder.append(" /process follow [limit] Show process metadata with buffered logs\n"); builder.append(" /process logs [limit] Read buffered logs for a process\n"); builder.append(" /process write Write text to a live process stdin\n"); builder.append(" /process stop Stop a live process\n"); builder.append(" /checkpoint Show the current structured checkpoint summary\n"); builder.append(" /resume Resume a saved session\n"); builder.append(" /load Alias of /resume\n"); builder.append(" /fork [new-id] or /fork Fork a session branch\n"); builder.append(" /compact [summary] Compact current session memory\n"); builder.append(" /clear Print a new screen section\n"); builder.append(" /exit Exit the session\n"); builder.append(" /quit Exit the session\n"); builder.append("TUI keys: / opens command list, Tab accepts completion, Ctrl+P palette, Ctrl+R replay, /team opens the team board, Enter submit, Esc interrupts an active raw-TUI turn or clears input.\n"); builder.append("Type natural language instructions to drive the coding agent.\n"); if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { setTuiAssistantOutput(builder.toString().trim()); return; } terminal.println(builder.toString()); } private void printSessionHeader(ManagedCodingSession session) { if (options.getUiMode() == CliUiMode.TUI) { if (useMainBufferInteractiveShell()) { refreshSessionContext(session); String model = session == null ? "model" : firstNonBlank(session.getModel(), "model"); String workspace = session == null ? "." : lastPathSegment(firstNonBlank(session.getWorkspace(), ".")); terminal.println("AI4J " + clip(model, 28) + " " + clip(workspace, 32)); terminal.println(""); return; } renderTui(session); return; } terminal.println("ai4j-cli code"); terminal.println("session=" + session.getSessionId()); terminal.println("provider=" + session.getProvider() + ", protocol=" + session.getProtocol() + ", model=" + session.getModel()); terminal.println("workspace=" + session.getWorkspace()); terminal.println("mode=" + (options.isNoSession() ? "memory-only" : "persistent") + ", root=" + clip(session.getRootSessionId(), 64) + ", parent=" + clip(session.getParentSessionId(), 64)); terminal.println("store=" + sessionManager.getDirectory()); } private void printStatus(ManagedCodingSession session) { CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot(); if (useMainBufferInteractiveShell()) { emitOutput(renderStatusOutput(session, snapshot)); return; } if (useAppendOnlyTranscriptTui()) { emitOutput(renderStatusOutput(session, snapshot)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { renderTui(session); return; } terminal.println("status: session=" + session.getSessionId() + ", provider=" + session.getProvider() + ", protocol=" + session.getProtocol() + ", model=" + session.getModel() + ", workspace=" + session.getWorkspace() + ", mode=" + (options.isNoSession() ? "memory-only" : "persistent") + ", memory=" + (snapshot == null ? 0 : snapshot.getMemoryItemCount()) + ", activeProcesses=" + (snapshot == null ? 0 : snapshot.getActiveProcessCount()) + ", restoredProcesses=" + (snapshot == null ? 0 : snapshot.getRestoredProcessCount()) + ", tokens=" + (snapshot == null ? 0 : snapshot.getEstimatedContextTokens()) + ", checkpointGoal=" + clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 80) + ", compact=" + firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), "none")); } private void printCurrentSession(ManagedCodingSession session) { if (useMainBufferInteractiveShell()) { emitOutput(renderSessionOutput(session)); return; } if (useAppendOnlyTranscriptTui()) { emitOutput(renderSessionOutput(session)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { renderTui(session); return; } CodingSessionDescriptor descriptor = session == null ? null : session.toDescriptor(); if (descriptor == null) { terminal.println("session: (none)"); return; } CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties); CodingSessionSnapshot snapshot = session.getSession() == null ? null : session.getSession().snapshot(); terminal.println(renderPanel("session", "id : " + descriptor.getSessionId(), "root : " + descriptor.getRootSessionId(), "parent : " + firstNonBlank(descriptor.getParentSessionId(), "(none)"), "provider : " + descriptor.getProvider(), "protocol : " + descriptor.getProtocol(), "model : " + descriptor.getModel(), "profile : " + firstNonBlank(resolved.getActiveProfile(), resolved.getEffectiveProfile(), "(none)"), "override : " + firstNonBlank(resolved.getModelOverride(), "(none)"), "workspace : " + descriptor.getWorkspace(), "mode : " + (options.isNoSession() ? "memory-only" : "persistent"), "created : " + formatTimestamp(descriptor.getCreatedAtEpochMs()), "updated : " + formatTimestamp(descriptor.getUpdatedAtEpochMs()), "memory : " + descriptor.getMemoryItemCount(), "processes : " + descriptor.getProcessCount() + " (active=" + descriptor.getActiveProcessCount() + ", restored=" + descriptor.getRestoredProcessCount() + ")", "tokens : " + (snapshot == null ? 0 : snapshot.getEstimatedContextTokens()), "checkpoint: " + clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 220), "compact : " + firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), "none") + " " + (snapshot == null ? "" : snapshot.getLastCompactTokensBefore() + "->" + snapshot.getLastCompactTokensAfter()), "summary : " + clip(descriptor.getSummary(), 220) )); } private void printThemes() { List themes = tuiConfigManager.listThemeNames(); String currentTheme = tuiRenderer == null ? options.getTheme() : tuiRenderer.getThemeName(); if (useMainBufferInteractiveShell()) { StringBuilder builder = new StringBuilder("themes:\n"); for (String themeName : themes) { builder.append("- ").append(themeName); if (themeName.equalsIgnoreCase(currentTheme)) { builder.append(" (active)"); } builder.append('\n'); } emitOutput(builder.toString().trim()); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { if (tuiRenderer != null) { StringBuilder builder = new StringBuilder(); builder.append("Available themes:\n"); for (String themeName : themes) { builder.append("- ").append(themeName); if (themeName.equalsIgnoreCase(currentTheme)) { builder.append(" (active)"); } builder.append('\n'); } setTuiAssistantOutput(builder.toString().trim()); } return; } terminal.println("themes:"); for (String themeName : themes) { terminal.println(" " + themeName + (themeName.equalsIgnoreCase(currentTheme) ? " *" : "")); } } private void printProviders() { emitOutput(renderProvidersOutput()); } private void printCurrentProvider() { emitOutput(renderCurrentProviderOutput()); } private void printSkills(ManagedCodingSession session, String argument) { emitOutput(renderSkillsOutput(session, argument)); } private void printAgents(ManagedCodingSession session, String argument) { emitOutput(renderAgentsOutput(session, argument)); } private ManagedCodingSession handleProviderCommand(ManagedCodingSession session, String argument) throws Exception { if (isBlank(argument)) { printCurrentProvider(); return session; } String trimmed = argument.trim(); String[] parts = trimmed.split("\\s+", 2); String action = parts[0].toLowerCase(Locale.ROOT); String value = parts.length > 1 ? parts[1].trim() : null; if ("use".equals(action)) { return switchToProviderProfile(session, value); } if ("save".equals(action)) { saveCurrentProviderProfile(value); return session; } if ("add".equals(action)) { addProviderProfile(value); return session; } if ("edit".equals(action)) { return editProviderProfile(session, value); } if ("default".equals(action)) { setDefaultProviderProfile(value); return session; } if ("remove".equals(action)) { removeProviderProfile(value); return session; } emitError("Unknown /provider action: " + action + ". Use /provider, /providers, /provider use , /provider save , /provider add ..., /provider edit ..., /provider default , or /provider remove ."); return session; } private ManagedCodingSession handleModelCommand(ManagedCodingSession session, String argument) throws Exception { if (isBlank(argument)) { emitOutput(renderModelOutput()); return session; } String normalized = argument.trim(); CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if ("reset".equalsIgnoreCase(normalized)) { workspaceConfig.setModelOverride(null); providerConfigManager.saveWorkspaceConfig(workspaceConfig); CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions(); ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions); emitOutput(renderModelOutput()); persistSession(rebound, false); return rebound; } workspaceConfig.setModelOverride(normalized); providerConfigManager.saveWorkspaceConfig(workspaceConfig); CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions(); ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions); emitOutput(renderModelOutput()); persistSession(rebound, false); return rebound; } private ManagedCodingSession handleExperimentalCommand(ManagedCodingSession session, String argument) throws Exception { if (isBlank(argument)) { emitOutput(renderExperimentalOutput()); return session; } List tokens = splitWhitespace(argument); if (tokens.size() != 2) { emitError("Usage: /experimental "); return session; } String feature = normalizeExperimentalFeature(tokens.get(0)); Boolean enabled = parseExperimentalToggle(tokens.get(1)); if (feature == null || enabled == null) { emitError("Usage: /experimental "); return session; } CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if ("subagent".equals(feature)) { workspaceConfig.setExperimentalSubagentsEnabled(enabled); } else { workspaceConfig.setExperimentalAgentTeamsEnabled(enabled); } providerConfigManager.saveWorkspaceConfig(workspaceConfig); ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput(renderExperimentalOutput()); persistSession(rebound, false); return rebound; } private ManagedCodingSession handleMcpCommand(ManagedCodingSession session, String argument) throws Exception { if (isBlank(argument)) { emitOutput(renderMcpOutput()); return session; } String trimmed = argument.trim(); String[] parts = trimmed.split("\\s+", 2); String action = parts[0].toLowerCase(Locale.ROOT); String value = parts.length > 1 ? parts[1].trim() : null; if ("list".equals(action)) { emitOutput(renderMcpOutput()); return session; } if ("add".equals(action)) { return addMcpServer(session, value); } if ("remove".equals(action) || "delete".equals(action)) { return removeMcpServer(session, value); } if ("enable".equals(action)) { return enableMcpServer(session, value); } if ("disable".equals(action)) { return disableMcpServer(session, value); } if ("pause".equals(action)) { return pauseMcpServer(session, value); } if ("resume".equals(action)) { return resumeMcpServer(session, value); } if ("retry".equals(action)) { return retryMcpServer(session, value); } emitError("Unknown /mcp action: " + action + ". Use /mcp, /mcp list, /mcp add --transport , /mcp enable , /mcp disable , /mcp pause , /mcp resume , /mcp retry , or /mcp remove ."); return session; } private ManagedCodingSession addMcpServer(ManagedCodingSession session, String rawArguments) throws Exception { McpAddCommand command = parseMcpAddCommand(rawArguments); if (command == null) { return session; } CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig(); if (globalConfig.getMcpServers().containsKey(command.name)) { emitError("MCP server already exists: " + command.name); return session; } globalConfig.getMcpServers().put(command.name, command.definition); mcpConfigManager.saveGlobalConfig(globalConfig); emitOutput("mcp added: " + command.name + " -> " + mcpConfigManager.globalMcpPath()); CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig(); if (containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), command.name)) { ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput(renderMcpOutput()); persistSession(rebound, false); return rebound; } return session; } private ManagedCodingSession removeMcpServer(ManagedCodingSession session, String name) throws Exception { if (isBlank(name)) { emitError("Usage: /mcp remove "); return session; } String normalizedName = name.trim(); CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig(); if (globalConfig.getMcpServers().remove(normalizedName) == null) { emitError("Unknown MCP server: " + normalizedName); return session; } mcpConfigManager.saveGlobalConfig(globalConfig); CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig(); workspaceConfig.setEnabledMcpServers(removeName(workspaceConfig.getEnabledMcpServers(), normalizedName)); mcpConfigManager.saveWorkspaceConfig(workspaceConfig); pausedMcpServers.remove(normalizedName); ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput("mcp removed: " + normalizedName); emitOutput(renderMcpOutput()); persistSession(rebound, false); return rebound; } private ManagedCodingSession enableMcpServer(ManagedCodingSession session, String name) throws Exception { if (isBlank(name)) { emitError("Usage: /mcp enable "); return session; } String normalizedName = name.trim(); if (!mcpConfigManager.loadGlobalConfig().getMcpServers().containsKey(normalizedName)) { emitError("Unknown MCP server: " + normalizedName); return session; } CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig(); workspaceConfig.setEnabledMcpServers(addName(workspaceConfig.getEnabledMcpServers(), normalizedName)); mcpConfigManager.saveWorkspaceConfig(workspaceConfig); ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput(renderMcpOutput()); persistSession(rebound, false); return rebound; } private ManagedCodingSession disableMcpServer(ManagedCodingSession session, String name) throws Exception { if (isBlank(name)) { emitError("Usage: /mcp disable "); return session; } String normalizedName = name.trim(); CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig(); if (!containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), normalizedName)) { emitError("MCP server is not enabled in this workspace: " + normalizedName); return session; } workspaceConfig.setEnabledMcpServers(removeName(workspaceConfig.getEnabledMcpServers(), normalizedName)); mcpConfigManager.saveWorkspaceConfig(workspaceConfig); pausedMcpServers.remove(normalizedName); ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput(renderMcpOutput()); persistSession(rebound, false); return rebound; } private ManagedCodingSession pauseMcpServer(ManagedCodingSession session, String name) throws Exception { if (isBlank(name)) { emitError("Usage: /mcp pause "); return session; } String normalizedName = name.trim(); CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig(); if (!containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), normalizedName)) { emitError("MCP server is not enabled in this workspace: " + normalizedName); return session; } if (!pausedMcpServers.add(normalizedName)) { emitOutput("mcp already paused: " + normalizedName); return session; } ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput(renderMcpOutput()); persistSession(rebound, false); return rebound; } private ManagedCodingSession resumeMcpServer(ManagedCodingSession session, String name) throws Exception { if (isBlank(name)) { emitError("Usage: /mcp resume "); return session; } String normalizedName = name.trim(); if (!pausedMcpServers.remove(normalizedName)) { emitError("MCP server is not paused in this session: " + normalizedName); return session; } ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput(renderMcpOutput()); persistSession(rebound, false); return rebound; } private ManagedCodingSession retryMcpServer(ManagedCodingSession session, String name) throws Exception { if (isBlank(name)) { emitError("Usage: /mcp retry "); return session; } String normalizedName = name.trim(); if (!containsMcpServer(normalizedName)) { emitError("Unknown MCP server: " + normalizedName); return session; } ManagedCodingSession rebound = switchSessionRuntime(session, options); emitOutput(renderMcpOutput()); persistSession(rebound, false); return rebound; } private McpAddCommand parseMcpAddCommand(String rawArguments) { if (isBlank(rawArguments)) { emitError("Usage: /mcp add --transport "); return null; } List tokens = splitWhitespace(rawArguments); if (tokens.size() < 4 || !"--transport".equalsIgnoreCase(tokens.get(0))) { emitError("Usage: /mcp add --transport "); return null; } String transport = normalizeMcpTransport(tokens.get(1)); if (transport == null) { emitError("Unsupported MCP transport: " + tokens.get(1) + ". Use stdio, sse, or http."); return null; } String name = tokens.get(2).trim(); if (isBlank(name)) { emitError("Usage: /mcp add --transport "); return null; } CliMcpServerDefinition definition = new CliMcpServerDefinition(); definition.setType(transport); if ("stdio".equals(transport)) { if (tokens.size() < 4) { emitError("Usage: /mcp add --transport stdio [args...]"); return null; } definition.setCommand(tokens.get(3)); if (tokens.size() > 4) { definition.setArgs(new ArrayList(tokens.subList(4, tokens.size()))); } } else { if (tokens.size() != 4) { emitError("Usage: /mcp add --transport " + tokens.get(1) + " "); return null; } definition.setUrl(tokens.get(3)); } return new McpAddCommand(name, definition); } private String renderMcpOutput() { CliResolvedMcpConfig resolvedConfig = mcpConfigManager.resolve(pausedMcpServers); List statuses = mcpRuntimeManager != null && mcpRuntimeManager.hasStatuses() ? mcpRuntimeManager.getStatuses() : deriveMcpStatuses(resolvedConfig); if ((statuses == null || statuses.isEmpty()) && resolvedConfig.getServers().isEmpty()) { return "mcp: (none)"; } StringBuilder builder = new StringBuilder(); builder.append("mcp:\n"); for (CliMcpStatusSnapshot status : statuses) { if (status == null) { continue; } builder.append("- ").append(status.getServerName()) .append(" | type=").append(firstNonBlank(status.getTransportType(), "(unknown)")) .append(" | state=").append(firstNonBlank(status.getState(), "(unknown)")) .append(" | workspace=").append(status.isWorkspaceEnabled() ? "enabled" : "disabled") .append(" | paused=").append(status.isSessionPaused() ? "yes" : "no") .append(" | tools=").append(status.getToolCount()); if (!isBlank(status.getErrorSummary())) { builder.append(" | error=").append(clip(status.getErrorSummary(), 120)); } builder.append('\n'); } builder.append("- store=").append(mcpConfigManager.globalMcpPath()).append('\n'); builder.append("- workspaceConfig=").append(mcpConfigManager.workspaceConfigPath()); return builder.toString().trim(); } private List deriveMcpStatuses(CliResolvedMcpConfig resolvedConfig) { List statuses = new ArrayList(); if (resolvedConfig == null) { return statuses; } for (CliResolvedMcpServer server : resolvedConfig.getServers().values()) { String state = server.isWorkspaceEnabled() ? (server.isSessionPaused() ? CliMcpRuntimeManager.STATE_PAUSED : (server.isValid() ? "configured" : CliMcpRuntimeManager.STATE_ERROR)) : CliMcpRuntimeManager.STATE_DISABLED; statuses.add(new CliMcpStatusSnapshot( server.getName(), server.getTransportType(), state, 0, server.getValidationError(), server.isWorkspaceEnabled(), server.isSessionPaused() )); } for (String missing : resolvedConfig.getUnknownEnabledServerNames()) { statuses.add(new CliMcpStatusSnapshot( missing, null, CliMcpRuntimeManager.STATE_MISSING, 0, "workspace references undefined MCP server", true, false )); } return statuses; } private ManagedCodingSession switchToProviderProfile(ManagedCodingSession session, String profileName) throws Exception { if (isBlank(profileName)) { emitError("Usage: /provider use "); return session; } CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); String normalizedName = profileName.trim(); if (!providersConfig.getProfiles().containsKey(normalizedName)) { emitError("Unknown provider profile: " + normalizedName); return session; } CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); workspaceConfig.setActiveProfile(normalizedName); providerConfigManager.saveWorkspaceConfig(workspaceConfig); CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions(); ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions); emitOutput(renderCurrentProviderOutput()); persistSession(rebound, false); return rebound; } private void saveCurrentProviderProfile(String profileName) throws IOException { if (isBlank(profileName)) { emitError("Usage: /provider save "); return; } CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); String normalizedName = profileName.trim(); providersConfig.getProfiles().put(normalizedName, CliProviderProfile.builder() .provider(options.getProvider() == null ? null : options.getProvider().getPlatform()) .protocol(protocol == null ? null : protocol.getValue()) .model(options.getModel()) .baseUrl(options.getBaseUrl()) .apiKey(options.getApiKey()) .build()); if (isBlank(providersConfig.getDefaultProfile())) { providersConfig.setDefaultProfile(normalizedName); } providerConfigManager.saveProvidersConfig(providersConfig); emitOutput("provider saved: " + normalizedName + " -> " + providerConfigManager.globalProvidersPath()); } private void addProviderProfile(String rawArguments) throws IOException { ProviderProfileMutation mutation = parseProviderProfileMutation( rawArguments, "Usage: /provider add --provider [--protocol ] [--model ] [--base-url ] [--api-key ]" ); if (mutation == null) { return; } CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); if (providersConfig.getProfiles().containsKey(mutation.profileName)) { emitError("Provider profile already exists: " + mutation.profileName + ". Use /provider edit " + mutation.profileName + " ..."); return; } if (isBlank(mutation.provider)) { emitError("Usage: /provider add --provider [--protocol ] [--model ] [--base-url ] [--api-key ]"); return; } PlatformType provider = parseProviderType(mutation.provider); if (provider == null) { return; } String baseUrlValue = mutation.clearBaseUrl ? null : mutation.baseUrl; CliProtocol protocolValue = parseProviderProtocol( firstNonBlank(mutation.protocol, CliProtocol.defaultProtocol(provider, baseUrlValue).getValue()) ); if (protocolValue == null) { return; } if (!isSupportedProviderProtocol(provider, protocolValue)) { return; } providersConfig.getProfiles().put(mutation.profileName, CliProviderProfile.builder() .provider(provider.getPlatform()) .protocol(protocolValue.getValue()) .model(mutation.clearModel ? null : mutation.model) .baseUrl(mutation.clearBaseUrl ? null : mutation.baseUrl) .apiKey(mutation.clearApiKey ? null : mutation.apiKey) .build()); if (isBlank(providersConfig.getDefaultProfile())) { providersConfig.setDefaultProfile(mutation.profileName); } providerConfigManager.saveProvidersConfig(providersConfig); emitOutput("provider added: " + mutation.profileName + " -> " + providerConfigManager.globalProvidersPath()); } private ManagedCodingSession editProviderProfile(ManagedCodingSession session, String rawArguments) throws Exception { ProviderProfileMutation mutation = parseProviderProfileMutation( rawArguments, "Usage: /provider edit [--provider ] [--protocol ] [--model |--clear-model] [--base-url |--clear-base-url] [--api-key |--clear-api-key]" ); if (mutation == null) { return session; } if (!mutation.hasAnyFieldChanges()) { emitError("Usage: /provider edit [--provider ] [--protocol ] [--model |--clear-model] [--base-url |--clear-base-url] [--api-key |--clear-api-key]"); return session; } CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliProviderProfile existing = providersConfig.getProfiles().get(mutation.profileName); if (existing == null) { emitError("Unknown provider profile: " + mutation.profileName); return session; } PlatformType provider = parseProviderType(firstNonBlank(mutation.provider, existing.getProvider())); if (provider == null) { return session; } String baseUrlValue = mutation.clearBaseUrl ? null : firstNonBlank(mutation.baseUrl, existing.getBaseUrl()); String protocolRaw = mutation.protocol; if (isBlank(protocolRaw)) { protocolRaw = firstNonBlank( normalizeStoredProtocol(existing.getProtocol(), provider, baseUrlValue), CliProtocol.defaultProtocol(provider, baseUrlValue).getValue() ); } CliProtocol protocolValue = parseProviderProtocol(protocolRaw); if (protocolValue == null) { return session; } if (!isSupportedProviderProtocol(provider, protocolValue)) { return session; } existing.setProvider(provider.getPlatform()); existing.setProtocol(protocolValue.getValue()); if (mutation.clearModel) { existing.setModel(null); } else if (mutation.model != null) { existing.setModel(mutation.model); } if (mutation.clearBaseUrl) { existing.setBaseUrl(null); } else if (mutation.baseUrl != null) { existing.setBaseUrl(mutation.baseUrl); } if (mutation.clearApiKey) { existing.setApiKey(null); } else if (mutation.apiKey != null) { existing.setApiKey(mutation.apiKey); } String effectiveProfileBeforeEdit = providerConfigManager.resolve(null, null, null, null, null, env, properties).getEffectiveProfile(); providersConfig.getProfiles().put(mutation.profileName, existing); providerConfigManager.saveProvidersConfig(providersConfig); emitOutput("provider updated: " + mutation.profileName + " -> " + providerConfigManager.globalProvidersPath()); if (!mutation.profileName.equals(effectiveProfileBeforeEdit)) { return session; } CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions(); ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions); emitOutput(renderCurrentProviderOutput()); persistSession(rebound, false); return rebound; } private void removeProviderProfile(String profileName) throws IOException { if (isBlank(profileName)) { emitError("Usage: /provider remove "); return; } String normalizedName = profileName.trim(); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); if (providersConfig.getProfiles().remove(normalizedName) == null) { emitError("Unknown provider profile: " + normalizedName); return; } if (normalizedName.equals(providersConfig.getDefaultProfile())) { providersConfig.setDefaultProfile(null); } providerConfigManager.saveProvidersConfig(providersConfig); CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if (normalizedName.equals(workspaceConfig.getActiveProfile())) { workspaceConfig.setActiveProfile(null); providerConfigManager.saveWorkspaceConfig(workspaceConfig); } emitOutput("provider removed: " + normalizedName); } private void setDefaultProviderProfile(String profileName) throws IOException { if (isBlank(profileName)) { emitError("Usage: /provider default "); return; } CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); String normalizedName = profileName.trim(); if ("clear".equalsIgnoreCase(normalizedName)) { providersConfig.setDefaultProfile(null); providerConfigManager.saveProvidersConfig(providersConfig); emitOutput("provider default cleared"); return; } if (!providersConfig.getProfiles().containsKey(normalizedName)) { emitError("Unknown provider profile: " + normalizedName); return; } providersConfig.setDefaultProfile(normalizedName); providerConfigManager.saveProvidersConfig(providersConfig); emitOutput("provider default: " + normalizedName); } private ProviderProfileMutation parseProviderProfileMutation(String rawArguments, String usage) { if (isBlank(rawArguments)) { emitError(usage); return null; } String[] tokens = rawArguments.trim().split("\\s+"); if (tokens.length == 0 || isBlank(tokens[0])) { emitError(usage); return null; } ProviderProfileMutation mutation = new ProviderProfileMutation(tokens[0].trim()); for (int i = 1; i < tokens.length; i++) { String token = tokens[i]; if ("--provider".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i, token, usage); if (value == null) { return null; } mutation.provider = value; continue; } if ("--protocol".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i, token, usage); if (value == null) { return null; } mutation.protocol = value; continue; } if ("--model".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i, token, usage); if (value == null) { return null; } mutation.model = value; mutation.clearModel = false; continue; } if ("--base-url".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i, token, usage); if (value == null) { return null; } mutation.baseUrl = value; mutation.clearBaseUrl = false; continue; } if ("--api-key".equalsIgnoreCase(token)) { String value = requireProviderMutationValue(tokens, ++i, token, usage); if (value == null) { return null; } mutation.apiKey = value; mutation.clearApiKey = false; continue; } if ("--clear-model".equalsIgnoreCase(token)) { mutation.model = null; mutation.clearModel = true; continue; } if ("--clear-base-url".equalsIgnoreCase(token)) { mutation.baseUrl = null; mutation.clearBaseUrl = true; continue; } if ("--clear-api-key".equalsIgnoreCase(token)) { mutation.apiKey = null; mutation.clearApiKey = true; continue; } emitError("Unknown provider option: " + token + ". " + usage); return null; } return mutation; } private String requireProviderMutationValue(String[] tokens, int index, String option, String usage) { if (tokens == null || index < 0 || index >= tokens.length || isBlank(tokens[index])) { emitError("Missing value for " + option + ". " + usage); return null; } return tokens[index].trim(); } private PlatformType parseProviderType(String raw) { if (isBlank(raw)) { emitError("Provider is required"); return null; } for (PlatformType platformType : PlatformType.values()) { if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) { return platformType; } } emitError("Unsupported provider: " + raw); return null; } private CliProtocol parseProviderProtocol(String raw) { try { return CliProtocol.parse(raw); } catch (IllegalArgumentException ex) { emitError(ex.getMessage()); return null; } } private boolean isSupportedProviderProtocol(PlatformType provider, CliProtocol protocol) { if (provider == null || protocol == null || protocol == CliProtocol.CHAT) { return true; } if (provider != PlatformType.OPENAI && provider != PlatformType.DOUBAO && provider != PlatformType.DASHSCOPE) { emitError("Provider " + provider.getPlatform() + " does not support responses protocol in ai4j-cli yet"); return false; } return true; } private String normalizeStoredProtocol(String raw, PlatformType provider, String baseUrl) { if (isBlank(raw)) { return null; } return CliProtocol.resolveConfigured(raw, provider, baseUrl).getValue(); } private CodeCommandOptions resolveConfiguredRuntimeOptions() { CliResolvedProviderConfig resolved = providerConfigManager.resolve( null, null, null, null, null, env, properties ); return options.withRuntime( resolved.getProvider(), resolved.getProtocol(), resolved.getModel(), resolved.getApiKey(), resolved.getBaseUrl() ); } private ManagedCodingSession switchSessionRuntime(ManagedCodingSession session, CodeCommandOptions nextOptions) throws Exception { if (agentFactory == null) { throw new IllegalStateException("Runtime switching is unavailable in this shell"); } CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare( nextOptions, terminal, interactionState, pausedMcpServers ); if (session == null || session.getSession() == null) { closeMcpRuntimeQuietly(mcpRuntimeManager); this.agent = prepared.getAgent(); this.protocol = prepared.getProtocol(); this.options = nextOptions; this.streamEnabled = nextOptions != null && nextOptions.isStream(); this.mcpRuntimeManager = prepared.getMcpRuntimeManager(); attachCodingTaskEventBridge(); refreshSessionContext(null); return session; } io.github.lnyocly.ai4j.coding.CodingSessionState state = session.getSession().exportState(); CodingSession nextSession = prepared.getAgent().newSession(session.getSessionId(), state); ManagedCodingSession rebound = new ManagedCodingSession( nextSession, nextOptions.getProvider().getPlatform(), prepared.getProtocol().getValue(), nextOptions.getModel(), nextOptions.getWorkspace(), nextOptions.getWorkspaceDescription(), nextOptions.getSystemPrompt(), nextOptions.getInstructions(), firstNonBlank(session.getRootSessionId(), session.getSessionId()), session.getParentSessionId(), session.getCreatedAtEpochMs(), session.getUpdatedAtEpochMs() ); closeQuietly(session); closeMcpRuntimeQuietly(mcpRuntimeManager); this.agent = prepared.getAgent(); this.protocol = prepared.getProtocol(); this.options = nextOptions; this.streamEnabled = nextOptions != null && nextOptions.isStream(); this.mcpRuntimeManager = prepared.getMcpRuntimeManager(); attachCodingTaskEventBridge(); refreshSessionContext(rebound); return rebound; } private void printCommands() { List commands = customCommandRegistry.list(); if (commands.isEmpty()) { emitOutput("commands: (none)"); return; } if (useMainBufferInteractiveShell()) { StringBuilder builder = new StringBuilder("commands:\n"); for (CustomCommandTemplate command : commands) { builder.append("- ").append(command.getName()) .append(" | ").append(firstNonBlank(command.getDescription(), "(no description)")) .append(" | ").append(command.getSource()) .append('\n'); } emitOutput(builder.toString().trim()); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { StringBuilder builder = new StringBuilder(); builder.append("commands:\n"); for (CustomCommandTemplate command : commands) { builder.append("- ").append(command.getName()) .append(" | ").append(firstNonBlank(command.getDescription(), "(no description)")) .append(" | ").append(command.getSource()) .append('\n'); } setTuiAssistantOutput(builder.toString().trim()); return; } terminal.println("commands:"); for (CustomCommandTemplate command : commands) { terminal.println(" " + command.getName() + " | " + firstNonBlank(command.getDescription(), "(no description)") + " | " + command.getSource()); } } private void runCustomCommand(ManagedCodingSession session, String rawArguments) throws Exception { if (session == null) { emitError("No current session."); return; } if (isBlank(rawArguments)) { emitError("Usage: /cmd [args]"); return; } String trimmed = rawArguments.trim(); int firstSpace = trimmed.indexOf(' '); String name = firstSpace < 0 ? trimmed : trimmed.substring(0, firstSpace).trim(); String args = firstSpace < 0 ? "" : trimmed.substring(firstSpace + 1).trim(); CustomCommandTemplate command = customCommandRegistry.find(name); if (command == null) { emitError("Unknown custom command: " + name); return; } String rendered = renderCustomCommand(command, session, args); runAgentTurn(session, rendered); } private String renderCustomCommand(CustomCommandTemplate command, ManagedCodingSession session, String args) { Map variables = new LinkedHashMap(); variables.put("ARGUMENTS", args); variables.put("WORKSPACE", options.getWorkspace()); variables.put("SESSION_ID", session == null ? "" : session.getSessionId()); variables.put("ROOT_SESSION_ID", session == null ? "" : firstNonBlank(session.getRootSessionId(), "")); variables.put("PARENT_SESSION_ID", session == null ? "" : firstNonBlank(session.getParentSessionId(), "")); String rendered = command.render(variables).trim(); if (!isBlank(args) && (command.getTemplate() == null || !command.getTemplate().contains("$ARGUMENTS"))) { rendered = rendered + "\n\nArguments:\n" + args; } return rendered; } private void applyTheme(String themeName, ManagedCodingSession session) { if (isBlank(themeName)) { emitError("Usage: /theme "); return; } try { TuiConfig config = tuiConfigManager.switchTheme(themeName); TuiTheme theme = tuiConfigManager.resolveTheme(config.getTheme()); if (terminal instanceof JlineShellTerminalIO) { ((JlineShellTerminalIO) terminal).updateTheme(theme); } if (tuiRenderer != null) { tuiConfig = config; tuiTheme = theme; tuiRenderer.updateTheme(config, theme); setTuiAssistantOutput("Theme switched to `" + theme.getName() + "`."); } if (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell()) { terminal.println("theme switched to: " + theme.getName()); } renderTui(session); } catch (Exception ex) { emitError("Failed to switch theme: " + safeMessage(ex)); } } private void printCheckpoint(ManagedCodingSession session) { CodingSessionCheckpoint checkpoint = session == null || session.getSession() == null ? null : session.getSession().exportState().getCheckpoint(); if (useMainBufferInteractiveShell()) { emitOutput(renderCheckpointOutput(checkpoint)); return; } if (useAppendOnlyTranscriptTui()) { emitOutput(renderCheckpointOutput(checkpoint)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { renderTui(session); return; } if (checkpoint == null) { terminal.println(renderPanel("checkpoint", "(none)")); return; } terminal.println(renderPanel("checkpoint", toPanelLines(CodingSessionCheckpointFormatter.render(checkpoint), 180, 32))); } private void printClearMarker(ManagedCodingSession session) { if (useAppendOnlyTranscriptTui()) { terminal.println(""); return; } if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.printSectionBreak(); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { renderTui(session); return; } terminal.println(""); } private CodingSessionCompactResult resolveCompact(CodingSession session, String normalizedCommand) { String summary = null; if (normalizedCommand != null) { String trimmed = normalizedCommand.trim(); if (trimmed.length() > "/compact".length()) { summary = trimmed.substring("/compact".length()).trim(); } } return isBlank(summary) ? session.compact() : session.compact(summary); } private void printCompactResult(CodingSessionCompactResult result) { if (result == null) { return; } if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatCompact(result)); return; } if (options.getUiMode() == CliUiMode.TUI) { setTuiAssistantOutput(result.getSummary()); return; } terminal.println("compact: mode=" + (result.isAutomatic() ? "auto" : "manual") + ", strategy=" + firstNonBlank(result.getStrategy(), "checkpoint") + ", before=" + result.getBeforeItemCount() + ", after=" + result.getAfterItemCount() + ", tokens=" + result.getEstimatedTokensBefore() + "->" + result.getEstimatedTokensAfter() + ", split=" + result.isSplitTurn() + ", fallback=" + result.isFallbackSummary() + ", summary=" + clip(result.getSummary(), options.isVerbose() ? 320 : 180)); } private void printAutoCompactOutcome(ManagedCodingSession session, String turnId) { List results = session == null || session.getSession() == null ? Collections.emptyList() : session.getSession().drainAutoCompactResults(); for (CodingSessionCompactResult result : results) { if (result == null) { continue; } printCompactResult(result); appendCompactEvent(session, result, turnId); } List errors = session == null || session.getSession() == null ? Collections.emptyList() : session.getSession().drainAutoCompactErrors(); for (Exception error : errors) { if (error == null) { continue; } appendEvent(session, SessionEventType.ERROR, turnId, null, safeMessage(error), payloadOf( "error", safeMessage(error), "source", "auto-compact" )); emitError("Auto compact failed: " + error.getMessage()); } } private void appendLoopDecisionEvents(ManagedCodingSession session, String turnId, CodingAgentResult result) { List decisions = session == null || session.getSession() == null ? Collections.emptyList() : session.getSession().drainLoopDecisions(); for (CodingLoopDecision decision : decisions) { if (decision == null) { continue; } SessionEventType eventType = decision.isContinueLoop() ? SessionEventType.AUTO_CONTINUE : decision.isBlocked() ? SessionEventType.BLOCKED : SessionEventType.AUTO_STOP; appendEvent(session, eventType, turnId, null, firstNonBlank(decision.getSummary(), formatLoopDecisionSummary(decision, result)), payloadOf( "turnNumber", decision.getTurnNumber(), "continueReason", decision.getContinueReason(), "stopReason", decision.getStopReason() == null ? null : decision.getStopReason().name().toLowerCase(Locale.ROOT), "compactApplied", decision.isCompactApplied() )); emitLoopDecision(decision, result); } } private void emitLoopDecision(CodingLoopDecision decision, CodingAgentResult result) { String summary = firstNonBlank(decision == null ? null : decision.getSummary(), formatLoopDecisionSummary(decision, result)); if (isBlank(summary)) { return; } if (useMainBufferInteractiveShell()) { String title = decision != null && decision.isContinueLoop() ? "Auto continue" : decision != null && decision.isBlocked() ? "Blocked" : "Auto stop"; mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock( title, Collections.singletonList(summary) )); return; } if (options.getUiMode() != CliUiMode.TUI) { terminal.println("[loop] " + summary); } } private String formatLoopDecisionSummary(CodingLoopDecision decision, CodingAgentResult result) { if (decision == null) { return result == null || result.getStopReason() == null ? null : formatStopReason(result.getStopReason()); } if (decision.isContinueLoop()) { return firstNonBlank(decision.getSummary(), "Auto continue."); } if (decision.isBlocked()) { return firstNonBlank(decision.getSummary(), "Blocked."); } return firstNonBlank(decision.getSummary(), result == null || result.getStopReason() == null ? "Stopped." : formatStopReason(result.getStopReason())); } private String formatStopReason(CodingStopReason stopReason) { if (stopReason == null) { return "Stopped."; } String normalized = stopReason.name().toLowerCase(Locale.ROOT).replace('_', ' '); return Character.toUpperCase(normalized.charAt(0)) + normalized.substring(1) + "."; } private ManagedCodingSession openInitialSession() throws Exception { if (!isBlank(options.getResumeSessionId())) { return sessionManager.resume(agent, protocol, options, options.getResumeSessionId()); } if (!isBlank(options.getForkSessionId())) { return sessionManager.fork(agent, protocol, options, options.getForkSessionId(), options.getSessionId()); } return sessionManager.create(agent, protocol, options); } private ManagedCodingSession resumeSession(ManagedCodingSession currentSession, String sessionId) throws Exception { if (isBlank(sessionId)) { emitError("Usage: /resume "); return currentSession; } closeQuietly(currentSession); persistSession(currentSession, true); ManagedCodingSession resumed = sessionManager.resume(agent, protocol, options, sessionId); if (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell()) { terminal.println("resumed session: " + resumed.getSessionId()); } printSessionHeader(resumed); return resumed; } private ManagedCodingSession forkSession(ManagedCodingSession currentSession, String rawArguments) throws Exception { if (currentSession == null) { emitError("No current session to fork."); return null; } String[] parts = isBlank(rawArguments) ? new String[0] : rawArguments.trim().split("\\s+"); String sourceSessionId; String targetSessionId; if (parts.length == 0) { sourceSessionId = currentSession.getSessionId(); targetSessionId = null; } else if (parts.length == 1) { sourceSessionId = currentSession.getSessionId(); targetSessionId = parts[0]; } else { sourceSessionId = parts[0]; targetSessionId = parts[1]; } if (currentSession != null && sourceSessionId.equals(currentSession.getSessionId())) { persistSession(currentSession, true); } ManagedCodingSession forked = sessionManager.fork(agent, protocol, options, sourceSessionId, targetSessionId); closeQuietly(currentSession); persistSession(forked, true); if (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell()) { terminal.println("forked session: " + forked.getSessionId() + " <- " + sourceSessionId); } printSessionHeader(forked); return forked; } private void persistSession(ManagedCodingSession session, boolean force) { if (session == null || (!force && !options.isAutoSaveSession())) { return; } try { StoredCodingSession stored = sessionManager.save(session); refreshTuiSessions(); refreshTuiEvents(session); if (force && (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell())) { terminal.println("saved session: " + stored.getSessionId() + " -> " + stored.getStorePath()); } } catch (IOException ex) { emitError("Failed to save session: " + ex.getMessage()); } } private void printSessions() { try { List sessions = sessionManager.list(); if (sessions.isEmpty()) { emitOutput("No saved sessions found in " + sessionManager.getDirectory()); return; } if (useAppendOnlyTranscriptTui()) { emitOutput(renderSessionsOutput(sessions)); return; } if (useMainBufferInteractiveShell()) { emitOutput(renderSessionsOutput(sessions)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { setTuiCachedSessions(sessions); return; } terminal.println("sessions:"); for (CodingSessionDescriptor session : sessions) { terminal.println(" " + session.getSessionId() + " | root=" + clip(session.getRootSessionId(), 24) + " | parent=" + clip(firstNonBlank(session.getParentSessionId(), "-"), 24) + " | updated=" + formatTimestamp(session.getUpdatedAtEpochMs()) + " | memory=" + session.getMemoryItemCount() + " | processes=" + session.getProcessCount() + " | " + clip(session.getSummary(), 120)); } } catch (IOException ex) { emitError("Failed to list sessions: " + ex.getMessage()); } } private void printHistory(ManagedCodingSession currentSession, String targetSessionId) { try { List sessions = mergeCurrentSession(sessionManager.list(), currentSession); CodingSessionDescriptor target = resolveTargetDescriptor(sessions, currentSession, targetSessionId); if (target == null) { emitOutput("history: (none)"); return; } List history = resolveHistory(sessions, target); if (history.isEmpty()) { emitOutput("history: (none)"); return; } if (useMainBufferInteractiveShell()) { StringBuilder builder = new StringBuilder(); builder.append("history:\n"); for (CodingSessionDescriptor session : history) { builder.append("- ") .append(session.getSessionId()) .append(" | parent=").append(firstNonBlank(session.getParentSessionId(), "(root)")) .append(" | updated=").append(formatTimestamp(session.getUpdatedAtEpochMs())) .append(" | ").append(clip(session.getSummary(), 120)) .append('\n'); } emitOutput(builder.toString().trim()); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { StringBuilder builder = new StringBuilder(); builder.append("history:\n"); for (CodingSessionDescriptor session : history) { builder.append("- ") .append(session.getSessionId()) .append(" | parent=").append(firstNonBlank(session.getParentSessionId(), "(root)")) .append(" | updated=").append(formatTimestamp(session.getUpdatedAtEpochMs())) .append(" | ").append(clip(session.getSummary(), 120)) .append('\n'); } setTuiAssistantOutput(builder.toString().trim()); return; } terminal.println("history:"); for (CodingSessionDescriptor session : history) { terminal.println(" " + session.getSessionId() + " | root=" + clip(session.getRootSessionId(), 24) + " | parent=" + clip(firstNonBlank(session.getParentSessionId(), "(root)"), 24) + " | updated=" + formatTimestamp(session.getUpdatedAtEpochMs()) + " | " + clip(session.getSummary(), 120)); } } catch (IOException ex) { emitError("Failed to build history: " + ex.getMessage()); } } private void printTree(ManagedCodingSession currentSession, String rootArgument) { try { List sessions = mergeCurrentSession(sessionManager.list(), currentSession); List lines = renderTreeLines(sessions, rootArgument, currentSession == null ? null : currentSession.getSessionId()); if (lines.isEmpty()) { emitOutput("tree: (none)"); return; } if (useMainBufferInteractiveShell()) { StringBuilder builder = new StringBuilder(); builder.append("tree:\n"); for (String line : lines) { builder.append(line).append('\n'); } emitOutput(builder.toString().trim()); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { StringBuilder builder = new StringBuilder(); builder.append("tree:\n"); for (String line : lines) { builder.append(line).append('\n'); } setTuiAssistantOutput(builder.toString().trim()); return; } terminal.println("tree:"); for (String line : lines) { terminal.println(" " + line); } } catch (IOException ex) { emitError("Failed to build session tree: " + ex.getMessage()); } } private void printEvents(ManagedCodingSession session, String limitArgument) { if (session == null) { emitOutput("events: (no current session)"); return; } Integer limit = parseLimit(limitArgument); try { List events = sessionManager.listEvents(session.getSessionId(), limit, null); if (useAppendOnlyTranscriptTui()) { emitOutput(renderEventsOutput(events)); return; } if (useMainBufferInteractiveShell()) { emitOutput(renderEventsOutput(events)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { setTuiCachedEvents(events); return; } if (events.isEmpty()) { terminal.println("events: (none)"); return; } terminal.println("events:"); for (SessionEvent event : events) { terminal.println(" " + formatTimestamp(event.getTimestamp()) + " | " + event.getType() + (event.getStep() == null ? "" : " | step=" + event.getStep()) + " | " + clip(event.getSummary(), 160)); } } catch (IOException ex) { emitError("Failed to list session events: " + ex.getMessage()); } } private void printReplay(ManagedCodingSession session, String limitArgument) { if (session == null) { emitOutput("replay: (no current session)"); return; } Integer limit = parseLimit(limitArgument); try { List events = sessionManager.listEvents(session.getSessionId(), limit, null); List replayLines = buildReplayLines(events); if (useAppendOnlyTranscriptTui()) { emitOutput(renderReplayOutput(replayLines)); return; } if (useMainBufferInteractiveShell()) { emitOutput(renderReplayOutput(replayLines)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { setTuiCachedReplay(replayLines); if (!replayLines.isEmpty()) { interactionState.openReplayViewer(); setTuiAssistantOutput("Replay viewer opened."); } else { setTuiAssistantOutput("history: (none)"); } return; } if (replayLines.isEmpty()) { emitOutput("replay: (none)"); return; } StringBuilder builder = new StringBuilder("replay:\n"); for (String replayLine : replayLines) { builder.append(replayLine).append('\n'); } emitOutput(builder.toString().trim()); } catch (IOException ex) { emitError("Failed to replay events: " + ex.getMessage()); } } private void printTeamBoard(ManagedCodingSession session) { if (session == null) { emitOutput("team: (no current session)"); return; } try { List events = sessionManager.listEvents(session.getSessionId(), null, null); List teamBoardLines = TeamBoardRenderSupport.renderBoardLines(events); if (useAppendOnlyTranscriptTui()) { tuiPersistedTeamBoard = false; emitOutput(TeamBoardRenderSupport.renderBoardOutput(teamBoardLines)); return; } if (useMainBufferInteractiveShell()) { tuiPersistedTeamBoard = false; emitOutput(TeamBoardRenderSupport.renderBoardOutput(teamBoardLines)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { tuiPersistedTeamBoard = false; setTuiCachedTeamBoard(teamBoardLines); if (!teamBoardLines.isEmpty()) { interactionState.openTeamBoard(); setTuiAssistantOutput("Team board opened."); } else { setTuiAssistantOutput("team: (none)"); } return; } emitOutput(TeamBoardRenderSupport.renderBoardOutput(teamBoardLines)); } catch (IOException ex) { emitError("Failed to render team board: " + ex.getMessage()); } } private void handleTeamCommand(ManagedCodingSession session, String argument) { if (isBlank(argument)) { printTeamBoard(session); return; } CliTeamStateManager teamStateManager = createTeamStateManager(session); List tokens = splitWhitespace(argument); if (tokens.isEmpty()) { printTeamBoard(session); return; } String action = tokens.get(0).toLowerCase(Locale.ROOT); if ("list".equals(action)) { emitOutput(teamStateManager.renderListOutput()); return; } if ("status".equals(action)) { String requestedTeamId = teamArgument(tokens, 1); emitOutput(teamStateManager.renderStatusOutput(firstNonBlank(requestedTeamId, selectedPersistedTeamId))); return; } if ("messages".equals(action)) { String requestedTeamId = teamArgument(tokens, 1); Integer limit = tokens.size() > 2 ? Integer.valueOf(parseLimit(tokens.get(2))) : null; emitOutput(teamStateManager.renderMessagesOutput(firstNonBlank(requestedTeamId, selectedPersistedTeamId), limit)); return; } if ("resume".equals(action)) { resumePersistedTeamBoard(session, teamStateManager, firstNonBlank(teamArgument(tokens, 1), selectedPersistedTeamId)); return; } emitError("Usage: /team | /team list | /team status [team-id] | /team messages [team-id] [limit] | /team resume [team-id]"); } private void resumePersistedTeamBoard(ManagedCodingSession session, CliTeamStateManager teamStateManager, String requestedTeamId) { CliTeamStateManager.ResolvedTeamState resolved = teamStateManager == null ? null : teamStateManager.resolveState(requestedTeamId); if (resolved == null || resolved.getState() == null) { emitOutput(isBlank(requestedTeamId) ? "team: (none)" : "team not found: " + requestedTeamId.trim()); return; } selectedPersistedTeamId = resolved.getTeamId(); List boardLines = TeamBoardRenderSupport.renderBoardLines(resolved.getState()); if (useAppendOnlyTranscriptTui() || useMainBufferInteractiveShell()) { emitOutput(teamStateManager.renderResumeOutput(resolved.getTeamId())); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { tuiPersistedTeamBoard = true; setTuiCachedTeamBoard(boardLines); if (!boardLines.isEmpty()) { interactionState.openTeamBoard(); setTuiAssistantOutput("Team board resumed: " + resolved.getTeamId()); } else { setTuiAssistantOutput("team: (none)"); } return; } emitOutput(teamStateManager.renderResumeOutput(resolved.getTeamId())); } private String teamArgument(List tokens, int index) { if (tokens == null || index < 0 || tokens.size() <= index) { return null; } String value = tokens.get(index); return isBlank(value) ? null : value.trim(); } private CliTeamStateManager createTeamStateManager(ManagedCodingSession session) { return new CliTeamStateManager(resolveWorkspaceRoot(session)); } private List listKnownTeamIds() { return createTeamStateManager(activeSession).listKnownTeamIds(); } private Path resolveWorkspaceRoot(ManagedCodingSession session) { String workspace = session == null ? null : session.getWorkspace(); if (isBlank(workspace) && options != null) { workspace = options.getWorkspace(); } if (isBlank(workspace)) { return Paths.get(".").toAbsolutePath().normalize(); } return Paths.get(workspace).toAbsolutePath().normalize(); } private void printCompacts(ManagedCodingSession session, String limitArgument) { if (session == null) { emitOutput("compacts: (no current session)"); return; } int limit = parseLimit(limitArgument); try { List events = sessionManager.listEvents(session.getSessionId(), null, null); List compactLines = buildCompactLines(events, limit); if (compactLines.isEmpty()) { emitOutput("compacts: (none)"); return; } StringBuilder builder = new StringBuilder("compacts:\n"); for (String compactLine : compactLines) { builder.append(compactLine).append('\n'); } emitOutput(builder.toString().trim()); } catch (IOException ex) { emitError("Failed to list compact history: " + ex.getMessage()); } } private void printProcesses(ManagedCodingSession session) { CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot(); List processes = snapshot == null ? null : snapshot.getProcesses(); if (processes == null || processes.isEmpty()) { emitOutput("processes: (none)"); return; } if (useMainBufferInteractiveShell()) { emitOutput(renderProcessesOutput(processes)); return; } if (useAppendOnlyTranscriptTui()) { emitOutput(renderProcessesOutput(processes)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { if (isBlank(interactionState.getSelectedProcessId())) { interactionState.selectProcess(processes.get(0).getProcessId()); } return; } terminal.println("processes:"); for (BashProcessInfo process : processes) { terminal.println(" " + process.getProcessId() + " | status=" + process.getStatus() + " | mode=" + (process.isControlAvailable() ? "live" : "metadata-only") + " | restored=" + process.isRestored() + " | cwd=" + clip(process.getWorkingDirectory(), 48) + " | cmd=" + clip(process.getCommand(), 72)); } } private void handleProcessCommand(ManagedCodingSession session, String rawArguments) { if (session == null || session.getSession() == null) { emitError("No current session."); return; } if (isBlank(rawArguments)) { emitError("Usage: /process ..."); return; } String[] parts = rawArguments.trim().split("\\s+", 3); String action = parts[0]; try { if ("status".equalsIgnoreCase(action)) { if (parts.length < 2) { emitError("Usage: /process status "); return; } showProcessStatus(session, parts[1], false, DEFAULT_PROCESS_LOG_LIMIT); return; } if ("follow".equalsIgnoreCase(action)) { if (parts.length < 2) { emitError("Usage: /process follow [limit]"); return; } Integer limit = parts.length >= 3 ? parseLimit(parts[2]) : DEFAULT_PROCESS_LOG_LIMIT; showProcessStatus(session, parts[1], true, limit); return; } if ("logs".equalsIgnoreCase(action)) { if (parts.length < 2) { emitError("Usage: /process logs [limit]"); return; } Integer limit = parts.length >= 3 ? parseLimit(parts[2]) : DEFAULT_EVENT_LIMIT * 40; BashProcessLogChunk logs = session.getSession().processLogs(parts[1], null, limit); emitOutput("process logs:\n" + (logs == null ? "(none)" : firstNonBlank(logs.getContent(), "(none)"))); return; } if ("write".equalsIgnoreCase(action)) { if (parts.length < 3) { emitError("Usage: /process write "); return; } int bytesWritten = session.getSession().writeProcess(parts[1], parts[2]); emitOutput("process write: " + parts[1] + " bytes=" + bytesWritten); return; } if ("stop".equalsIgnoreCase(action)) { if (parts.length < 2) { emitError("Usage: /process stop "); return; } BashProcessInfo processInfo = session.getSession().stopProcess(parts[1]); emitOutput("process stopped: " + processInfo.getProcessId() + " status=" + processInfo.getStatus()); return; } emitError("Unknown process action: " + action); } catch (Exception ex) { emitError("Process command failed: " + safeMessage(ex)); } } private void openReplayViewer(ManagedCodingSession session, int limit) { if (session == null) { setTuiAssistantOutput("history: (no current session)"); return; } try { List events = sessionManager.listEvents(session.getSessionId(), limit, null); List replayLines = buildReplayLines(events); if (useAppendOnlyTranscriptTui()) { emitOutput(renderReplayOutput(replayLines)); return; } setTuiCachedReplay(replayLines); interactionState.openReplayViewer(); setTuiAssistantOutput(replayLines.isEmpty() ? "history: (none)" : "History opened."); } catch (IOException ex) { emitError("Failed to open replay viewer: " + ex.getMessage()); } } private void openProcessInspector(ManagedCodingSession session, String processId) { if (session == null || session.getSession() == null || isBlank(processId)) { return; } if (useAppendOnlyTranscriptTui()) { try { BashProcessInfo processInfo = session.getSession().processStatus(processId); BashProcessLogChunk logs = session.getSession().processLogs(processId, null, DEFAULT_PROCESS_LOG_LIMIT); emitOutput(renderProcessDetailsOutput(processInfo, logs)); } catch (Exception ex) { setTuiAssistantOutput("process inspector failed: " + safeMessage(ex)); } return; } interactionState.selectProcess(processId); interactionState.openProcessInspector(processId); try { setTuiProcessInspector( session.getSession().processStatus(processId), session.getSession().processLogs(processId, null, DEFAULT_PROCESS_LOG_LIMIT) ); setTuiAssistantOutput("Process inspector opened: " + processId); } catch (Exception ex) { setTuiAssistantOutput("process inspector failed: " + safeMessage(ex)); } } private List buildReplayLines(List events) { if (events == null || events.isEmpty()) { return new ArrayList(); } Map> byTurn = new LinkedHashMap>(); Map> completedToolKeysByTurn = buildCompletedToolKeysByTurn(events); for (SessionEvent event : events) { if (event == null || event.getType() == null) { continue; } String turnId = isBlank(event.getTurnId()) ? "session" : event.getTurnId(); if (event.getType() == SessionEventType.TOOL_CALL && completedToolKeysByTurn.containsKey(turnId) && completedToolKeysByTurn.get(turnId).contains(buildToolEventKey(event.getPayload()))) { continue; } List eventLines = buildReplayEventLines(event); if (eventLines == null || eventLines.isEmpty()) { continue; } List turnLines = byTurn.get(turnId); if (turnLines == null) { turnLines = new ArrayList(); byTurn.put(turnId, turnLines); } turnLines.addAll(eventLines); } List lines = new ArrayList(); for (List turnLines : byTurn.values()) { if (turnLines == null || turnLines.isEmpty()) { continue; } if (!lines.isEmpty()) { lines.add(""); } lines.addAll(turnLines); } return lines; } private Map> buildCompletedToolKeysByTurn(List events) { Map> keysByTurn = new LinkedHashMap>(); if (events == null || events.isEmpty()) { return keysByTurn; } for (SessionEvent event : events) { if (event == null || event.getType() != SessionEventType.TOOL_RESULT) { continue; } String key = buildToolEventKey(event.getPayload()); if (isBlank(key)) { continue; } String turnId = isBlank(event.getTurnId()) ? "session" : event.getTurnId(); java.util.Set turnKeys = keysByTurn.get(turnId); if (turnKeys == null) { turnKeys = new java.util.HashSet(); keysByTurn.put(turnId, turnKeys); } turnKeys.add(key); } return keysByTurn; } private String buildToolEventKey(Map payload) { if (payload == null || payload.isEmpty()) { return null; } String callId = safeTrimToNull(payloadString(payload, "callId")); if (!isBlank(callId)) { return callId; } String toolName = payloadString(payload, "tool"); JSONObject arguments = parseObject(payloadString(payload, "arguments")); return firstNonBlank(toolName, "tool") + "|" + firstNonBlank(safeTrimToNull(payloadString(payload, "title")), buildToolTitle(toolName, arguments)); } private List buildReplayEventLines(SessionEvent event) { List lines = new ArrayList(); SessionEventType type = event == null ? null : event.getType(); Map payload = event == null ? null : event.getPayload(); if (type == SessionEventType.USER_MESSAGE) { appendReplayBlock(lines, codexStyleBlockFormatter.formatAssistant( firstNonBlank(payloadString(payload, "input"), event.getSummary()) )); return lines; } if (type == SessionEventType.ASSISTANT_MESSAGE) { appendReplayBlock(lines, codexStyleBlockFormatter.formatAssistant( firstNonBlank(payloadString(payload, "output"), event.getSummary()) )); return lines; } if (type == SessionEventType.TOOL_CALL) { appendReplayToolLines(lines, payload, true); lines.add(""); return lines; } if (type == SessionEventType.TOOL_RESULT) { appendReplayToolLines(lines, payload, false); lines.add(""); return lines; } if (type == SessionEventType.ERROR) { appendReplayBlock(lines, codexStyleBlockFormatter.formatError( firstNonBlank(payloadString(payload, "error"), event.getSummary()) )); return lines; } if (type == SessionEventType.COMPACT) { appendReplayBlock(lines, codexStyleBlockFormatter.formatCompact(buildReplayCompactResult(event))); return lines; } if (type == SessionEventType.AUTO_CONTINUE || type == SessionEventType.AUTO_STOP || type == SessionEventType.BLOCKED) { String title = type == SessionEventType.AUTO_CONTINUE ? "Auto continue" : type == SessionEventType.BLOCKED ? "Blocked" : "Auto stop"; appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock( title, Collections.singletonList(firstNonBlank(event.getSummary(), title.toLowerCase(Locale.ROOT))) )); return lines; } if (type == SessionEventType.SESSION_RESUMED) { appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock( "Session resumed", Collections.singletonList(firstNonBlank(event.getSummary(), "session resumed")) )); return lines; } if (type == SessionEventType.SESSION_FORKED) { appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock( "Session forked", Collections.singletonList(firstNonBlank(event.getSummary(), "session forked")) )); return lines; } if (type == SessionEventType.TASK_CREATED || type == SessionEventType.TASK_UPDATED) { appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock( "Delegate task", buildReplayTaskLines(event) )); return lines; } if (type == SessionEventType.TEAM_MESSAGE) { appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock( "Team message", buildReplayTeamMessageLines(event) )); return lines; } return lines; } private List buildReplayTaskLines(SessionEvent event) { List lines = new ArrayList(); Map payload = event == null ? null : event.getPayload(); lines.add(firstNonBlank(event == null ? null : event.getSummary(), "delegate task")); String detail = firstNonBlank(payloadString(payload, "detail"), payloadString(payload, "error"), payloadString(payload, "output")); if (!isBlank(detail)) { lines.add(detail); } String member = firstNonBlank(payloadString(payload, "memberName"), payloadString(payload, "memberId")); if (!isBlank(member)) { lines.add("member: " + member); } String childSessionId = payloadString(payload, "childSessionId"); if (!isBlank(childSessionId)) { lines.add("child session: " + childSessionId); } String status = payloadString(payload, "status"); String phase = payloadString(payload, "phase"); String percent = payloadString(payload, "percent"); if (!isBlank(status) || !isBlank(phase) || !isBlank(percent)) { StringBuilder stateLine = new StringBuilder(); if (!isBlank(status)) { stateLine.append("status: ").append(status); } if (!isBlank(phase)) { if (stateLine.length() > 0) { stateLine.append(" | "); } stateLine.append("phase: ").append(phase); } if (!isBlank(percent)) { if (stateLine.length() > 0) { stateLine.append(" | "); } stateLine.append("progress: ").append(percent).append('%'); } lines.add(stateLine.toString()); } String heartbeatCount = payloadString(payload, "heartbeatCount"); if (!isBlank(heartbeatCount) && !"0".equals(heartbeatCount)) { lines.add("heartbeats: " + heartbeatCount); } return lines; } private List buildReplayTeamMessageLines(SessionEvent event) { List lines = new ArrayList(); Map payload = event == null ? null : event.getPayload(); lines.add(firstNonBlank(event == null ? null : event.getSummary(), "team message")); String taskId = payloadString(payload, "taskId"); if (!isBlank(taskId)) { lines.add("task: " + taskId); } String detail = firstNonBlank(payloadString(payload, "content"), payloadString(payload, "detail")); if (!isBlank(detail)) { lines.add(detail); } return lines; } private void appendReplayBlock(List lines, List blockLines) { if (lines == null || blockLines == null || blockLines.isEmpty()) { return; } lines.addAll(blockLines); lines.add(""); } private CodingSessionCompactResult buildReplayCompactResult(SessionEvent event) { Map payload = event == null ? null : event.getPayload(); return CodingSessionCompactResult.builder() .automatic("auto".equalsIgnoreCase(resolveCompactMode(event))) .beforeItemCount(defaultInt(payloadInt(payload, "beforeItemCount"))) .afterItemCount(defaultInt(payloadInt(payload, "afterItemCount"))) .estimatedTokensBefore(defaultInt(payloadInt(payload, "estimatedTokensBefore"))) .estimatedTokensAfter(defaultInt(payloadInt(payload, "estimatedTokensAfter"))) .splitTurn(payloadBoolean(payload, "splitTurn")) .summary(firstNonBlank(payloadString(payload, "summary"), event == null ? null : event.getSummary())) .build(); } private void appendReplayMultiline(List lines, String prefix, String text) { if (lines == null || isBlank(text)) { return; } String[] rawLines = text.replace("\r", "").split("\n"); String continuation = replayContinuation(prefix); for (int i = 0; i < rawLines.length; i++) { lines.add((i == 0 ? firstNonBlank(prefix, "") : continuation) + (rawLines[i] == null ? "" : rawLines[i])); } } private void appendReplayToolLines(List lines, Map payload, boolean pending) { if (lines == null || payload == null) { return; } String toolName = payloadString(payload, "tool"); JSONObject arguments = parseObject(payloadString(payload, "arguments")); JSONObject output = parseObject(payloadString(payload, "output")); String title = firstNonBlank(payloadString(payload, "title"), buildToolTitle(toolName, arguments)); List previewLines = payloadLines(payload, "previewLines"); if (pending) { lines.addAll(buildToolPrimaryLines(toolName, title, "pending", 120)); if (previewLines.isEmpty()) { previewLines = buildPendingToolPreviewLines(toolName, arguments); } previewLines = normalizeToolPreviewLines(toolName, "pending", previewLines); for (int i = 0; i < previewLines.size(); i++) { String prefix = i == 0 ? " \u2514 " : " "; lines.addAll(wrapPrefixedText(prefix, " ", previewLines.get(i), 120)); } return; } String rawOutput = payloadString(payload, "output"); if (isApprovalRejectedToolError(rawOutput, output)) { lines.addAll(codexStyleBlockFormatter.formatInfoBlock( "Rejected", Collections.singletonList(firstNonBlank( payloadString(payload, "detail"), buildCompletedToolDetail(toolName, arguments, output, rawOutput), normalizeToolPrimaryLabel(firstNonBlank(title, toolName)) )) )); return; } lines.addAll(buildToolPrimaryLines(toolName, title, isBlank(extractToolError(rawOutput, output)) ? "done" : "error", 120)); String detail = firstNonBlank(payloadString(payload, "detail"), buildCompletedToolDetail(toolName, arguments, output, rawOutput)); if (!isBlank(detail)) { lines.addAll(wrapPrefixedText(" \u2514 ", " ", detail, 120)); } if (previewLines.isEmpty()) { previewLines = buildToolPreviewLines(toolName, arguments, output, rawOutput); } previewLines = normalizeToolPreviewLines(toolName, "done", previewLines); for (int i = 0; i < previewLines.size(); i++) { String prefix = i == 0 && isBlank(detail) ? " \u2514 " : " "; lines.addAll(wrapPrefixedText(prefix, " ", previewLines.get(i), 120)); } } private List buildToolPrimaryLines(String toolName, String title, String status, int width) { List lines = new ArrayList(); String normalizedTool = firstNonBlank(toolName, "tool"); String normalizedStatus = firstNonBlank(status, "done").toLowerCase(Locale.ROOT); String label = normalizeToolPrimaryLabel(firstNonBlank(title, normalizedTool)); if ("error".equals(normalizedStatus)) { if ("bash".equals(normalizedTool)) { return wrapPrefixedText("\u2022 Command failed ", " \u2502 ", label, width); } lines.add("\u2022 Tool failed " + clip(label, Math.max(24, width - 16))); return lines; } if ("apply_patch".equals(normalizedTool)) { lines.add("pending".equals(normalizedStatus) ? "\u2022 Applying patch" : "\u2022 Applied patch"); return lines; } return wrapPrefixedText(resolveToolPrimaryPrefix(normalizedTool, title, normalizedStatus), " \u2502 ", label, width); } private String resolveToolPrimaryPrefix(String toolName, String title, String status) { String normalizedTool = firstNonBlank(toolName, "tool"); String normalizedTitle = firstNonBlank(title, normalizedTool); boolean pending = "pending".equalsIgnoreCase(status); if ("read_file".equals(normalizedTool)) { return pending ? "\u2022 Reading " : "\u2022 Read "; } if ("write_file".equals(normalizedTool)) { return pending ? "\u2022 Writing " : "\u2022 Wrote "; } if ("bash".equals(normalizedTool)) { if (normalizedTitle.startsWith("bash logs ")) { return pending ? "\u2022 Reading logs " : "\u2022 Read logs "; } if (normalizedTitle.startsWith("bash status ")) { return pending ? "\u2022 Checking " : "\u2022 Checked "; } if (normalizedTitle.startsWith("bash write ")) { return pending ? "\u2022 Writing to " : "\u2022 Wrote to "; } if (normalizedTitle.startsWith("bash stop ")) { return pending ? "\u2022 Stopping " : "\u2022 Stopped "; } } return pending ? "\u2022 Running " : "\u2022 Ran "; } private String normalizeToolPrimaryLabel(String title) { String normalizedTitle = firstNonBlank(title, "tool").trim(); if (normalizedTitle.startsWith("$ ")) { return normalizedTitle.substring(2).trim(); } if (normalizedTitle.startsWith("read ")) { return normalizedTitle.substring(5).trim(); } if (normalizedTitle.startsWith("write ")) { return normalizedTitle.substring(6).trim(); } if (normalizedTitle.startsWith("bash logs ")) { return normalizedTitle.substring("bash logs ".length()).trim(); } if (normalizedTitle.startsWith("bash status ")) { return normalizedTitle.substring("bash status ".length()).trim(); } if (normalizedTitle.startsWith("bash write ")) { return normalizedTitle.substring("bash write ".length()).trim(); } if (normalizedTitle.startsWith("bash stop ")) { return normalizedTitle.substring("bash stop ".length()).trim(); } return normalizedTitle; } private String stripToolTitlePrefix(String title, String prefix) { if (isBlank(title) || isBlank(prefix)) { return firstNonBlank(title, ""); } return title.startsWith(prefix) ? title.substring(prefix.length()).trim() : title; } private String replayContinuation(String prefix) { int length = prefix == null ? 0 : prefix.length(); StringBuilder builder = new StringBuilder(length); for (int i = 0; i < length; i++) { builder.append(' '); } return builder.toString(); } private List buildCompactLines(List events, int limit) { List compactEvents = new ArrayList(); if (events != null) { for (SessionEvent event : events) { if (event != null && event.getType() == SessionEventType.COMPACT) { compactEvents.add(event); } } } if (compactEvents.isEmpty()) { return new ArrayList(); } int safeLimit = limit <= 0 ? DEFAULT_EVENT_LIMIT : limit; int from = Math.max(0, compactEvents.size() - safeLimit); List lines = new ArrayList(); for (int i = from; i < compactEvents.size(); i++) { SessionEvent event = compactEvents.get(i); Map payload = event.getPayload(); StringBuilder line = new StringBuilder(); line.append(formatTimestamp(event.getTimestamp())) .append(" | mode=").append(resolveCompactMode(event)) .append(" | tokens=").append(formatCompactDelta( payloadInt(payload, "estimatedTokensBefore"), payloadInt(payload, "estimatedTokensAfter") )) .append(" | items=").append(formatCompactDelta( payloadInt(payload, "beforeItemCount"), payloadInt(payload, "afterItemCount") )); if (payloadBoolean(payload, "splitTurn")) { line.append(" | splitTurn"); } if (payloadBoolean(payload, "fallbackSummary")) { line.append(" | fallback"); } String checkpointGoal = clip(payloadString(payload, "checkpointGoal"), 64); if (!isBlank(checkpointGoal)) { line.append(" | goal=").append(checkpointGoal); } lines.add(line.toString()); String summary = firstNonBlank(payloadString(payload, "summary"), event.getSummary()); if (!isBlank(summary)) { lines.add(" - " + clip(summary, 140)); } } return lines; } private void showProcessStatus(ManagedCodingSession session, String processId, boolean includeLogs, int logLimit) throws Exception { BashProcessInfo processInfo = session.getSession().processStatus(processId); if (useMainBufferInteractiveShell() && !includeLogs) { emitOutput(renderProcessStatusOutput(processInfo)); return; } if (useAppendOnlyTranscriptTui() && !includeLogs) { emitOutput(renderProcessStatusOutput(processInfo)); return; } if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) { BashProcessLogChunk logs = includeLogs ? session.getSession().processLogs(processId, null, logLimit) : null; interactionState.selectProcess(processId); interactionState.openProcessInspector(processId); setTuiAssistantOutput(includeLogs ? "Process inspector opened: " + processId : "Selected process: " + processId); setTuiProcessInspector(processInfo, logs); return; } if (includeLogs) { followProcessLogs(session, processInfo, logLimit); return; } StringBuilder builder = new StringBuilder(); builder.append("process status:\n"); appendProcessSummary(builder, processInfo); terminal.println(builder.toString()); } private void followProcessLogs(ManagedCodingSession session, BashProcessInfo initialProcessInfo, int logLimit) throws Exception { BashProcessInfo processInfo = initialProcessInfo; long nextOffset = 0L; int idlePolls = 0; StringBuilder builder = new StringBuilder(); builder.append("process status:\n"); appendProcessSummary(builder, processInfo); builder.append('\n').append("process follow:"); terminal.println(builder.toString()); while (true) { BashProcessLogChunk logs = session.getSession().processLogs(processInfo.getProcessId(), Long.valueOf(nextOffset), logLimit); boolean advanced = logs != null && logs.getNextOffset() > nextOffset; if (advanced) { String content = logs.getContent(); if (!isBlank(content)) { terminal.println(content); } nextOffset = logs.getNextOffset(); idlePolls = 0; } processInfo = session.getSession().processStatus(processInfo.getProcessId()); BashProcessStatus status = logs != null && logs.getStatus() != null ? logs.getStatus() : processInfo.getStatus(); if (status != BashProcessStatus.RUNNING) { if (advanced) { continue; } terminal.println("process final: status=" + status + ", exitCode=" + processInfo.getExitCode()); return; } if (advanced) { continue; } idlePolls++; if (idlePolls >= PROCESS_FOLLOW_MAX_IDLE_POLLS) { terminal.println("(follow paused: no new output yet, process still running; rerun /process follow to continue)"); return; } try { Thread.sleep(PROCESS_FOLLOW_POLL_MS); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); terminal.println("(follow interrupted)"); return; } } } private void appendProcessSummary(StringBuilder builder, BashProcessInfo processInfo) { builder.append(" id=").append(processInfo.getProcessId()) .append(" | status=").append(processInfo.getStatus()) .append(" | mode=").append(processInfo.isControlAvailable() ? "live" : "metadata-only") .append(" | restored=").append(processInfo.isRestored()) .append('\n'); builder.append(" cwd=").append(clip(processInfo.getWorkingDirectory(), 120)).append('\n'); builder.append(" cmd=").append(clip(processInfo.getCommand(), 120)); } private void writeSelectedProcessInput(ManagedCodingSession session) { String processId = interactionState.getSelectedProcessId(); String input = interactionState.consumeProcessInputBuffer(); if (isBlank(processId) || isBlank(input) || session == null || session.getSession() == null) { return; } try { int bytesWritten = session.getSession().writeProcess(processId, input); setTuiAssistantOutput("process write: " + processId + " bytes=" + bytesWritten); renderTui(session); } catch (Exception ex) { setTuiAssistantOutput("process write failed: " + safeMessage(ex)); } } private List resolveProcessIds(ManagedCodingSession session) { CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot(); List processes = snapshot == null ? null : snapshot.getProcesses(); List processIds = new ArrayList(); if (processes == null) { return processIds; } for (BashProcessInfo process : processes) { if (process != null && !isBlank(process.getProcessId())) { processIds.add(process.getProcessId()); } } return processIds; } private List buildProcessCompletionCandidates() { CodingSessionSnapshot snapshot = activeSession == null || activeSession.getSession() == null ? null : activeSession.getSession().snapshot(); List processes = snapshot == null ? null : snapshot.getProcesses(); List candidates = new ArrayList(); if (processes == null) { return candidates; } for (BashProcessInfo process : processes) { if (process == null || isBlank(process.getProcessId())) { continue; } candidates.add(new SlashCommandController.ProcessCompletionCandidate( process.getProcessId(), buildProcessCompletionDescription(process) )); } return candidates; } private List buildModelCompletionCandidates() { LinkedHashMap candidates = new LinkedHashMap(); CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties); String currentProvider = options.getProvider() == null ? (resolved.getProvider() == null ? null : resolved.getProvider().getPlatform()) : options.getProvider().getPlatform(); addModelCompletionCandidate(candidates, resolved.getModelOverride(), "Current workspace override"); addModelCompletionCandidate(candidates, options.getModel(), "Current effective model"); CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); addProfileModelCompletionCandidate(candidates, workspaceConfig.getActiveProfile(), currentProvider, "Active profile"); addProfileModelCompletionCandidate(candidates, providersConfig.getDefaultProfile(), currentProvider, "Default profile"); for (String profileName : providerConfigManager.listProfileNames()) { addProfileModelCompletionCandidate(candidates, profileName, currentProvider, "Saved profile"); } return new ArrayList(candidates.values()); } private List listKnownSkillNames() { WorkspaceContext workspaceContext = activeSession == null || activeSession.getSession() == null ? null : activeSession.getSession().getWorkspaceContext(); List skills = workspaceContext == null ? null : workspaceContext.getAvailableSkills(); if (skills == null || skills.isEmpty()) { return Collections.emptyList(); } LinkedHashSet names = new LinkedHashSet(); for (CodingSkillDescriptor skill : skills) { if (skill != null && !isBlank(skill.getName())) { names.add(skill.getName().trim()); } } return new ArrayList(names); } private List listKnownAgentNames() { CodingAgentDefinitionRegistry registry = agent == null ? null : agent.getDefinitionRegistry(); List definitions = registry == null ? null : registry.listDefinitions(); if (definitions == null || definitions.isEmpty()) { return Collections.emptyList(); } LinkedHashSet names = new LinkedHashSet(); for (CodingAgentDefinition definition : definitions) { if (definition == null) { continue; } if (!isBlank(definition.getName())) { names.add(definition.getName().trim()); } if (!isBlank(definition.getToolName())) { names.add(definition.getToolName().trim()); } } return new ArrayList(names); } private List listKnownMcpServerNames() { LinkedHashSet names = new LinkedHashSet(); CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig(); if (globalConfig.getMcpServers() != null) { names.addAll(globalConfig.getMcpServers().keySet()); } CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig(); if (workspaceConfig.getEnabledMcpServers() != null) { names.addAll(workspaceConfig.getEnabledMcpServers()); } names.addAll(pausedMcpServers); return new ArrayList(names); } private void addProfileModelCompletionCandidate(LinkedHashMap candidates, String profileName, String provider, String label) { if (candidates == null || isBlank(profileName)) { return; } CliProviderProfile profile = providerConfigManager.getProfile(profileName); if (profile == null || isBlank(profile.getModel())) { return; } if (!isBlank(provider) && !provider.equalsIgnoreCase(firstNonBlank(profile.getProvider(), provider))) { return; } addModelCompletionCandidate(candidates, profile.getModel(), label + " " + profileName); } private void addModelCompletionCandidate(LinkedHashMap candidates, String model, String description) { if (candidates == null || isBlank(model)) { return; } String key = model.trim().toLowerCase(Locale.ROOT); if (candidates.containsKey(key)) { return; } candidates.put(key, new SlashCommandController.ModelCompletionCandidate(model.trim(), description)); } private boolean containsMcpServer(String name) { if (isBlank(name)) { return false; } String normalizedName = name.trim(); CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig(); if (globalConfig.getMcpServers().containsKey(normalizedName)) { return true; } CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig(); return containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), normalizedName); } private boolean containsIgnoreCase(List values, String target) { if (values == null || values.isEmpty() || isBlank(target)) { return false; } for (String value : values) { if (!isBlank(value) && target.trim().equalsIgnoreCase(value.trim())) { return true; } } return false; } private List addName(List values, String name) { LinkedHashSet names = new LinkedHashSet(); if (values != null) { for (String value : values) { if (!isBlank(value)) { names.add(value.trim()); } } } if (!isBlank(name)) { names.add(name.trim()); } return names.isEmpty() ? null : new ArrayList(names); } private List removeName(List values, String name) { if (values == null || values.isEmpty()) { return null; } List remaining = new ArrayList(); for (String value : values) { if (isBlank(value)) { continue; } if (!value.trim().equalsIgnoreCase(firstNonBlank(name, ""))) { remaining.add(value.trim()); } } return remaining.isEmpty() ? null : remaining; } private List splitWhitespace(String value) { if (isBlank(value)) { return Collections.emptyList(); } return Arrays.asList(value.trim().split("\\s+")); } private String normalizeMcpTransport(String rawTransport) { if (isBlank(rawTransport)) { return null; } String normalized = rawTransport.trim().toLowerCase(Locale.ROOT); if ("http".equals(normalized) || "streamable_http".equals(normalized) || "streamable-http".equals(normalized)) { return "streamable_http"; } if ("sse".equals(normalized)) { return "sse"; } if ("stdio".equals(normalized)) { return "stdio"; } return null; } private String buildProcessCompletionDescription(BashProcessInfo process) { if (process == null) { return "Process"; } StringBuilder builder = new StringBuilder(); builder.append(firstNonBlank( process.getStatus() == null ? null : String.valueOf(process.getStatus()).toLowerCase(Locale.ROOT), "unknown" )); builder.append(" | ").append(process.isControlAvailable() ? "live" : "metadata-only"); if (process.isRestored()) { builder.append(" | restored"); } String command = clip(process.getCommand(), 48); if (!isBlank(command)) { builder.append(" | ").append(command); } return builder.toString(); } private String firstProcessId(ManagedCodingSession session) { List processIds = resolveProcessIds(session); return processIds.isEmpty() ? null : processIds.get(0); } private List mergeCurrentSession(List sessions, ManagedCodingSession currentSession) { List merged = sessions == null ? new ArrayList() : new ArrayList(sessions); if (currentSession == null) { return merged; } CodingSessionDescriptor currentDescriptor = currentSession.toDescriptor(); for (int i = 0; i < merged.size(); i++) { CodingSessionDescriptor existing = merged.get(i); if (existing != null && currentDescriptor.getSessionId().equals(existing.getSessionId())) { merged.set(i, currentDescriptor); return merged; } } merged.add(0, currentDescriptor); return merged; } private CodingSessionDescriptor resolveTargetDescriptor(List sessions, ManagedCodingSession currentSession, String targetSessionId) { if (isBlank(targetSessionId)) { return currentSession == null ? null : currentSession.toDescriptor(); } for (CodingSessionDescriptor session : sessions) { if (session != null && targetSessionId.equals(session.getSessionId())) { return session; } } return null; } private List resolveHistory(List sessions, CodingSessionDescriptor target) { List history = new ArrayList(); if (target == null) { return history; } Map byId = new LinkedHashMap(); for (CodingSessionDescriptor session : sessions) { if (session != null && !isBlank(session.getSessionId())) { byId.put(session.getSessionId(), session); } } CodingSessionDescriptor cursor = target; while (cursor != null) { history.add(0, cursor); String parentSessionId = cursor.getParentSessionId(); if (isBlank(parentSessionId)) { break; } CodingSessionDescriptor next = byId.get(parentSessionId); if (next == null || parentSessionId.equals(cursor.getSessionId())) { break; } cursor = next; } return history; } private List resolveHistoryLines(List sessions, CodingSessionDescriptor target) { List history = resolveHistory(sessions, target); List lines = new ArrayList(); for (CodingSessionDescriptor session : history) { lines.add(session.getSessionId() + " | parent=" + firstNonBlank(session.getParentSessionId(), "(root)") + " | " + clip(session.getSummary(), 84)); } return lines; } private List renderTreeLines(List sessions, String rootArgument, String currentSessionId) { Map byId = new LinkedHashMap(); Map> children = new LinkedHashMap>(); List roots = new ArrayList(); for (CodingSessionDescriptor session : sessions) { if (session == null || isBlank(session.getSessionId())) { continue; } byId.put(session.getSessionId(), session); } for (CodingSessionDescriptor session : byId.values()) { String parentSessionId = session.getParentSessionId(); if (isBlank(parentSessionId) || !byId.containsKey(parentSessionId)) { roots.add(session); continue; } List siblings = children.get(parentSessionId); if (siblings == null) { siblings = new ArrayList(); children.put(parentSessionId, siblings); } siblings.add(session); } java.util.Comparator comparator = java.util.Comparator .comparingLong(CodingSessionDescriptor::getCreatedAtEpochMs) .thenComparing(CodingSessionDescriptor::getSessionId); java.util.Collections.sort(roots, comparator); for (List siblings : children.values()) { java.util.Collections.sort(siblings, comparator); } List lines = new ArrayList(); if (isBlank(rootArgument)) { for (CodingSessionDescriptor root : roots) { appendTreeLines(lines, children, root, 0, currentSessionId); } return lines; } CodingSessionDescriptor root = byId.get(rootArgument); if (root == null) { for (CodingSessionDescriptor candidate : byId.values()) { if (rootArgument.equals(candidate.getRootSessionId())) { root = byId.get(candidate.getRootSessionId()); break; } } } if (root != null) { appendTreeLines(lines, children, root, 0, currentSessionId); } return lines; } private void appendTreeLines(List lines, Map> children, CodingSessionDescriptor session, int depth, String currentSessionId) { if (session == null) { return; } StringBuilder builder = new StringBuilder(); for (int i = 0; i < depth; i++) { builder.append(" "); } builder.append(depth == 0 ? "* " : "- "); builder.append(session.getSessionId()); if (session.getSessionId().equals(currentSessionId)) { builder.append(" [current]"); } builder.append(" | updated=").append(formatTimestamp(session.getUpdatedAtEpochMs())); builder.append(" | ").append(clip(session.getSummary(), 96)); lines.add(builder.toString()); List childSessions = children.get(session.getSessionId()); if (childSessions == null) { return; } for (CodingSessionDescriptor child : childSessions) { appendTreeLines(lines, children, child, depth + 1, currentSessionId); } } private List renderCommandLines(List commands) { List lines = new ArrayList(); lines.add("/help /status /session /theme /save /providers /provider /model /experimental /skills /agents"); lines.add("/provider use|save|add|edit|default|remove /mcp"); lines.add("/experimental subagent|agent-teams on|off"); lines.add("/mcp add|enable|disable|pause|resume|retry|remove"); lines.add("/commands /palette /cmd /sessions /history /tree /events /replay /team /team list|status|messages|resume /compacts /checkpoint"); lines.add("/processes /process status|follow|logs|write|stop"); lines.add("/resume /load /fork ... /compact /clear /exit"); if (commands != null) { int max = Math.min(commands.size(), 5); for (int i = 0; i < max; i++) { CustomCommandTemplate command = commands.get(i); lines.add("/cmd " + command.getName() + " | " + clip(firstNonBlank(command.getDescription(), "(no description)"), 72)); } } return lines; } private List buildPaletteItems(ManagedCodingSession session) { List items = new ArrayList(buildCommandPaletteItems()); for (String themeName : tuiConfigManager.listThemeNames()) { items.add(new TuiPaletteItem("theme-" + themeName, "theme", "Theme: " + themeName, "/theme " + themeName, "/theme " + themeName)); } for (String profileName : providerConfigManager.listProfileNames()) { items.add(new TuiPaletteItem( "provider-" + profileName, "provider", "Provider: " + profileName, "Switch to saved profile " + profileName, "/provider use " + profileName )); } for (String serverName : listKnownMcpServerNames()) { items.add(new TuiPaletteItem( "mcp-enable-" + serverName, "mcp", "MCP enable: " + serverName, "Enable workspace MCP service " + serverName, "/mcp enable " + serverName )); items.add(new TuiPaletteItem( "mcp-pause-" + serverName, "mcp", "MCP pause: " + serverName, "Pause MCP service " + serverName + " for this session", "/mcp pause " + serverName )); } try { List sessions = mergeCurrentSession(sessionManager.list(), session); int maxSessions = Math.min(sessions.size(), 8); for (int i = 0; i < maxSessions; i++) { CodingSessionDescriptor descriptor = sessions.get(i); items.add(new TuiPaletteItem( "resume-" + descriptor.getSessionId(), "session", "Resume: " + descriptor.getSessionId(), clip(firstNonBlank(descriptor.getSummary(), descriptor.getSessionId()), 96), "/resume " + descriptor.getSessionId() )); } } catch (IOException ex) { if (options.isVerbose()) { terminal.errorln("Failed to build palette sessions: " + ex.getMessage()); } } return items; } private List buildCommandPaletteItems() { List items = new ArrayList(); items.add(new TuiPaletteItem("help", "command", "/help", "Show help", "/help")); items.add(new TuiPaletteItem("status", "command", "/status", "Show current session status", "/status")); items.add(new TuiPaletteItem("session", "command", "/session", "Show current session metadata", "/session")); items.add(new TuiPaletteItem("theme", "command", "/theme", "Show or switch the active theme", "/theme")); items.add(new TuiPaletteItem("save", "command", "/save", "Persist the current session state", "/save")); items.add(new TuiPaletteItem("providers", "command", "/providers", "List saved provider profiles", "/providers")); items.add(new TuiPaletteItem("provider", "command", "/provider", "Show current provider/profile state", "/provider")); items.add(new TuiPaletteItem("provider-use", "command", "/provider use", "Switch to a saved provider profile", "/provider use")); items.add(new TuiPaletteItem("provider-save", "command", "/provider save", "Save the current runtime as a profile", "/provider save")); items.add(new TuiPaletteItem("provider-add", "command", "/provider add", "Create a provider profile from explicit fields", "/provider add")); items.add(new TuiPaletteItem("provider-edit", "command", "/provider edit", "Update a saved provider profile", "/provider edit")); items.add(new TuiPaletteItem("provider-default", "command", "/provider default", "Set the global default provider profile", "/provider default")); items.add(new TuiPaletteItem("provider-remove", "command", "/provider remove", "Delete a saved provider profile", "/provider remove")); items.add(new TuiPaletteItem("model", "command", "/model", "Show the current effective model", "/model")); items.add(new TuiPaletteItem("model-reset", "command", "/model reset", "Clear the workspace model override", "/model reset")); items.add(new TuiPaletteItem("experimental", "command", "/experimental", "Show experimental runtime feature state", "/experimental")); items.add(new TuiPaletteItem("experimental-subagent-on", "command", "/experimental subagent on", "Enable experimental subagent tool injection", "/experimental subagent on")); items.add(new TuiPaletteItem("experimental-subagent-off", "command", "/experimental subagent off", "Disable experimental subagent tool injection", "/experimental subagent off")); items.add(new TuiPaletteItem("experimental-agent-teams-on", "command", "/experimental agent-teams on", "Enable experimental agent team tool injection", "/experimental agent-teams on")); items.add(new TuiPaletteItem("experimental-agent-teams-off", "command", "/experimental agent-teams off", "Disable experimental agent team tool injection", "/experimental agent-teams off")); items.add(new TuiPaletteItem("skills", "command", "/skills", "List discovered coding skills", "/skills")); items.add(new TuiPaletteItem("agents", "command", "/agents", "List available coding agents", "/agents")); items.add(new TuiPaletteItem("mcp", "command", "/mcp", "Show current MCP services and status", "/mcp")); items.add(new TuiPaletteItem("mcp-add", "command", "/mcp add", "Add a global MCP service", "/mcp add --transport ")); items.add(new TuiPaletteItem("mcp-enable", "command", "/mcp enable", "Enable an MCP service in this workspace", "/mcp enable ")); items.add(new TuiPaletteItem("mcp-disable", "command", "/mcp disable", "Disable an MCP service in this workspace", "/mcp disable ")); items.add(new TuiPaletteItem("mcp-pause", "command", "/mcp pause", "Pause an MCP service for this session", "/mcp pause ")); items.add(new TuiPaletteItem("mcp-resume", "command", "/mcp resume", "Resume an MCP service for this session", "/mcp resume ")); items.add(new TuiPaletteItem("mcp-retry", "command", "/mcp retry", "Reconnect an MCP service", "/mcp retry ")); items.add(new TuiPaletteItem("mcp-remove", "command", "/mcp remove", "Delete a global MCP service", "/mcp remove ")); items.add(new TuiPaletteItem("commands", "command", "/commands", "List available custom commands", "/commands")); items.add(new TuiPaletteItem("palette", "command", "/palette", "Alias of /commands", "/palette")); items.add(new TuiPaletteItem("cmd", "command", "/cmd", "Run a custom command template", "/cmd")); items.add(new TuiPaletteItem("sessions", "command", "/sessions", "List saved sessions", "/sessions")); items.add(new TuiPaletteItem("history", "command", "/history", "Show session lineage", "/history")); items.add(new TuiPaletteItem("tree", "command", "/tree", "Show the current session tree", "/tree")); items.add(new TuiPaletteItem("events", "command", "/events", "Show the latest session ledger events", "/events 20")); items.add(new TuiPaletteItem("replay", "command", "/replay", "Show recent history", "/replay 20")); items.add(new TuiPaletteItem("team", "command", "/team", "Open the current agent team board", "/team")); items.add(new TuiPaletteItem("team-list", "command", "/team list", "List persisted teams in this workspace", "/team list")); items.add(new TuiPaletteItem("team-status", "command", "/team status ", "Inspect one persisted team snapshot", "/team status ")); items.add(new TuiPaletteItem("team-messages", "command", "/team messages ", "Inspect persisted team mailbox messages", "/team messages ")); items.add(new TuiPaletteItem("team-resume", "command", "/team resume ", "Reopen a persisted team board from disk", "/team resume ")); items.add(new TuiPaletteItem("compacts", "command", "/compacts", "Show compact history from the event ledger", "/compacts 20")); items.add(new TuiPaletteItem("processes", "command", "/processes", "List active and restored process metadata", "/processes")); items.add(new TuiPaletteItem("process-status", "command", "/process status", "Show metadata for one process", "/process status")); items.add(new TuiPaletteItem("process-follow", "command", "/process follow", "Follow buffered logs for a process", "/process follow")); items.add(new TuiPaletteItem("process-logs", "command", "/process logs", "Read buffered logs for a process", "/process logs")); items.add(new TuiPaletteItem("process-write", "command", "/process write", "Write text to process stdin", "/process write")); items.add(new TuiPaletteItem("process-stop", "command", "/process stop", "Stop a live process", "/process stop")); items.add(new TuiPaletteItem("checkpoint", "command", "/checkpoint", "Show the current structured checkpoint summary", "/checkpoint")); items.add(new TuiPaletteItem("resume", "command", "/resume", "Resume a saved session", "/resume")); items.add(new TuiPaletteItem("load", "command", "/load", "Alias of /resume", "/load")); items.add(new TuiPaletteItem("fork", "command", "/fork", "Fork a session branch", "/fork")); items.add(new TuiPaletteItem("compact", "command", "/compact", "Compact current session memory", "/compact")); items.add(new TuiPaletteItem("clear", "command", "/clear", "Print a new screen section", "/clear")); items.add(new TuiPaletteItem("exit", "command", "/exit", "Exit session", "/exit")); List commands = customCommandRegistry.list(); for (CustomCommandTemplate command : commands) { items.add(new TuiPaletteItem( "cmd-" + command.getName(), "command", "/cmd " + command.getName(), firstNonBlank(command.getDescription(), "Run custom command " + command.getName()), "/cmd " + command.getName() )); } return items; } private void emitOutput(String text) { if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatOutput(text)); return; } if (options.getUiMode() == CliUiMode.TUI) { setTuiAssistantOutput(text); return; } terminal.println(text); } private void emitError(String text) { if (useAppendOnlyTranscriptTui()) { emitOutput("Error: " + firstNonBlank(text, "unknown error")); return; } if (useMainBufferInteractiveShell()) { emitMainBufferError(text); return; } terminal.errorln(text); } private boolean useMainBufferInteractiveShell() { if (options.getUiMode() != CliUiMode.TUI || useAlternateScreenTui()) { return false; } Boolean explicit = resolveMainBufferInteractiveOverride(); if (explicit != null) { return explicit.booleanValue(); } if (useAppendOnlyTranscriptTui()) { return false; } // Default built-in non-alt-screen TUI sessions to Codex-style transcript mode. return tuiRuntime instanceof AnsiTuiRuntime && tuiRenderer instanceof TuiSessionView; } private boolean useAppendOnlyTranscriptTui() { return options.getUiMode() == CliUiMode.TUI && tuiRuntime instanceof AppendOnlyTuiRuntime; } private boolean suppressMainBufferReasoningBlocks() { return false; } private boolean streamTranscriptEnabled() { return useMainBufferInteractiveShell() && streamEnabled; } private boolean renderMainBufferAssistantIncrementally() { return false; } private boolean renderMainBufferReasoningIncrementally() { return false; } private Boolean resolveMainBufferInteractiveOverride() { String value = System.getProperty("ai4j.tui.main-buffer"); if (isBlank(value)) { value = System.getenv("AI4J_TUI_MAIN_BUFFER"); } if (isBlank(value)) { return null; } return Boolean.valueOf("true".equalsIgnoreCase(value.trim())); } private List buildMainBufferToolLines(TuiAssistantToolView toolView) { return codexStyleBlockFormatter.formatTool(toolView); } private String buildMainBufferRunningStatus(TuiAssistantToolView toolView) { return codexStyleBlockFormatter.formatRunningStatus(toolView); } private void emitMainBufferAssistant(String text) { if (!isBlank(text)) { mainBufferTurnPrinter.printAssistantBlock(text); } } private void emitMainBufferReasoning(String text) { if (isBlank(text)) { return; } List lines = new ArrayList(); String[] rawLines = text.replace("\r", "").split("\n", -1); int start = 0; int end = rawLines.length - 1; while (start <= end && isBlank(rawLines[start])) { start++; } while (end >= start && isBlank(rawLines[end])) { end--; } if (start > end) { return; } String continuationPrefix = repeat(' ', "Thinking: ".length()); boolean previousBlank = false; for (int i = start; i <= end; i++) { String rawLine = rawLines[i] == null ? "" : rawLines[i]; if (isBlank(rawLine)) { if (!lines.isEmpty() && !previousBlank) { lines.add(""); } previousBlank = true; continue; } lines.add((lines.isEmpty() ? "Thinking: " : continuationPrefix) + rawLine); previousBlank = false; } if (lines.isEmpty()) { return; } mainBufferTurnPrinter.printBlock(lines); } private void emitMainBufferError(String message) { mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatError(message)); } private String lastPathSegment(String value) { if (isBlank(value)) { return "."; } String normalized = value.replace('\\', '/'); int index = normalized.lastIndexOf('/'); return index >= 0 && index + 1 < normalized.length() ? normalized.substring(index + 1) : normalized; } private void refreshTuiSessions() { if (tuiRenderer == null) { return; } try { List sessions = mergeCurrentSession(sessionManager.list(), activeSession); setTuiCachedSessions(sessions); setTuiCachedHistory(resolveHistoryLines(sessions, activeSession == null ? null : activeSession.toDescriptor())); setTuiCachedTree(renderTreeLines(sessions, null, activeSession == null ? null : activeSession.getSessionId())); setTuiCachedCommands(renderCommandLines(customCommandRegistry.list())); } catch (IOException ex) { terminal.errorln("Failed to refresh sessions: " + ex.getMessage()); } } private void refreshTuiEvents(ManagedCodingSession session) { if (tuiRenderer == null || session == null) { return; } try { setTuiCachedEvents(sessionManager.listEvents(session.getSessionId(), null, null)); } catch (IOException ex) { terminal.errorln("Failed to refresh session events: " + ex.getMessage()); } } private void refreshTuiReplay(ManagedCodingSession session) { if (tuiRenderer == null || session == null || !interactionState.isReplayViewerOpen()) { return; } try { List events = sessionManager.listEvents(session.getSessionId(), DEFAULT_REPLAY_LIMIT, null); setTuiCachedReplay(buildReplayLines(events)); } catch (IOException ex) { terminal.errorln("Failed to refresh replay viewer: " + ex.getMessage()); } } private void refreshTuiTeamBoard(ManagedCodingSession session) { if (tuiRenderer == null || session == null || !interactionState.isTeamBoardOpen() || tuiPersistedTeamBoard) { return; } try { List events = sessionManager.listEvents(session.getSessionId(), null, null); setTuiCachedTeamBoard(TeamBoardRenderSupport.renderBoardLines(events)); } catch (IOException ex) { terminal.errorln("Failed to refresh team board: " + ex.getMessage()); } } private void refreshTuiProcessInspector(ManagedCodingSession session) { if (tuiRenderer == null || session == null || session.getSession() == null) { return; } String processId = firstNonBlank(interactionState.getSelectedProcessId(), firstProcessId(session)); if (isBlank(processId)) { setTuiProcessInspector(null, null); return; } try { BashProcessInfo processInfo = session.getSession().processStatus(processId); BashProcessLogChunk logs = interactionState.isProcessInspectorOpen() ? session.getSession().processLogs(processId, null, DEFAULT_PROCESS_LOG_LIMIT) : null; setTuiProcessInspector(processInfo, logs); } catch (Exception ex) { setTuiAssistantOutput("process refresh failed: " + safeMessage(ex)); setTuiProcessInspector(null, null); } } private void renderTui(ManagedCodingSession session) { renderTui(session, true); } private void renderTuiFromCache(ManagedCodingSession session) { renderTui(session, false); } private void renderTui(ManagedCodingSession session, boolean refreshCaches) { if (useMainBufferInteractiveShell()) { return; } if (tuiRenderer == null || tuiRuntime == null) { return; } activeSession = session; if (refreshCaches) { refreshTuiSessions(); refreshTuiEvents(session); refreshTuiReplay(session); refreshTuiTeamBoard(session); refreshTuiProcessInspector(session); } tuiRuntime.render(buildTuiScreenModel(session)); } private TuiScreenModel buildTuiScreenModel(ManagedCodingSession session) { CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot(); CodingSessionDescriptor descriptor = session == null ? null : session.toDescriptor(); CodingSessionCheckpoint checkpoint = session == null || session.getSession() == null ? null : session.getSession().exportState().getCheckpoint(); return TuiScreenModel.builder() .config(tuiConfig) .theme(tuiTheme) .descriptor(descriptor) .snapshot(snapshot) .checkpoint(checkpoint) .renderContext(buildTuiRenderContext()) .interactionState(interactionState) .cachedSessions(tuiSessions) .cachedHistory(tuiHistory) .cachedTree(tuiTree) .cachedCommands(tuiCommands) .cachedEvents(tuiEvents) .cachedReplay(tuiReplay) .cachedTeamBoard(tuiTeamBoard) .inspectedProcess(tuiInspectedProcess) .inspectedProcessLogs(tuiInspectedProcessLogs) .assistantOutput(tuiAssistantOutput) .assistantViewModel(tuiLiveTurnState.toViewModel()) .build(); } private void setTuiAssistantOutput(String output) { this.tuiAssistantOutput = isBlank(output) ? null : output; } private void setTuiCachedSessions(List sessions) { this.tuiSessions = sessions == null ? new ArrayList() : new ArrayList(sessions); } private void setTuiCachedHistory(List historyLines) { this.tuiHistory = historyLines == null ? new ArrayList() : new ArrayList(historyLines); } private void setTuiCachedTree(List treeLines) { this.tuiTree = treeLines == null ? new ArrayList() : new ArrayList(treeLines); } private void setTuiCachedCommands(List commands) { this.tuiCommands = commands == null ? new ArrayList() : new ArrayList(commands); } private void setTuiCachedEvents(List events) { this.tuiEvents = events == null ? new ArrayList() : new ArrayList(events); } private void setTuiCachedReplay(List replayLines) { this.tuiReplay = replayLines == null ? new ArrayList() : new ArrayList(replayLines); } private void setTuiCachedTeamBoard(List teamBoardLines) { this.tuiTeamBoard = teamBoardLines == null ? new ArrayList() : new ArrayList(teamBoardLines); } private void setTuiProcessInspector(BashProcessInfo processInfo, BashProcessLogChunk logs) { this.tuiInspectedProcess = processInfo; this.tuiInspectedProcessLogs = logs; } private boolean shouldAutoRefresh(ManagedCodingSession session) { if (session == null || session.getSession() == null) { return false; } if (!useAlternateScreenTui()) { return false; } return interactionState.isProcessInspectorOpen() && tuiInspectedProcess != null && tuiInspectedProcess.isControlAvailable(); } private boolean shouldAnimateAppendOnlyFooter() { return useAppendOnlyTranscriptTui() && terminal != null && terminal.supportsAnsi() && tuiLiveTurnState.isSpinnerActive(); } private void beginTuiTurn(String input) { if (!isTuiMode()) { return; } setTuiAssistantOutput(null); tuiLiveTurnState.beginTurn(input); } private boolean isTuiMode() { return options.getUiMode() == CliUiMode.TUI; } private void renderTuiIfEnabled(ManagedCodingSession session) { if (isTuiMode()) { renderTui(session); } } private boolean shouldRunTurnsAsync() { return isTuiMode() && tuiRuntime != null && tuiRuntime.supportsRawInput() && !useMainBufferInteractiveShell(); } private void runMainBufferTurn(final ManagedCodingSession session, final String input) throws Exception { if (session == null || isBlank(input)) { return; } final String turnId = newTurnId(); final Exception[] failure = new Exception[1]; Thread worker = new Thread(new Runnable() { @Override public void run() { try { runTurn(session, input, null, turnId); } catch (Exception ex) { failure[0] = ex; } } }, "ai4j-main-buffer-turn"); registerMainBufferTurn(turnId, worker); JlineShellTerminalIO shellTerminal = terminal instanceof JlineShellTerminalIO ? (JlineShellTerminalIO) terminal : null; worker.start(); if (shellTerminal != null) { shellTerminal.beginTurnInterruptPolling(); } try { while (worker.isAlive()) { if (shellTerminal != null && shellTerminal.pollTurnInterrupt(100L)) { interruptActiveMainBufferTurn(turnId); } try { worker.join(25L); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); interruptActiveMainBufferTurn(turnId); break; } } try { worker.join(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); interruptActiveMainBufferTurn(turnId); throw ex; } } finally { if (shellTerminal != null) { shellTerminal.endTurnInterruptPolling(); } } boolean interrupted = isMainBufferTurnInterrupted(turnId); clearMainBufferTurnInterruptState(turnId); if (failure[0] != null && !interrupted) { throw failure[0]; } } private boolean isTurnInterrupted(String turnId, ActiveTuiTurn activeTurn) { return (activeTurn != null && activeTurn.isInterrupted()) || isMainBufferTurnInterrupted(turnId); } private void registerMainBufferTurn(String turnId, Thread worker) { synchronized (mainBufferTurnInterruptLock) { activeMainBufferTurnId = turnId; activeMainBufferTurnThread = worker; activeMainBufferTurnInterrupted = false; } } private void clearMainBufferTurnInterruptState(String turnId) { synchronized (mainBufferTurnInterruptLock) { if (!sameTurnId(activeMainBufferTurnId, turnId)) { return; } activeMainBufferTurnId = null; activeMainBufferTurnThread = null; activeMainBufferTurnInterrupted = false; } } private boolean interruptActiveMainBufferTurn(String turnId) { Thread thread; synchronized (mainBufferTurnInterruptLock) { if (!sameTurnId(activeMainBufferTurnId, turnId) || activeMainBufferTurnInterrupted || activeMainBufferTurnThread == null) { return false; } activeMainBufferTurnInterrupted = true; thread = activeMainBufferTurnThread; } thread.interrupt(); ChatModelClient.cancelActiveStream(thread); ResponsesModelClient.cancelActiveStream(thread); return true; } private boolean isMainBufferTurnInterrupted(String turnId) { synchronized (mainBufferTurnInterruptLock) { return activeMainBufferTurnInterrupted && sameTurnId(activeMainBufferTurnId, turnId); } } private String buildModelConnectionStatus(ManagedCodingSession session) { String provider = session == null ? null : session.getProvider(); String model = session == null ? options.getModel() : firstNonBlank(session.getModel(), options.getModel()); if (!isBlank(provider) && !isBlank(model)) { return "Connecting to " + clip(provider + "/" + model, 56); } if (!isBlank(model)) { return "Connecting to " + clip(model, 56); } if (!isBlank(provider)) { return "Connecting to " + clip(provider, 56); } return "Opening model stream"; } private void handleMainBufferTurnInterrupted(ManagedCodingSession session, String turnId) { mainBufferTurnPrinter.clearTransient(); tuiLiveTurnState.onError(null, TURN_INTERRUPTED_MESSAGE); appendEvent(session, SessionEventType.ERROR, turnId, null, TURN_INTERRUPTED_MESSAGE, payloadOf( "error", TURN_INTERRUPTED_MESSAGE )); renderTuiIfEnabled(session); emitMainBufferError(TURN_INTERRUPTED_MESSAGE); Thread.interrupted(); } private boolean hasActiveTuiTurn() { ActiveTuiTurn turn = activeTuiTurn; return turn != null && !turn.isDone(); } private void startAsyncTuiTurn(ManagedCodingSession session, String input) { if (session == null || isBlank(input) || hasActiveTuiTurn()) { return; } ActiveTuiTurn turn = new ActiveTuiTurn(session, input); activeTuiTurn = turn; turn.start(); } private void interruptActiveTuiTurn(ManagedCodingSession session) { ActiveTuiTurn turn = activeTuiTurn; if (turn == null || !turn.requestInterrupt()) { return; } tuiLiveTurnState.onError(null, TURN_INTERRUPTED_MESSAGE); appendEvent(session, SessionEventType.ERROR, turn.getTurnId(), null, TURN_INTERRUPTED_MESSAGE, payloadOf( "error", TURN_INTERRUPTED_MESSAGE )); renderTuiIfEnabled(session); } private ManagedCodingSession reapCompletedTuiTurn(ManagedCodingSession fallbackSession) throws Exception { ActiveTuiTurn turn = activeTuiTurn; if (turn == null || !turn.isDone()) { return fallbackSession; } activeTuiTurn = null; ManagedCodingSession session = turn.getSession() == null ? fallbackSession : turn.getSession(); persistSession(session, false); Exception failure = turn.getFailure(); if (failure != null && !turn.isInterrupted()) { throw failure; } return session; } private void startTuiTurnAnimation(final ManagedCodingSession session) { if (!shouldAnimateTuiTurn()) { return; } stopTuiTurnAnimation(); tuiTurnAnimationRunning = true; Thread thread = new Thread(new Runnable() { @Override public void run() { while (tuiTurnAnimationRunning) { try { renderTui(session); Thread.sleep(TUI_TURN_ANIMATION_POLL_MS); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); break; } catch (Exception ignored) { break; } } } }, "ai4j-tui-turn-animation"); thread.setDaemon(true); tuiTurnAnimationThread = thread; thread.start(); } private boolean shouldAnimateTuiTurn() { return isTuiMode() && tuiRuntime != null && !(tuiRuntime instanceof AppendOnlyTuiRuntime) && terminal != null && terminal.supportsAnsi(); } private void stopTuiTurnAnimation() { tuiTurnAnimationRunning = false; Thread thread = tuiTurnAnimationThread; tuiTurnAnimationThread = null; if (thread != null) { thread.interrupt(); try { thread.join(100L); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } } private boolean useAlternateScreenTui() { return tuiConfig != null && tuiConfig.isUseAlternateScreen(); } private boolean shouldRenderModelDelta(String delta) { if (useAlternateScreenTui()) { return true; } if (delta != null && (delta.indexOf('\n') >= 0 || delta.indexOf('\r') >= 0)) { lastNonAlternateScreenRenderAtMs = System.currentTimeMillis(); return true; } long now = System.currentTimeMillis(); if (now - lastNonAlternateScreenRenderAtMs >= NON_ALTERNATE_SCREEN_RENDER_THROTTLE_MS) { lastNonAlternateScreenRenderAtMs = now; return true; } return false; } private TuiRenderContext buildTuiRenderContext() { return TuiRenderContext.builder() .provider(options.getProvider() == null ? null : options.getProvider().getPlatform()) .protocol(protocol == null ? null : protocol.getValue()) .model(options.getModel()) .workspace(options.getWorkspace()) .sessionStore(String.valueOf(sessionManager.getDirectory())) .sessionMode(options.isNoSession() ? "memory-only" : "persistent") .approvalMode(options.getApprovalMode() == null ? null : options.getApprovalMode().getValue()) .terminalRows(terminal == null ? 0 : terminal.getTerminalRows()) .terminalColumns(terminal == null ? 0 : terminal.getTerminalColumns()) .build(); } private void closeQuietly(ManagedCodingSession session) { if (session == null) { return; } try { session.close(); } catch (Exception ex) { terminal.errorln("Failed to close session: " + ex.getMessage()); } } private void closeMcpRuntimeQuietly(CliMcpRuntimeManager runtimeManager) { if (runtimeManager == null) { return; } try { runtimeManager.close(); } catch (Exception ex) { terminal.errorln("Failed to close MCP runtime: " + ex.getMessage()); } } private void refreshSessionContext(ManagedCodingSession session) { if (!(terminal instanceof JlineShellTerminalIO)) { return; } ((JlineShellTerminalIO) terminal).updateSessionContext( session == null ? null : session.getSessionId(), session == null ? options.getModel() : session.getModel(), session == null ? options.getWorkspace() : session.getWorkspace() ); ((JlineShellTerminalIO) terminal).showIdle("Enter a prompt or /command"); } private void attachCodingTaskEventBridge() { CodingRuntime nextRuntime = agent == null ? null : agent.getRuntime(); if (bridgedRuntime == nextRuntime) { return; } detachCodingTaskEventBridge(); if (nextRuntime == null || sessionManager == null) { return; } codingTaskEventBridge = new CodingTaskSessionEventBridge(sessionManager, new CodingTaskSessionEventBridge.SessionEventConsumer() { @Override public void onEvent(SessionEvent event) { handleCodingTaskSessionEvent(event); } }); nextRuntime.addListener(codingTaskEventBridge); bridgedRuntime = nextRuntime; } private void detachCodingTaskEventBridge() { if (bridgedRuntime != null && codingTaskEventBridge != null) { bridgedRuntime.removeListener(codingTaskEventBridge); } bridgedRuntime = null; codingTaskEventBridge = null; } private void handleCodingTaskSessionEvent(SessionEvent event) { if (event == null || activeSession == null) { return; } if (!safeEquals(activeSession.getSessionId(), event.getSessionId())) { return; } refreshTuiEvents(activeSession); if (isTuiMode()) { renderTuiFromCache(activeSession); } } private String maskSecret(String value) { if (isBlank(value)) { return null; } String trimmed = value.trim(); if (trimmed.length() <= 8) { return "****"; } return trimmed.substring(0, 4) + "..." + trimmed.substring(trimmed.length() - 4); } private void appendCompactEvent(ManagedCodingSession session, CodingSessionCompactResult result, String turnId) { if (result == null) { return; } appendEvent(session, SessionEventType.COMPACT, turnId, null, (result.isAutomatic() ? "auto" : "manual") + " compact " + result.getEstimatedTokensBefore() + "->" + result.getEstimatedTokensAfter() + " tokens", payloadOf( "automatic", result.isAutomatic(), "strategy", result.getStrategy(), "beforeItemCount", result.getBeforeItemCount(), "afterItemCount", result.getAfterItemCount(), "estimatedTokensBefore", result.getEstimatedTokensBefore(), "estimatedTokensAfter", result.getEstimatedTokensAfter(), "compactedToolResultCount", result.getCompactedToolResultCount(), "deltaItemCount", result.getDeltaItemCount(), "checkpointReused", result.isCheckpointReused(), "fallbackSummary", result.isFallbackSummary(), "splitTurn", result.isSplitTurn(), "summary", clip(result.getSummary(), options.isVerbose() ? 4000 : 1200), "checkpointGoal", result.getCheckpoint() == null ? null : result.getCheckpoint().getGoal() )); } private void appendEvent(ManagedCodingSession session, SessionEventType type, String turnId, Integer step, String summary, Map payload) { if (session == null || type == null) { return; } try { sessionManager.appendEvent(session.getSessionId(), SessionEvent.builder() .sessionId(session.getSessionId()) .type(type) .turnId(turnId) .step(step) .summary(summary) .payload(payload) .build()); refreshTuiEvents(session); } catch (IOException ex) { if (options.isVerbose()) { terminal.errorln("Failed to append session event: " + ex.getMessage()); } } } private SessionEventType resolveProcessEventType(String action, String status) { if ("start".equals(action)) { return SessionEventType.PROCESS_STARTED; } if ("stop".equals(action)) { return SessionEventType.PROCESS_STOPPED; } if ("EXITED".equalsIgnoreCase(status) || "STOPPED".equalsIgnoreCase(status)) { return SessionEventType.PROCESS_STOPPED; } if ("status".equals(action) || "write".equals(action)) { return SessionEventType.PROCESS_UPDATED; } return null; } private void appendProcessEvent(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call, AgentToolResult result) { if (call == null || result == null || !"bash".equals(call.getName())) { return; } JSONObject arguments = parseObject(call.getArguments()); String action = arguments == null || isBlank(arguments.getString("action")) ? "exec" : arguments.getString("action"); if ("exec".equals(action) || "logs".equals(action) || "list".equals(action)) { return; } JSONObject process = extractProcessObject(action, result.getOutput()); String processId = process == null ? null : process.getString("processId"); String status = process == null ? null : process.getString("status"); SessionEventType eventType = resolveProcessEventType(action, status); if (eventType == null) { return; } appendEvent(session, eventType, turnId, step, buildProcessSummary(eventType, processId, status), payloadOf( "action", action, "processId", processId, "status", status, "command", process == null ? arguments.getString("command") : process.getString("command"), "workingDirectory", process == null ? arguments.getString("cwd") : process.getString("workingDirectory"), "output", clip(result.getOutput(), options.isVerbose() ? 4000 : 1200) )); } private JSONObject extractProcessObject(String action, String output) { if (isBlank(output)) { return null; } try { if ("write".equals(action)) { JSONObject root = JSON.parseObject(output); return root == null ? null : root.getJSONObject("process"); } if ("start".equals(action) || "status".equals(action) || "stop".equals(action)) { return JSON.parseObject(output); } if ("list".equals(action)) { JSONArray array = JSON.parseArray(output); return array == null || array.isEmpty() ? null : array.getJSONObject(0); } } catch (Exception ignored) { } return null; } private String buildProcessSummary(SessionEventType eventType, String processId, String status) { String normalizedProcessId = isBlank(processId) ? "unknown-process" : processId; switch (eventType) { case PROCESS_STARTED: return "process started: " + normalizedProcessId; case PROCESS_STOPPED: return "process stopped: " + normalizedProcessId; case PROCESS_UPDATED: return "process updated: " + normalizedProcessId + (isBlank(status) ? "" : " (" + status + ")"); default: return normalizedProcessId; } } private Integer parseLimit(String raw) { if (isBlank(raw)) { return DEFAULT_EVENT_LIMIT; } try { int value = Integer.parseInt(raw.trim()); return value <= 0 ? DEFAULT_EVENT_LIMIT : value; } catch (NumberFormatException ex) { emitError("Invalid event limit: " + raw + ", using " + DEFAULT_EVENT_LIMIT); return DEFAULT_EVENT_LIMIT; } } private String resolveCompactMode(SessionEvent event) { if (event == null) { return "unknown"; } Map payload = event.getPayload(); if (hasPayloadKey(payload, "automatic")) { return payloadBoolean(payload, "automatic") ? "auto" : "manual"; } String summary = event.getSummary(); if (!isBlank(summary) && summary.toLowerCase(Locale.ROOT).startsWith("auto")) { return "auto"; } return "manual"; } private String formatCompactDelta(Integer before, Integer after) { if (before == null || after == null) { return "n/a"; } return before + "->" + after; } private int defaultInt(Integer value) { return value == null ? 0 : value.intValue(); } private Integer payloadInt(Map payload, String key) { if (payload == null || isBlank(key)) { return null; } Object value = payload.get(key); if (value instanceof Number) { return Integer.valueOf(((Number) value).intValue()); } if (value instanceof String) { try { return Integer.valueOf(Integer.parseInt(((String) value).trim())); } catch (NumberFormatException ignored) { return null; } } return null; } private boolean payloadBoolean(Map payload, String key) { if (payload == null || isBlank(key)) { return false; } Object value = payload.get(key); if (value instanceof Boolean) { return ((Boolean) value).booleanValue(); } if (value instanceof String) { return Boolean.parseBoolean(((String) value).trim()); } return false; } private String payloadString(Map payload, String key) { if (payload == null || isBlank(key)) { return null; } Object value = payload.get(key); return value == null ? null : String.valueOf(value); } private List payloadLines(Map payload, String key) { if (payload == null || isBlank(key)) { return new ArrayList(); } Object value = payload.get(key); if (value == null) { return new ArrayList(); } if (value instanceof Iterable) { return toStringLines((Iterable) value); } if (value instanceof String) { String raw = (String) value; if (isBlank(raw)) { return new ArrayList(); } try { return toStringLines(JSON.parseArray(raw)); } catch (Exception ignored) { return new ArrayList(); } } return new ArrayList(); } private List toStringLines(Iterable source) { List lines = new ArrayList(); if (source == null) { return lines; } for (Object item : source) { if (item == null) { continue; } String line = String.valueOf(item); if (!isBlank(line)) { lines.add(line); } } return lines; } private List wrapPrefixedText(String firstPrefix, String continuationPrefix, String rawText, int maxWidth) { List lines = new ArrayList(); if (isBlank(rawText)) { return lines; } String first = firstPrefix == null ? "" : firstPrefix; String continuation = continuationPrefix == null ? "" : continuationPrefix; int firstWidth = Math.max(12, maxWidth - first.length()); int continuationWidth = Math.max(12, maxWidth - continuation.length()); boolean firstLine = true; String[] paragraphs = rawText.replace("\r", "").split("\n"); for (String paragraph : paragraphs) { String text = safeTrimToNull(paragraph); if (isBlank(text)) { continue; } while (!isBlank(text)) { int width = firstLine ? firstWidth : continuationWidth; int split = findWrapIndex(text, width); lines.add((firstLine ? first : continuation) + text.substring(0, split).trim()); text = text.substring(split).trim(); firstLine = false; } } return lines; } private String safeTrimToNull(String value) { return isBlank(value) ? null : value.trim(); } private String repeat(char ch, int count) { if (count <= 0) { return ""; } StringBuilder builder = new StringBuilder(count); for (int i = 0; i < count; i++) { builder.append(ch); } return builder.toString(); } private int findWrapIndex(String text, int width) { if (isBlank(text) || text.length() <= width) { return text == null ? 0 : text.length(); } int whitespace = -1; for (int i = Math.min(width, text.length() - 1); i >= 0; i--) { if (Character.isWhitespace(text.charAt(i))) { whitespace = i; break; } } return whitespace > 0 ? whitespace : width; } private List normalizeToolPreviewLines(String toolName, String status, List previewLines) { if (previewLines == null || previewLines.isEmpty()) { return new ArrayList(); } List normalized = new ArrayList(); for (String previewLine : previewLines) { String candidate = stripPreviewLabel(previewLine); if (isBlank(candidate)) { continue; } if ("bash".equals(toolName)) { if ("pending".equalsIgnoreCase(status)) { continue; } if ("(no command output)".equalsIgnoreCase(candidate)) { continue; } } if ("apply_patch".equals(toolName) && "(no changed files)".equalsIgnoreCase(candidate)) { continue; } normalized.add(candidate); } return normalized; } private String stripPreviewLabel(String previewLine) { if (isBlank(previewLine)) { return null; } String value = previewLine.trim(); int separator = value.indexOf("> "); if (separator > 0) { String prefix = value.substring(0, separator).trim().toLowerCase(Locale.ROOT); if ("stdout".equals(prefix) || "stderr".equals(prefix) || "log".equals(prefix) || "file".equals(prefix) || "path".equals(prefix) || "cwd".equals(prefix) || "timeout".equals(prefix) || "process".equals(prefix) || "status".equals(prefix) || "command".equals(prefix) || "stdin".equals(prefix) || "meta".equals(prefix) || "out".equals(prefix)) { return value.substring(separator + 2).trim(); } } return value; } private boolean hasPayloadKey(Map payload, String key) { return payload != null && !isBlank(key) && payload.containsKey(key); } private JSONObject parseObject(String value) { if (isBlank(value)) { return null; } try { return JSON.parseObject(value); } catch (Exception ignored) { return null; } } private String[] toPanelLines(String text, int maxChars, int maxLines) { if (isBlank(text)) { return new String[]{"(none)"}; } String[] rawLines = text.replace("\r", "").split("\n"); List lines = new ArrayList(); for (String rawLine : rawLines) { if (lines.size() >= maxLines) { lines.add("..."); break; } lines.add(clip(rawLine, maxChars)); } return lines.toArray(new String[0]); } private Map payloadOf(Object... pairs) { Map payload = new LinkedHashMap(); if (pairs == null) { return payload; } for (int i = 0; i + 1 < pairs.length; i += 2) { Object key = pairs[i]; if (key != null) { payload.put(String.valueOf(key), pairs[i + 1]); } } return payload; } private String newTurnId() { return "turn_" + UUID.randomUUID().toString().replace("-", ""); } private String formatTimestamp(long epochMs) { if (epochMs <= 0) { return "unknown"; } SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT); format.setTimeZone(TimeZone.getDefault()); return format.format(new Date(epochMs)); } private String extractCommandArgument(String command) { if (isBlank(command)) { return null; } int firstSpace = command.indexOf(' '); if (firstSpace < 0 || firstSpace + 1 >= command.length()) { return null; } return command.substring(firstSpace + 1).trim(); } private String renderStatusOutput(ManagedCodingSession session, CodingSessionSnapshot snapshot) { if (session == null) { return "status: (none)"; } CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties); StringBuilder builder = new StringBuilder(); builder.append("status:\n"); builder.append("- session=").append(session.getSessionId()).append('\n'); builder.append("- provider=").append(session.getProvider()) .append(", protocol=").append(session.getProtocol()) .append(", model=").append(session.getModel()).append('\n'); builder.append("- profile=").append(firstNonBlank(resolved.getActiveProfile(), resolved.getEffectiveProfile(), "(none)")) .append(", modelOverride=").append(firstNonBlank(resolved.getModelOverride(), "(none)")).append('\n'); builder.append("- workspace=").append(session.getWorkspace()).append('\n'); builder.append("- mode=").append(options.isNoSession() ? "memory-only" : "persistent") .append(", memory=").append(snapshot == null ? 0 : snapshot.getMemoryItemCount()) .append(", activeProcesses=").append(snapshot == null ? 0 : snapshot.getActiveProcessCount()) .append(", restoredProcesses=").append(snapshot == null ? 0 : snapshot.getRestoredProcessCount()) .append(", tokens=").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\n'); builder.append("- stream=").append(streamEnabled ? "on" : "off").append('\n'); String mcpSummary = renderMcpSummary(); if (!isBlank(mcpSummary)) { builder.append("- mcp=").append(mcpSummary).append('\n'); } builder.append("- checkpointGoal=").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 120)).append('\n'); builder.append("- compact=").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), "none")); return builder.toString().trim(); } private ManagedCodingSession handleStreamCommand(ManagedCodingSession session, String argument) throws Exception { String normalized = argument == null ? "" : argument.trim().toLowerCase(Locale.ROOT); if (isBlank(normalized)) { emitOutput(renderStreamOutput()); return session; } if ("on".equals(normalized)) { if (!streamEnabled) { CodeCommandOptions nextOptions = options.withStream(true); ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions); emitOutput(renderStreamOutput()); persistSession(rebound, false); return rebound; } emitOutput(renderStreamOutput()); return session; } if ("off".equals(normalized)) { if (streamEnabled) { CodeCommandOptions nextOptions = options.withStream(false); ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions); emitOutput(renderStreamOutput()); persistSession(rebound, false); return rebound; } emitOutput(renderStreamOutput()); return session; } emitError("Unknown /stream option: " + argument + ". Use /stream, /stream on, or /stream off."); return session; } private String renderStreamOutput() { StringBuilder builder = new StringBuilder(); builder.append("stream:\n"); builder.append("- status=").append(streamEnabled ? "on" : "off").append('\n'); builder.append("- scope=current CLI session\n"); builder.append("- request=").append(streamEnabled ? "stream=true" : "stream=false").append('\n'); builder.append("- behavior=").append(streamEnabled ? "provider responses stream incrementally and assistant text renders incrementally" : "provider responses return as completed payloads and assistant text renders as completed blocks"); return builder.toString().trim(); } private String renderSessionOutput(ManagedCodingSession session) { CodingSessionDescriptor descriptor = session == null ? null : session.toDescriptor(); if (descriptor == null) { return "session: (none)"; } CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties); CodingSessionSnapshot snapshot = session.getSession() == null ? null : session.getSession().snapshot(); StringBuilder builder = new StringBuilder(); builder.append("session:\n"); builder.append("- id=").append(descriptor.getSessionId()).append('\n'); builder.append("- root=").append(descriptor.getRootSessionId()) .append(", parent=").append(firstNonBlank(descriptor.getParentSessionId(), "(none)")).append('\n'); builder.append("- provider=").append(descriptor.getProvider()) .append(", protocol=").append(descriptor.getProtocol()) .append(", model=").append(descriptor.getModel()).append('\n'); builder.append("- profile=").append(firstNonBlank(resolved.getActiveProfile(), resolved.getEffectiveProfile(), "(none)")) .append(", modelOverride=").append(firstNonBlank(resolved.getModelOverride(), "(none)")).append('\n'); builder.append("- workspace=").append(descriptor.getWorkspace()) .append(", mode=").append(options.isNoSession() ? "memory-only" : "persistent").append('\n'); builder.append("- created=").append(formatTimestamp(descriptor.getCreatedAtEpochMs())) .append(", updated=").append(formatTimestamp(descriptor.getUpdatedAtEpochMs())).append('\n'); builder.append("- memory=").append(descriptor.getMemoryItemCount()) .append(", processes=").append(descriptor.getProcessCount()) .append(" (active=").append(descriptor.getActiveProcessCount()) .append(", restored=").append(descriptor.getRestoredProcessCount()).append(")").append('\n'); String mcpSummary = renderMcpSummary(); if (!isBlank(mcpSummary)) { builder.append("- mcp=").append(mcpSummary).append('\n'); } builder.append("- tokens=").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\n'); builder.append("- checkpoint=").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 160)).append('\n'); builder.append("- compact=").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), "none")).append('\n'); builder.append("- summary=").append(clip(descriptor.getSummary(), 220)); return builder.toString().trim(); } private String renderMcpSummary() { if (mcpRuntimeManager == null || !mcpRuntimeManager.hasStatuses()) { return null; } int connected = 0; int errors = 0; int paused = 0; int disabled = 0; int missing = 0; int tools = 0; for (CliMcpStatusSnapshot status : mcpRuntimeManager.getStatuses()) { if (status == null) { continue; } tools += Math.max(0, status.getToolCount()); if (CliMcpRuntimeManager.STATE_CONNECTED.equals(status.getState())) { connected++; } else if (CliMcpRuntimeManager.STATE_ERROR.equals(status.getState())) { errors++; } else if (CliMcpRuntimeManager.STATE_PAUSED.equals(status.getState())) { paused++; } else if (CliMcpRuntimeManager.STATE_DISABLED.equals(status.getState())) { disabled++; } else if (CliMcpRuntimeManager.STATE_MISSING.equals(status.getState())) { missing++; } } return "connected " + connected + ", errors " + errors + ", paused " + paused + ", disabled " + disabled + ", missing " + missing + ", tools " + tools; } private String renderProvidersOutput() { CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig(); CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); if (providersConfig.getProfiles().isEmpty()) { return "providers: (none)"; } StringBuilder builder = new StringBuilder(); builder.append("providers:\n"); List names = new ArrayList(providersConfig.getProfiles().keySet()); Collections.sort(names); for (String name : names) { CliProviderProfile profile = providersConfig.getProfiles().get(name); builder.append("- ").append(name); if (name.equals(workspaceConfig.getActiveProfile())) { builder.append(" [active]"); } if (name.equals(providersConfig.getDefaultProfile())) { builder.append(" [default]"); } builder.append(" | provider=").append(profile == null ? null : profile.getProvider()); builder.append(", protocol=").append(profile == null ? null : profile.getProtocol()); builder.append(", model=").append(profile == null ? null : profile.getModel()); if (!isBlank(profile == null ? null : profile.getBaseUrl())) { builder.append(", baseUrl=").append(profile.getBaseUrl()); } builder.append('\n'); } return builder.toString().trim(); } private String renderCurrentProviderOutput() { CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties); StringBuilder builder = new StringBuilder(); builder.append("provider:\n"); builder.append("- activeProfile=").append(firstNonBlank(resolved.getActiveProfile(), "(none)")).append('\n'); builder.append("- defaultProfile=").append(firstNonBlank(resolved.getDefaultProfile(), "(none)")).append('\n'); builder.append("- effectiveProfile=").append(firstNonBlank(resolved.getEffectiveProfile(), "(none)")).append('\n'); builder.append("- provider=").append(options.getProvider() == null ? null : options.getProvider().getPlatform()) .append(", protocol=").append(protocol == null ? null : protocol.getValue()) .append(", model=").append(options.getModel()).append('\n'); builder.append("- baseUrl=").append(firstNonBlank(options.getBaseUrl(), "(default)")).append('\n'); builder.append("- apiKey=").append(isBlank(options.getApiKey()) ? "(missing)" : maskSecret(options.getApiKey())).append('\n'); builder.append("- store=").append(providerConfigManager.globalProvidersPath()); return builder.toString().trim(); } private String renderModelOutput() { CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties); StringBuilder builder = new StringBuilder(); builder.append("model:\n"); builder.append("- current=").append(options.getModel()).append('\n'); builder.append("- override=").append(firstNonBlank(resolved.getModelOverride(), "(none)")).append('\n'); builder.append("- profile=").append(firstNonBlank(resolved.getEffectiveProfile(), "(none)")).append('\n'); builder.append("- workspaceConfig=").append(providerConfigManager.workspaceConfigPath()); return builder.toString().trim(); } private String renderExperimentalOutput() { CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); StringBuilder builder = new StringBuilder(); builder.append("experimental:\n"); builder.append("- subagent=").append(renderExperimentalState( workspaceConfig == null ? null : workspaceConfig.getExperimentalSubagentsEnabled(), DefaultCodingCliAgentFactory.isExperimentalSubagentsEnabled(workspaceConfig) )).append('\n'); builder.append("- agent-teams=").append(renderExperimentalState( workspaceConfig == null ? null : workspaceConfig.getExperimentalAgentTeamsEnabled(), DefaultCodingCliAgentFactory.isExperimentalAgentTeamsEnabled(workspaceConfig) )).append('\n'); builder.append("- workspaceConfig=").append(providerConfigManager.workspaceConfigPath()); return builder.toString().trim(); } private String renderExperimentalState(Boolean configuredValue, boolean effectiveValue) { String base = effectiveValue ? "on" : "off"; return configuredValue == null ? base + " (default)" : base; } private String normalizeExperimentalFeature(String raw) { if (isBlank(raw)) { return null; } String normalized = raw.trim().toLowerCase(Locale.ROOT); if ("subagent".equals(normalized) || "subagents".equals(normalized)) { return "subagent"; } if ("agent-teams".equals(normalized) || "agent-team".equals(normalized) || "agentteams".equals(normalized) || "team".equals(normalized) || "teams".equals(normalized)) { return "agent-teams"; } return null; } private Boolean parseExperimentalToggle(String raw) { if (isBlank(raw)) { return null; } String normalized = raw.trim().toLowerCase(Locale.ROOT); if ("on".equals(normalized) || "enable".equals(normalized) || "enabled".equals(normalized)) { return Boolean.TRUE; } if ("off".equals(normalized) || "disable".equals(normalized) || "disabled".equals(normalized)) { return Boolean.FALSE; } return null; } private String renderSkillsOutput(ManagedCodingSession session, String argument) { WorkspaceContext workspaceContext = session == null || session.getSession() == null ? null : session.getSession().getWorkspaceContext(); if (workspaceContext == null) { return "skills: (none)"; } List skills = workspaceContext.getAvailableSkills(); if (!isBlank(argument)) { CodingSkillDescriptor selected = findSkill(skills, argument); if (selected == null) { return "skills: unknown skill `" + argument.trim() + "`"; } return renderSkillDetailOutput(selected, workspaceContext); } List roots = resolveSkillRoots(workspaceContext); StringBuilder builder = new StringBuilder(); builder.append("skills:\n"); builder.append("- count=").append(skills == null ? 0 : skills.size()).append('\n'); builder.append("- workspaceConfig=").append(providerConfigManager.workspaceConfigPath()).append('\n'); builder.append("- roots=").append(joinWithComma(roots)).append('\n'); if (skills == null || skills.isEmpty()) { builder.append("- entries=(none)"); return builder.toString().trim(); } for (CodingSkillDescriptor skill : skills) { if (skill == null) { continue; } builder.append("- ").append(firstNonBlank(skill.getName(), "skill")); builder.append(" | source=").append(firstNonBlank(skill.getSource(), "unknown")); builder.append(" | path=").append(firstNonBlank(skill.getSkillFilePath(), "(missing)")); builder.append(" | description=").append(firstNonBlank(skill.getDescription(), "No description available.")); builder.append('\n'); } return builder.toString().trim(); } private CodingSkillDescriptor findSkill(List skills, String name) { if (skills == null || isBlank(name)) { return null; } String normalized = name.trim(); for (CodingSkillDescriptor skill : skills) { if (skill == null || isBlank(skill.getName())) { continue; } if (skill.getName().trim().equalsIgnoreCase(normalized)) { return skill; } } return null; } private String renderSkillDetailOutput(CodingSkillDescriptor skill, WorkspaceContext workspaceContext) { StringBuilder builder = new StringBuilder(); builder.append("skill:\n"); builder.append("- name=").append(firstNonBlank(skill == null ? null : skill.getName(), "skill")).append('\n'); builder.append("- source=").append(firstNonBlank(skill == null ? null : skill.getSource(), "unknown")).append('\n'); builder.append("- path=").append(firstNonBlank(skill == null ? null : skill.getSkillFilePath(), "(missing)")).append('\n'); builder.append("- description=").append(firstNonBlank(skill == null ? null : skill.getDescription(), "No description available.")).append('\n'); builder.append("- roots=").append(joinWithComma(resolveSkillRoots(workspaceContext))); return builder.toString().trim(); } private String renderAgentsOutput(ManagedCodingSession session, String argument) { CodingAgentDefinitionRegistry registry = agent == null ? null : agent.getDefinitionRegistry(); List definitions = registry == null ? null : registry.listDefinitions(); if (!isBlank(argument)) { CodingAgentDefinition selected = findAgent(definitions, argument); if (selected == null) { return "agents: unknown agent `" + argument.trim() + "`"; } return renderAgentDetailOutput(selected); } List roots = resolveAgentRoots(); StringBuilder builder = new StringBuilder(); builder.append("agents:\n"); builder.append("- count=").append(definitions == null ? 0 : definitions.size()).append('\n'); builder.append("- workspaceConfig=").append(providerConfigManager.workspaceConfigPath()).append('\n'); builder.append("- roots=").append(joinWithComma(roots)).append('\n'); if (definitions == null || definitions.isEmpty()) { builder.append("- entries=(none)"); return builder.toString().trim(); } for (CodingAgentDefinition definition : definitions) { if (definition == null) { continue; } builder.append("- ").append(firstNonBlank(definition.getName(), "agent")); builder.append(" | tool=").append(firstNonBlank(definition.getToolName(), "(none)")); builder.append(" | model=").append(firstNonBlank(definition.getModel(), "(inherit)")); builder.append(" | background=").append(definition.isBackground()); builder.append(" | tools=").append(renderAllowedTools(definition)); builder.append(" | description=").append(firstNonBlank(definition.getDescription(), "No description available.")); builder.append('\n'); } return builder.toString().trim(); } private List resolveSkillRoots(WorkspaceContext workspaceContext) { if (workspaceContext == null) { return Collections.emptyList(); } LinkedHashSet roots = new LinkedHashSet(); roots.add(workspaceContext.getRoot().resolve(".ai4j").resolve("skills").toAbsolutePath().normalize().toString()); String userHome = System.getProperty("user.home"); if (!isBlank(userHome)) { roots.add(Paths.get(userHome).resolve(".ai4j").resolve("skills").toAbsolutePath().normalize().toString()); } if (workspaceContext.getSkillDirectories() != null) { for (String configuredRoot : workspaceContext.getSkillDirectories()) { if (isBlank(configuredRoot)) { continue; } Path root = Paths.get(configuredRoot); if (!root.isAbsolute()) { root = workspaceContext.getRoot().resolve(configuredRoot); } roots.add(root.toAbsolutePath().normalize().toString()); } } return new ArrayList(roots); } private CodingAgentDefinition findAgent(List definitions, String nameOrToolName) { if (definitions == null || isBlank(nameOrToolName)) { return null; } String normalized = nameOrToolName.trim(); for (CodingAgentDefinition definition : definitions) { if (definition == null) { continue; } if ((!isBlank(definition.getName()) && definition.getName().trim().equalsIgnoreCase(normalized)) || (!isBlank(definition.getToolName()) && definition.getToolName().trim().equalsIgnoreCase(normalized))) { return definition; } } return null; } private String renderAgentDetailOutput(CodingAgentDefinition definition) { StringBuilder builder = new StringBuilder(); builder.append("agent:\n"); builder.append("- name=").append(firstNonBlank(definition == null ? null : definition.getName(), "agent")).append('\n'); builder.append("- tool=").append(firstNonBlank(definition == null ? null : definition.getToolName(), "(none)")).append('\n'); builder.append("- model=").append(firstNonBlank(definition == null ? null : definition.getModel(), "(inherit)")).append('\n'); builder.append("- sessionMode=").append(definition == null ? null : definition.getSessionMode()).append('\n'); builder.append("- isolationMode=").append(definition == null ? null : definition.getIsolationMode()).append('\n'); builder.append("- memoryScope=").append(definition == null ? null : definition.getMemoryScope()).append('\n'); builder.append("- approvalMode=").append(definition == null ? null : definition.getApprovalMode()).append('\n'); builder.append("- background=").append(definition != null && definition.isBackground()).append('\n'); builder.append("- tools=").append(renderAllowedTools(definition)).append('\n'); builder.append("- description=").append(firstNonBlank(definition == null ? null : definition.getDescription(), "No description available.")).append('\n'); builder.append("- roots=").append(joinWithComma(resolveAgentRoots())).append('\n'); builder.append("- instructions=").append(firstNonBlank(definition == null ? null : definition.getInstructions(), "(none)")); return builder.toString().trim(); } private List resolveAgentRoots() { CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig(); CliCodingAgentRegistry registry = new CliCodingAgentRegistry( Paths.get(firstNonBlank(options == null ? null : options.getWorkspace(), ".")), workspaceConfig == null ? null : workspaceConfig.getAgentDirectories() ); List roots = registry.listRoots(); if (roots == null || roots.isEmpty()) { return Collections.emptyList(); } List values = new ArrayList(roots.size()); for (Path root : roots) { if (root != null) { values.add(root.toAbsolutePath().normalize().toString()); } } return values; } private String renderAllowedTools(CodingAgentDefinition definition) { if (definition == null || definition.getAllowedToolNames() == null || definition.getAllowedToolNames().isEmpty()) { return "(inherit/all available)"; } StringBuilder builder = new StringBuilder(); for (String toolName : definition.getAllowedToolNames()) { if (isBlank(toolName)) { continue; } if (builder.length() > 0) { builder.append(", "); } builder.append(toolName.trim()); } return builder.length() == 0 ? "(inherit/all available)" : builder.toString(); } private String joinWithComma(List values) { if (values == null || values.isEmpty()) { return "(none)"; } StringBuilder builder = new StringBuilder(); for (String value : values) { if (isBlank(value)) { continue; } if (builder.length() > 0) { builder.append(", "); } builder.append(value); } return builder.length() == 0 ? "(none)" : builder.toString(); } private String renderCheckpointOutput(CodingSessionCheckpoint checkpoint) { if (checkpoint == null) { return "checkpoint: (none)"; } return "checkpoint:\n" + firstNonBlank(CodingSessionCheckpointFormatter.render(checkpoint), "(none)"); } private String renderSessionsOutput(List sessions) { if (sessions == null || sessions.isEmpty()) { return "sessions: (none)"; } StringBuilder builder = new StringBuilder("sessions:\n"); for (CodingSessionDescriptor session : sessions) { builder.append("- ").append(session.getSessionId()) .append(" | root=").append(clip(session.getRootSessionId(), 24)) .append(" | parent=").append(clip(firstNonBlank(session.getParentSessionId(), "-"), 24)) .append(" | updated=").append(formatTimestamp(session.getUpdatedAtEpochMs())) .append(" | memory=").append(session.getMemoryItemCount()) .append(" | processes=").append(session.getProcessCount()) .append(" | ").append(clip(session.getSummary(), 120)) .append('\n'); } return builder.toString().trim(); } private String renderEventsOutput(List events) { if (events == null || events.isEmpty()) { return "events: (none)"; } StringBuilder builder = new StringBuilder("events:\n"); for (SessionEvent event : events) { builder.append("- ").append(formatTimestamp(event.getTimestamp())) .append(" | ").append(event.getType()) .append(event.getStep() == null ? "" : " | step=" + event.getStep()) .append(" | ").append(clip(event.getSummary(), 160)) .append('\n'); } return builder.toString().trim(); } private String renderReplayOutput(List replayLines) { if (replayLines == null || replayLines.isEmpty()) { return "replay: (none)"; } StringBuilder builder = new StringBuilder("replay:\n"); for (String replayLine : replayLines) { builder.append(replayLine).append('\n'); } return builder.toString().trim(); } private String renderProcessesOutput(List processes) { if (processes == null || processes.isEmpty()) { return "processes: (none)"; } StringBuilder builder = new StringBuilder("processes:\n"); for (BashProcessInfo process : processes) { builder.append("- ").append(process.getProcessId()) .append(" | status=").append(process.getStatus()) .append(" | mode=").append(process.isControlAvailable() ? "live" : "metadata-only") .append(" | restored=").append(process.isRestored()) .append(" | cwd=").append(clip(process.getWorkingDirectory(), 48)) .append(" | cmd=").append(clip(process.getCommand(), 72)) .append('\n'); } return builder.toString().trim(); } private String renderProcessStatusOutput(BashProcessInfo processInfo) { if (processInfo == null) { return "process status: (none)"; } StringBuilder builder = new StringBuilder("process status:\n"); appendProcessSummary(builder, processInfo); return builder.toString().trim(); } private String renderProcessDetailsOutput(BashProcessInfo processInfo, BashProcessLogChunk logs) { StringBuilder builder = new StringBuilder(renderProcessStatusOutput(processInfo)); String content = logs == null ? null : logs.getContent(); if (!isBlank(content)) { builder.append('\n').append('\n').append("process logs:\n").append(content.trim()); } return builder.toString().trim(); } private String renderPanel(String title, String... lines) { StringBuilder builder = new StringBuilder(); builder.append("----------------------------------------------------------------").append('\n'); builder.append(title).append('\n'); if (lines != null) { for (String line : lines) { if (!isBlank(line)) { builder.append(line).append('\n'); } } } builder.append("----------------------------------------------------------------"); return builder.toString(); } private String resolveToolCallKey(AgentToolCall call) { if (call == null) { return UUID.randomUUID().toString(); } if (!isBlank(call.getCallId())) { return call.getCallId(); } return firstNonBlank(call.getName(), "tool") + "::" + firstNonBlank(call.getArguments(), ""); } private String resolveToolResultKey(AgentToolResult result) { if (result == null) { return UUID.randomUUID().toString(); } if (!isBlank(result.getCallId())) { return result.getCallId(); } return firstNonBlank(result.getName(), "tool"); } private TuiAssistantToolView buildPendingToolView(AgentToolCall call) { JSONObject arguments = parseObject(call == null ? null : call.getArguments()); String toolName = call == null ? null : call.getName(); String title = buildToolTitle(toolName, arguments); String detail = buildPendingToolDetail(toolName, arguments); return TuiAssistantToolView.builder() .callId(call == null ? null : call.getCallId()) .toolName(toolName) .status("pending") .title(title) .detail(detail) .previewLines(buildPendingToolPreviewLines(toolName, arguments)) .build(); } private TuiAssistantToolView buildCompletedToolView(AgentToolCall call, AgentToolResult result) { String toolName = call != null && !isBlank(call.getName()) ? call.getName() : (result == null ? null : result.getName()); JSONObject arguments = parseObject(call == null ? null : call.getArguments()); JSONObject output = parseObject(result == null ? null : result.getOutput()); return TuiAssistantToolView.builder() .callId(call == null ? null : call.getCallId()) .toolName(toolName) .status(isToolError(result, output) ? "error" : "done") .title(buildToolTitle(toolName, arguments)) .detail(buildCompletedToolDetail(toolName, arguments, output, result == null ? null : result.getOutput())) .previewLines(buildToolPreviewLines(toolName, arguments, output, result == null ? null : result.getOutput())) .build(); } private boolean isToolError(AgentToolResult result, JSONObject output) { return !isBlank(extractToolError(result == null ? null : result.getOutput(), output)); } private boolean isApprovalRejectedToolResult(AgentToolResult result) { return isApprovalRejectedToolError(result == null ? null : result.getOutput(), null); } private boolean isApprovalRejectedToolError(String rawOutput, JSONObject output) { String error = extractRawToolError(rawOutput, output); return !isBlank(error) && error.startsWith(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX); } private String extractToolError(String rawOutput, JSONObject output) { String rawError = extractRawToolError(rawOutput, output); if (isBlank(rawError)) { return null; } return stripApprovalRejectedPrefix(rawError); } private String extractRawToolError(String rawOutput, JSONObject output) { if (!isBlank(rawOutput) && rawOutput.startsWith("TOOL_ERROR:")) { JSONObject errorPayload = parseObject(rawOutput.substring("TOOL_ERROR:".length()).trim()); if (errorPayload != null && !isBlank(errorPayload.getString("error"))) { return errorPayload.getString("error"); } return rawOutput.substring("TOOL_ERROR:".length()).trim(); } if (!isBlank(rawOutput) && rawOutput.startsWith("CODE_ERROR:")) { return rawOutput.substring("CODE_ERROR:".length()).trim(); } if (output == null) { return null; } String error = output.getString("error"); return isBlank(error) ? null : error.trim(); } private String stripApprovalRejectedPrefix(String error) { if (isBlank(error)) { return null; } if (!error.startsWith(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX)) { return error; } String stripped = error.substring(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX.length()).trim(); return isBlank(stripped) ? "Tool call rejected by user" : stripped; } private String buildToolTitle(String toolName, JSONObject arguments) { if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) || "start".equals(action)) { String command = firstNonBlank(arguments == null ? null : arguments.getString("command"), null); return isBlank(command) ? "bash " + action : "$ " + command; } if ("write".equals(action)) { return "bash write " + firstNonBlank(arguments == null ? null : arguments.getString("processId"), "(process)"); } if ("logs".equals(action) || "status".equals(action) || "stop".equals(action)) { return "bash " + action + " " + firstNonBlank(arguments == null ? null : arguments.getString("processId"), "(process)"); } return "bash " + action; } if ("read_file".equals(toolName)) { String path = arguments == null ? null : arguments.getString("path"); Integer startLine = arguments == null ? null : arguments.getInteger("startLine"); Integer endLine = arguments == null ? null : arguments.getInteger("endLine"); String range = startLine == null ? "" : ":" + startLine + (endLine == null ? "" : "-" + endLine); return "read " + firstNonBlank(path, "(path)") + range; } if ("write_file".equals(toolName)) { return "write " + firstNonBlank(arguments == null ? null : arguments.getString("path"), "(path)"); } if ("apply_patch".equals(toolName)) { return "apply_patch"; } String qualifiedToolName = qualifyMcpToolName(toolName); String argumentSummary = formatToolArgumentSummary(arguments); if (isBlank(argumentSummary)) { return qualifiedToolName; } return qualifiedToolName + "(" + argumentSummary + ")"; } private String buildPendingToolDetail(String toolName, JSONObject arguments) { if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) || "start".equals(action)) { return "Running command..."; } if ("logs".equals(action)) { return "Reading process logs..."; } if ("status".equals(action)) { return "Checking process status..."; } if ("write".equals(action)) { return "Writing to process..."; } if ("stop".equals(action)) { return "Stopping process..."; } return "Running tool..."; } if ("read_file".equals(toolName)) { return "Reading file content..."; } if ("write_file".equals(toolName)) { return "Writing file content..."; } if ("apply_patch".equals(toolName)) { return "Applying workspace patch..."; } if (isMcpToolName(toolName)) { return "Calling MCP tool..."; } return "Running tool..."; } private List buildPendingToolPreviewLines(String toolName, JSONObject arguments) { List previewLines = new ArrayList(); if ("bash".equals(toolName)) { return previewLines; } if ("apply_patch".equals(toolName)) { previewLines.addAll(PatchSummaryFormatter.summarizePatchRequest( arguments == null ? null : arguments.getString("patch"), 6 )); return previewLines; } return previewLines; } private String buildCompletedToolDetail(String toolName, JSONObject arguments, JSONObject output, String rawOutput) { String toolError = extractToolError(rawOutput, output); if (!isBlank(toolError)) { return clip(toolError, 96); } if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) && output != null) { if (output.getBooleanValue("timedOut")) { return "timed out"; } return null; } if (("start".equals(action) || "status".equals(action) || "stop".equals(action)) && output != null) { String processId = firstNonBlank(output.getString("processId"), "process"); String status = safeTrimToNull(output.getString("status")); return isBlank(status) ? processId : processId + " | " + status.toLowerCase(Locale.ROOT); } if ("write".equals(action) && output != null) { return output.getIntValue("bytesWritten") + " bytes written"; } if ("logs".equals(action) && output != null) { return null; } return clip(rawOutput, 96); } if ("read_file".equals(toolName) && output != null) { return firstNonBlank(output.getString("path"), firstNonBlank(arguments == null ? null : arguments.getString("path"), "(path)")) + ":" + output.getIntValue("startLine") + "-" + output.getIntValue("endLine") + (output.getBooleanValue("truncated") ? " | truncated" : ""); } if ("write_file".equals(toolName) && output != null) { String status = output.getBooleanValue("appended") ? "appended" : (output.getBooleanValue("created") ? "created" : "overwritten"); return status + " | " + output.getIntValue("bytesWritten") + " bytes"; } if ("apply_patch".equals(toolName) && output != null) { int filesChanged = output.getIntValue("filesChanged"); int operationsApplied = output.getIntValue("operationsApplied"); if (filesChanged <= 0 && operationsApplied <= 0) { return null; } if (operationsApplied > 0 && operationsApplied != filesChanged) { return filesChanged + " files changed, " + operationsApplied + " operations"; } return filesChanged == 1 ? "1 file changed" : filesChanged + " files changed"; } if (isMcpToolName(toolName)) { return null; } return clip(rawOutput, 96); } private boolean isMcpToolName(String toolName) { return mcpRuntimeManager != null && !isBlank(mcpRuntimeManager.findServerNameByToolName(toolName)); } private String qualifyMcpToolName(String toolName) { String normalizedToolName = firstNonBlank(toolName, "tool"); if (mcpRuntimeManager == null || isBlank(toolName)) { return normalizedToolName; } String serverName = mcpRuntimeManager.findServerNameByToolName(toolName); if (isBlank(serverName)) { return normalizedToolName; } return serverName + "." + normalizedToolName; } private String formatToolArgumentSummary(JSONObject arguments) { if (arguments == null || arguments.isEmpty()) { return null; } List parts = new ArrayList(); int count = 0; for (Map.Entry entry : arguments.entrySet()) { if (entry == null || isBlank(entry.getKey())) { continue; } parts.add(entry.getKey() + "=" + formatInlineToolArgumentValue(entry.getValue())); count++; if (count >= 2) { break; } } if (parts.isEmpty()) { return null; } if (arguments.size() > count) { parts.add("..."); } StringBuilder builder = new StringBuilder(); for (int i = 0; i < parts.size(); i++) { if (i > 0) { builder.append(", "); } builder.append(parts.get(i)); } return clip(builder.toString(), 88); } private String formatInlineToolArgumentValue(Object value) { if (value == null) { return "null"; } if (value instanceof String) { return "\"" + clip((String) value, 48) + "\""; } if (value instanceof Number || value instanceof Boolean) { return String.valueOf(value); } return clip(JSON.toJSONString(value), 48); } private List buildToolPreviewLines(String toolName, JSONObject arguments, JSONObject output, String rawOutput) { List previewLines = new ArrayList(); if (!isBlank(extractToolError(rawOutput, output))) { return previewLines; } if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) && output != null) { addCommandPreviewLines(previewLines, output.getString("stdout"), output.getString("stderr")); return previewLines; } if ("logs".equals(action) && output != null) { addPlainPreviewLines(previewLines, output.getString("content")); return previewLines; } if (("start".equals(action) || "status".equals(action) || "stop".equals(action)) && output != null) { addPreviewLine(previewLines, "command", output.getString("command")); return previewLines; } if ("write".equals(action) && output != null) { return previewLines; } return previewLines; } if ("read_file".equals(toolName) && output != null) { addPreviewLines(previewLines, "file", output.getString("content"), 4); if (previewLines.isEmpty()) { previewLines.add("file> (empty file)"); } return previewLines; } if ("write_file".equals(toolName) && output != null) { addPreviewLine(previewLines, "path", output.getString("resolvedPath")); return previewLines; } if ("apply_patch".equals(toolName) && output != null) { previewLines.addAll(PatchSummaryFormatter.summarizePatchResult(output, 8)); if (!previewLines.isEmpty()) { return previewLines; } return previewLines; } addPreviewLines(previewLines, "out", rawOutput, 4); return previewLines; } private void addPreviewLines(List target, String label, String raw, int maxLines) { if (target == null || isBlank(raw) || maxLines <= 0) { return; } String[] lines = raw.replace("\r", "").split("\n"); int count = 0; for (String line : lines) { if (isBlank(line)) { continue; } target.add(firstNonBlank(label, "out") + "> " + clip(line, 92)); count++; if (count >= maxLines) { break; } } } private void addCommandPreviewLines(List target, String stdout, String stderr) { if (target == null) { return; } List lines = collectNonBlankLines(stdout); if (lines.isEmpty()) { lines = collectNonBlankLines(stderr); } else { List stderrLines = collectNonBlankLines(stderr); for (String stderrLine : stderrLines) { lines.add("stderr: " + stderrLine); } } addSummarizedPreview(target, lines); } private void addPlainPreviewLines(List target, String raw) { if (target == null) { return; } addSummarizedPreview(target, collectNonBlankLines(raw)); } private void addSummarizedPreview(List target, List lines) { if (target == null || lines == null || lines.isEmpty()) { return; } if (lines.size() <= 3) { for (String line : lines) { target.add(clip(line, 92)); } return; } target.add(clip(lines.get(0), 92)); target.add("\u2026 +" + (lines.size() - 2) + " lines"); target.add(clip(lines.get(lines.size() - 1), 92)); } private List collectNonBlankLines(String raw) { if (isBlank(raw)) { return new ArrayList(); } String[] rawLines = raw.replace("\r", "").split("\n"); List lines = new ArrayList(); for (String rawLine : rawLines) { if (!isBlank(rawLine)) { lines.add(rawLine.trim()); } } return lines; } private void addPreviewLine(List target, String label, String value) { if (target == null || isBlank(value)) { return; } target.add(firstNonBlank(label, "meta") + "> " + clip(value, 92)); } private final class TuiLiveTurnState { private TuiAssistantPhase phase = TuiAssistantPhase.IDLE; private Integer step; private String phaseDetail; private final StringBuilder reasoningBuffer = new StringBuilder(); private final StringBuilder textBuffer = new StringBuilder(); private final Map toolViews = new LinkedHashMap(); private String textBeforeFinalOutput; private int persistedReasoningLength; private int persistedTextLength; private int liveReasoningLength; private int liveTextLength; private long updatedAtEpochMs; private int animationTick; private synchronized void beginTurn(String input) { phase = TuiAssistantPhase.THINKING; step = null; phaseDetail = isBlank(input) ? "Waiting for model output..." : "Thinking about: " + clip(input, 72); reasoningBuffer.setLength(0); textBuffer.setLength(0); toolViews.clear(); textBeforeFinalOutput = null; persistedReasoningLength = 0; persistedTextLength = 0; liveReasoningLength = 0; liveTextLength = 0; touch(); } private synchronized void onStepStart(Integer step) { this.step = step; if (phase != TuiAssistantPhase.COMPLETE && phase != TuiAssistantPhase.ERROR) { phase = TuiAssistantPhase.THINKING; phaseDetail = "Waiting for model output..."; touch(); } } private synchronized void onModelDelta(Integer step, String delta) { this.step = step; if (!isBlank(delta)) { textBuffer.append(delta); } phase = TuiAssistantPhase.GENERATING; phaseDetail = "Streaming model output..."; touch(); } private synchronized void onReasoningDelta(Integer step, String delta) { this.step = step; if (!isBlank(delta)) { reasoningBuffer.append(delta); } if (phase != TuiAssistantPhase.GENERATING) { phase = TuiAssistantPhase.THINKING; } phaseDetail = "Streaming reasoning..."; touch(); } private synchronized void onRetry(Integer step, String detail) { this.step = step; phase = TuiAssistantPhase.THINKING; phaseDetail = firstNonBlank(detail, "Retrying model request..."); touch(); } private synchronized void onToolCall(Integer step, TuiAssistantToolView toolView) { this.step = step; if (toolView != null) { toolViews.put(firstNonBlank(toolView.getCallId(), toolView.getToolName(), UUID.randomUUID().toString()), toolView); phaseDetail = firstNonBlank(toolView.getDetail(), "Waiting for tool result..."); } phase = TuiAssistantPhase.WAITING_TOOL_RESULT; touch(); } private synchronized void onToolResult(Integer step, TuiAssistantToolView toolView) { this.step = step; if (toolView != null) { toolViews.put(firstNonBlank(toolView.getCallId(), toolView.getToolName(), UUID.randomUUID().toString()), toolView); } phase = toolView != null && "error".equalsIgnoreCase(toolView.getStatus()) ? TuiAssistantPhase.ERROR : TuiAssistantPhase.THINKING; phaseDetail = phase == TuiAssistantPhase.ERROR ? firstNonBlank(toolView == null ? null : toolView.getDetail(), "Tool execution failed.") : "Tool finished, continuing..."; touch(); } private synchronized void onStepEnd(Integer step) { this.step = step; if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT) { phase = TuiAssistantPhase.THINKING; phaseDetail = "Preparing next step..."; touch(); } } private synchronized void onFinalOutput(Integer step, String output) { this.step = step; if (!isBlank(output)) { String current = textBuffer.toString(); textBeforeFinalOutput = current; if (isBlank(current)) { textBuffer.setLength(0); textBuffer.append(output); } else if (!output.equals(current)) { if (output.startsWith(current)) { textBuffer.setLength(0); textBuffer.append(output); } else { textBuffer.setLength(0); textBuffer.append(output); persistedTextLength = 0; if (liveTextLength > 0) { liveTextLength = textBuffer.length(); } else { liveTextLength = 0; } } } } phase = TuiAssistantPhase.COMPLETE; phaseDetail = "Turn complete."; touch(); } private synchronized void onError(Integer step, String errorMessage) { this.step = step; phase = TuiAssistantPhase.ERROR; phaseDetail = firstNonBlank(errorMessage, "Agent run failed."); touch(); } private synchronized void finishTurn(Integer step, String output) { this.step = step == null ? this.step : step; if (phase == TuiAssistantPhase.ERROR || phase == TuiAssistantPhase.COMPLETE) { return; } if (!isBlank(output)) { onFinalOutput(this.step, output); return; } phase = TuiAssistantPhase.COMPLETE; phaseDetail = "Turn complete."; touch(); } private synchronized String flushPendingText() { String pending = pendingText(); persistedTextLength = textBuffer.length(); return pending; } private synchronized String flushPendingReasoning() { String pending = pendingReasoning(); persistedReasoningLength = reasoningBuffer.length(); return pending; } private synchronized String flushLiveReasoning() { String pending = pendingLiveReasoning(); liveReasoningLength = reasoningBuffer.length(); return pending; } private synchronized String flushLiveText() { String pending = pendingLiveText(); liveTextLength = textBuffer.length(); return pending; } private synchronized String pendingReasoning() { if (persistedReasoningLength >= reasoningBuffer.length()) { return ""; } return reasoningBuffer.substring(Math.max(0, persistedReasoningLength)); } private synchronized String pendingText() { if (persistedTextLength >= textBuffer.length()) { return ""; } return textBuffer.substring(Math.max(0, persistedTextLength)); } private synchronized String pendingLiveReasoning() { if (liveReasoningLength >= reasoningBuffer.length()) { return ""; } return reasoningBuffer.substring(Math.max(0, liveReasoningLength)); } private synchronized String pendingLiveText() { if (liveTextLength >= textBuffer.length()) { return ""; } return textBuffer.substring(Math.max(0, liveTextLength)); } private synchronized boolean hasPendingReasoning() { return !isBlank(pendingReasoning()); } private synchronized boolean hasPendingText() { return !isBlank(pendingText()); } private synchronized String currentText() { return textBuffer.toString(); } private synchronized String textBeforeFinalOutput() { return textBeforeFinalOutput == null ? textBuffer.toString() : textBeforeFinalOutput; } private synchronized TuiAssistantViewModel toViewModel() { return TuiAssistantViewModel.builder() .phase(phase) .step(step) .phaseDetail(phaseDetail) .reasoningText(pendingReasoning()) .text(pendingText()) .updatedAtEpochMs(updatedAtEpochMs) .animationTick(animationTick) .tools(new ArrayList(toolViews.values())) .build(); } private synchronized boolean advanceAnimationTick() { if (!isSpinnerActive()) { return false; } if (animationTick == Integer.MAX_VALUE) { animationTick = 0; } else { animationTick++; } return true; } private synchronized boolean isSpinnerActive() { return phase == TuiAssistantPhase.THINKING || phase == TuiAssistantPhase.GENERATING || phase == TuiAssistantPhase.WAITING_TOOL_RESULT; } private void touch() { updatedAtEpochMs = System.currentTimeMillis(); animationTick = 0; } } private final class MainBufferTurnPrinter { private TranscriptPrinter transcriptPrinter; private LiveTranscriptKind liveTranscriptKind; private boolean liveTranscriptAtLineStart = true; private final StringBuilder assistantLineBuffer = new StringBuilder(); private boolean assistantInsideCodeBlock; private String assistantCodeBlockLanguage; private synchronized void beginTurn(String input) { finishLiveTranscript(); JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.beginTurn(input); } } private synchronized void finishTurn() { finishLiveTranscript(); JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.finishTurn(); } } private synchronized void showThinking() { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.showThinking(); } } private synchronized void showConnecting(String text) { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.showConnecting(text); } } private synchronized void showRetrying(String text, int attempt, int maxAttempts) { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.showRetrying(text, attempt, maxAttempts); } } private synchronized void showResponding() { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.showResponding(); } } private synchronized void showStatus(String text) { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.showWorking(text); } } private synchronized void clearTransient() { finishLiveTranscript(); JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.clearTransient(); } } private synchronized void printSectionBreak() { finishLiveTranscript(); transcriptPrinter().printSectionBreak(); } private synchronized void printBlock(List lines) { finishLiveTranscript(); transcriptPrinter().printBlock(lines); } private synchronized void printAssistantBlock(String text) { finishLiveTranscript(); JlineShellTerminalIO shellTerminal = shellTerminal(); transcriptPrinter().beginStreamingBlock(); if (shellTerminal != null) { beginAssistantBlockTracking(); shellTerminal.printAssistantMarkdownBlock(text); return; } List lines = assistantTranscriptRenderer.render(text); if (lines.isEmpty()) { return; } CliThemeStyler fallbackStyler = new CliThemeStyler(tuiTheme, terminal.supportsAnsi()); for (AssistantTranscriptRenderer.Line line : lines) { String renderedLine = line == null ? "" : (line.code() ? fallbackStyler.styleTranscriptCodeLine(line.text(), line.language()) : fallbackStyler.styleTranscriptLine(line.text())); terminal.println(renderedLine); } } private synchronized boolean replaceAssistantBlock(String previousText, String replacementText) { finishLiveTranscript(); JlineShellTerminalIO shellTerminal = shellTerminal(); return shellTerminal != null && shellTerminal.rewriteAssistantBlock(shellTerminal.assistantBlockRows(), replacementText); } private synchronized void discardAssistantBlock() { if (liveTranscriptKind == LiveTranscriptKind.ASSISTANT && assistantInsideCodeBlock) { exitTranscriptCodeBlock(); } liveTranscriptKind = null; liveTranscriptAtLineStart = true; assistantLineBuffer.setLength(0); assistantInsideCodeBlock = false; assistantCodeBlockLanguage = null; JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { // Do not rewrite already-printed transcript lines in the main // buffer. Once the terminal has scrolled, cursor-up clears can // erase visible history and make the viewport look like it // "refreshed". Keep the pre-tool assistant text and only // forget the tracked block so later output appends normally. shellTerminal.forgetAssistantBlock(); } } private synchronized void streamAssistant(String delta) { streamLiveTranscript(LiveTranscriptKind.ASSISTANT, delta); } private synchronized void streamReasoning(String delta) { streamLiveTranscript(LiveTranscriptKind.REASONING, delta); } private JlineShellTerminalIO shellTerminal() { return terminal instanceof JlineShellTerminalIO ? (JlineShellTerminalIO) terminal : null; } private TranscriptPrinter transcriptPrinter() { if (transcriptPrinter == null) { transcriptPrinter = new TranscriptPrinter(terminal); } return transcriptPrinter; } private void streamLiveTranscript(LiveTranscriptKind kind, String delta) { if (delta == null || delta.isEmpty()) { return; } ensureLiveTranscript(kind); if (kind == LiveTranscriptKind.ASSISTANT) { streamAssistantMarkdown(delta); return; } String normalized = delta.replace("\r", ""); int start = 0; while (start <= normalized.length()) { int newlineIndex = normalized.indexOf('\n', start); String fragment = newlineIndex >= 0 ? normalized.substring(start, newlineIndex) : normalized.substring(start); if (!fragment.isEmpty()) { if (kind == LiveTranscriptKind.REASONING && liveTranscriptAtLineStart) { emitLiveTranscriptFragment(kind, "Thinking: "); } emitLiveTranscriptFragment(kind, fragment); liveTranscriptAtLineStart = false; } if (newlineIndex < 0) { break; } terminal.println(""); liveTranscriptAtLineStart = true; start = newlineIndex + 1; } } private void ensureLiveTranscript(LiveTranscriptKind kind) { if (kind == null) { return; } if (liveTranscriptKind != null && liveTranscriptKind != kind) { finishLiveTranscript(); } if (liveTranscriptKind == null) { if (kind == LiveTranscriptKind.ASSISTANT) { beginAssistantBlockTracking(); } transcriptPrinter().beginStreamingBlock(); liveTranscriptKind = kind; liveTranscriptAtLineStart = true; } } private void finishLiveTranscript() { if (liveTranscriptKind == null) { return; } if (liveTranscriptKind == LiveTranscriptKind.ASSISTANT) { flushAssistantRemainder(); } if (assistantInsideCodeBlock) { exitTranscriptCodeBlock(); } if (!liveTranscriptAtLineStart) { terminal.println(""); } liveTranscriptKind = null; liveTranscriptAtLineStart = true; assistantLineBuffer.setLength(0); assistantInsideCodeBlock = false; assistantCodeBlockLanguage = null; } private void emitLiveTranscriptFragment(LiveTranscriptKind kind, String fragment) { if (fragment == null || fragment.isEmpty()) { return; } JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { if (kind == LiveTranscriptKind.REASONING) { shellTerminal.printReasoningFragment(fragment); } else { shellTerminal.printAssistantFragment(fragment); } return; } terminal.print(fragment); } private void streamAssistantMarkdown(String delta) { String normalized = delta.replace("\r", ""); int index = 0; while (index < normalized.length()) { int newlineIndex = normalized.indexOf('\n', index); if (newlineIndex < 0) { assistantLineBuffer.append(normalized.substring(index)); return; } assistantLineBuffer.append(normalized.substring(index, newlineIndex)); emitCompletedAssistantLine(); index = newlineIndex + 1; } } private void emitCompletedAssistantLine() { if (codexStyleBlockFormatter.isCodeFenceLine(assistantLineBuffer.toString())) { if (!assistantInsideCodeBlock) { assistantInsideCodeBlock = true; assistantCodeBlockLanguage = codexStyleBlockFormatter.codeFenceLanguage(assistantLineBuffer.toString()); enterTranscriptCodeBlock(assistantCodeBlockLanguage); } else { assistantInsideCodeBlock = false; assistantCodeBlockLanguage = null; exitTranscriptCodeBlock(); } } else if (assistantInsideCodeBlock) { emitLiveTranscriptLine(codexStyleBlockFormatter.formatCodeContentLine(assistantLineBuffer.toString())); } else { emitLiveTranscriptAssistantLine(assistantLineBuffer.toString(), true); } assistantLineBuffer.setLength(0); liveTranscriptAtLineStart = true; } private void flushAssistantRemainder() { if (assistantLineBuffer.length() == 0) { assistantInsideCodeBlock = false; assistantCodeBlockLanguage = null; return; } if (assistantInsideCodeBlock) { emitLiveTranscriptLine(codexStyleBlockFormatter.formatCodeContentLine(assistantLineBuffer.toString())); assistantInsideCodeBlock = false; assistantCodeBlockLanguage = null; exitTranscriptCodeBlock(); return; } if (codexStyleBlockFormatter.isCodeFenceLine(assistantLineBuffer.toString())) { assistantInsideCodeBlock = true; assistantCodeBlockLanguage = codexStyleBlockFormatter.codeFenceLanguage(assistantLineBuffer.toString()); enterTranscriptCodeBlock(assistantCodeBlockLanguage); return; } emitLiveTranscriptAssistantLine(assistantLineBuffer.toString(), false); } private void emitLiveTranscriptLine(String line) { if (line == null) { line = ""; } if (!liveTranscriptAtLineStart) { terminal.println(""); } terminal.println(line); liveTranscriptAtLineStart = true; } private void emitLiveTranscriptAssistantLine(String line, boolean newline) { String safe = line == null ? "" : line; if (newline) { if (!liveTranscriptAtLineStart) { terminal.println(""); } JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.printTranscriptLine(safe, true); } else { terminal.println(safe); } liveTranscriptAtLineStart = true; return; } JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.printTranscriptLine(safe, false); } else { terminal.print(safe); } liveTranscriptAtLineStart = false; } private void enterTranscriptCodeBlock(String language) { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.enterTranscriptCodeBlock(language); } } private void beginAssistantBlockTracking() { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.beginAssistantBlockTracking(); } } private void exitTranscriptCodeBlock() { JlineShellTerminalIO shellTerminal = shellTerminal(); if (shellTerminal != null) { shellTerminal.exitTranscriptCodeBlock(); } } private List trimBlankEdges(String[] rawLines) { List lines = new ArrayList(); if (rawLines == null || rawLines.length == 0) { return lines; } int start = 0; int end = rawLines.length - 1; while (start <= end && isBlank(rawLines[start])) { start++; } while (end >= start && isBlank(rawLines[end])) { end--; } for (int index = start; index <= end; index++) { lines.add(rawLines[index] == null ? "" : rawLines[index]); } return lines; } private boolean sameText(String left, String right) { return left == null ? right == null : left.equals(right); } } private enum LiveTranscriptKind { REASONING, ASSISTANT } private static final class DispatchResult { private final ManagedCodingSession session; private final boolean exitRequested; private DispatchResult(ManagedCodingSession session, boolean exitRequested) { this.session = session; this.exitRequested = exitRequested; } private static DispatchResult stay(ManagedCodingSession session) { return new DispatchResult(session, false); } private static DispatchResult exit(ManagedCodingSession session) { return new DispatchResult(session, true); } private ManagedCodingSession getSession() { return session; } private boolean isExitRequested() { return exitRequested; } } private String safeMessage(Throwable throwable) { String message = throwable == null ? null : throwable.getMessage(); if (isBlank(message)) { return throwable == null ? "unknown error" : throwable.getClass().getSimpleName(); } return message; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private boolean safeEquals(String left, String right) { return left == null ? right == null : left.equals(right); } private boolean sameTurnId(String left, String right) { if (left == null) { return right == null; } return left.equals(right); } private boolean isExitCommand(String input) { String normalized = input == null ? "" : input.trim(); return "/exit".equalsIgnoreCase(normalized) || "/quit".equalsIgnoreCase(normalized); } private String clip(String value, int maxChars) { return CliDisplayWidth.clip(value, maxChars); } private final class CliAgentListener implements AgentListener { private final ManagedCodingSession session; private final String turnId; private final ActiveTuiTurn activeTurn; private final Map toolCalls = new LinkedHashMap(); private String finalOutput; private boolean errorOccurred; private boolean suppressAssistantStreamingAfterTool; private volatile boolean closed; private CliAgentListener(ManagedCodingSession session, String turnId, ActiveTuiTurn activeTurn) { this.session = session; this.turnId = turnId; this.activeTurn = activeTurn; } @Override public void onEvent(AgentEvent event) { if (shouldIgnoreEvents()) { return; } if (event == null || event.getType() == null) { return; } AgentEventType type = event.getType(); if (type == AgentEventType.STEP_START) { tuiLiveTurnState.onStepStart(event.getStep()); if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.showThinking(); } renderTuiIfEnabled(session); if (options.isVerbose() && !isTuiMode()) { terminal.println("[step] " + type.name().toLowerCase(Locale.ROOT) + " #" + event.getStep()); } return; } if (type == AgentEventType.MODEL_REQUEST) { if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.showConnecting(buildModelConnectionStatus(session)); } renderTuiIfEnabled(session); return; } if (type == AgentEventType.MODEL_RETRY) { String detail = firstNonBlank(event.getMessage(), "Retrying model request"); int attempt = retryPayloadInt(event.getPayload(), "attempt"); int maxAttempts = retryPayloadInt(event.getPayload(), "maxAttempts"); tuiLiveTurnState.onRetry(event.getStep(), detail); if (useMainBufferInteractiveShell()) { if (attempt > 0 && maxAttempts > 0) { mainBufferTurnPrinter.showRetrying(detail, attempt, maxAttempts); } else { mainBufferTurnPrinter.showStatus(detail); } } else if (options.isVerbose() && !isTuiMode()) { terminal.println("[model] " + detail); } renderTuiIfEnabled(session); return; } if (type == AgentEventType.MODEL_RESPONSE) { if (!isBlank(event.getMessage())) { if (tuiLiveTurnState.hasPendingReasoning()) { flushPendingReasoning(event.getStep()); } tuiLiveTurnState.onModelDelta(event.getStep(), event.getMessage()); if (streamTranscriptEnabled() && renderMainBufferAssistantIncrementally()) { streamMainBufferAssistantDelta(); } if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.showResponding(); } if (shouldRenderModelDelta(event.getMessage())) { renderTuiIfEnabled(session); } } return; } if (type == AgentEventType.MODEL_REASONING) { if (!isBlank(event.getMessage())) { if (tuiLiveTurnState.hasPendingText()) { flushPendingText(event.getStep()); } tuiLiveTurnState.onReasoningDelta(event.getStep(), event.getMessage()); if (streamTranscriptEnabled() && renderMainBufferReasoningIncrementally()) { streamMainBufferReasoningDelta(); } if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.showThinking(); } if (shouldRenderModelDelta(event.getMessage())) { renderTuiIfEnabled(session); } } return; } if (type == AgentEventType.TOOL_CALL) { handleToolCall(event); return; } if (type == AgentEventType.TOOL_RESULT) { handleToolResult(event); return; } if (AgentHandoffSessionEventSupport.supports(event)) { handleHandoffEvent(event); return; } if (AgentTeamSessionEventSupport.supports(event)) { handleTeamTaskEvent(event); return; } if (AgentTeamMessageSessionEventSupport.supports(event)) { handleTeamMessageEvent(event); return; } if (type == AgentEventType.FINAL_OUTPUT) { if (errorOccurred && isBlank(event.getMessage())) { return; } finalOutput = event.getMessage(); tuiLiveTurnState.onFinalOutput(event.getStep(), finalOutput); if (streamTranscriptEnabled() && renderMainBufferAssistantIncrementally()) { streamMainBufferAssistantDelta(); } renderTuiIfEnabled(session); return; } if (type == AgentEventType.ERROR) { errorOccurred = true; if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.clearTransient(); } else if (!isTuiMode()) { terminal.errorln("[error] " + clip(event.getMessage(), 320)); } flushPendingAssistantText(event.getStep()); tuiLiveTurnState.onError(event.getStep(), event.getMessage()); renderTuiIfEnabled(session); appendEvent(session, SessionEventType.ERROR, turnId, event.getStep(), clip(event.getMessage(), 320), payloadOf( "error", clip(event.getMessage(), options.isVerbose() ? 4000 : 1200) )); if (useMainBufferInteractiveShell()) { emitMainBufferError(event.getMessage()); } return; } if (type == AgentEventType.STEP_END) { tuiLiveTurnState.onStepEnd(event.getStep()); if (useMainBufferInteractiveShell() && isBlank(finalOutput)) { mainBufferTurnPrinter.showThinking(); } renderTuiIfEnabled(session); } if (options.isVerbose() && !isTuiMode() && type == AgentEventType.STEP_END) { terminal.println("[step] " + type.name().toLowerCase(Locale.ROOT) + " #" + event.getStep()); } } private void close() { closed = true; } private boolean shouldIgnoreEvents() { return closed || isTurnInterrupted(turnId, activeTurn); } private int retryPayloadInt(Object payload, String key) { if (!(payload instanceof Map) || isBlank(key)) { return 0; } @SuppressWarnings("unchecked") Map values = (Map) payload; Object value = values.get(key); if (value instanceof Number) { return ((Number) value).intValue(); } if (value instanceof String) { try { return Integer.parseInt(((String) value).trim()); } catch (NumberFormatException ignored) { return 0; } } return 0; } private void handleToolCall(AgentEvent event) { AgentToolCall call = event.getPayload() instanceof AgentToolCall ? (AgentToolCall) event.getPayload() : null; if (call == null) { return; } boolean firstToolCallInTurn = !suppressAssistantStreamingAfterTool; suppressAssistantStreamingAfterTool = true; String callKey = resolveToolCallKey(call); if (toolCalls.containsKey(callKey)) { return; } toolCalls.put(callKey, call); if (!isTuiMode()) { terminal.println("[tool] " + call.getName() + " " + clip(call.getArguments(), options.isVerbose() ? 320 : 160)); } flushPendingAssistantText(event.getStep()); if (firstToolCallInTurn && useMainBufferInteractiveShell()) { mainBufferTurnPrinter.discardAssistantBlock(); } TuiAssistantToolView toolView = buildPendingToolView(call); appendEvent(session, SessionEventType.TOOL_CALL, turnId, event.getStep(), call.getName() + " " + clip(call.getArguments(), 120), payloadOf( "tool", call.getName(), "callId", call.getCallId(), "arguments", clip(call.getArguments(), options.isVerbose() ? 4000 : 1200), "title", toolView.getTitle(), "detail", toolView.getDetail(), "previewLines", toolView.getPreviewLines() )); tuiLiveTurnState.onToolCall(event.getStep(), toolView); if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.showStatus(buildMainBufferRunningStatus(toolView)); } renderTuiIfEnabled(session); } private void handleToolResult(AgentEvent event) { AgentToolResult result = event.getPayload() instanceof AgentToolResult ? (AgentToolResult) event.getPayload() : null; if (result == null) { return; } AgentToolCall call = toolCalls.remove(resolveToolResultKey(result)); if (!isTuiMode()) { terminal.println("[tool-result] " + result.getName() + " " + clip(result.getOutput(), options.isVerbose() ? 320 : 160)); } TuiAssistantToolView toolView = buildCompletedToolView(call, result); appendEvent(session, SessionEventType.TOOL_RESULT, turnId, event.getStep(), result.getName() + " " + clip(result.getOutput(), 120), payloadOf( "tool", result.getName(), "callId", result.getCallId(), "arguments", call == null ? null : clip(call.getArguments(), options.isVerbose() ? 4000 : 1200), "output", clip(result.getOutput(), options.isVerbose() ? 4000 : 1200), "title", toolView.getTitle(), "detail", toolView.getDetail(), "previewLines", toolView.getPreviewLines() )); tuiLiveTurnState.onToolResult(event.getStep(), toolView); if (useMainBufferInteractiveShell()) { if (!isApprovalRejectedToolResult(result)) { mainBufferTurnPrinter.printBlock(buildMainBufferToolLines(toolView)); } } renderTuiIfEnabled(session); appendProcessEvent(session, turnId, event.getStep(), call, result); } private void handleHandoffEvent(AgentEvent event) { SessionEvent sessionEvent = AgentHandoffSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event); if (sessionEvent == null) { return; } appendEvent(session, sessionEvent.getType(), turnId, sessionEvent.getStep(), sessionEvent.getSummary(), sessionEvent.getPayload()); if (useMainBufferInteractiveShell() && sessionEvent.getType() == SessionEventType.TASK_UPDATED) { mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock( "Subagent task", buildReplayTaskLines(sessionEvent) )); } renderTuiIfEnabled(session); } private void handleTeamTaskEvent(AgentEvent event) { SessionEvent sessionEvent = AgentTeamSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event); if (sessionEvent == null) { return; } appendEvent(session, sessionEvent.getType(), turnId, sessionEvent.getStep(), sessionEvent.getSummary(), sessionEvent.getPayload()); if (useMainBufferInteractiveShell() && sessionEvent.getType() == SessionEventType.TASK_UPDATED) { mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock( "Team task", buildReplayTaskLines(sessionEvent) )); } renderTuiIfEnabled(session); } private void handleTeamMessageEvent(AgentEvent event) { SessionEvent sessionEvent = AgentTeamMessageSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event); if (sessionEvent == null) { return; } appendEvent(session, sessionEvent.getType(), turnId, sessionEvent.getStep(), sessionEvent.getSummary(), sessionEvent.getPayload()); if (useMainBufferInteractiveShell()) { mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock( "Team message", buildReplayTeamMessageLines(sessionEvent) )); } renderTuiIfEnabled(session); } private void flushFinalOutput() { if (shouldIgnoreEvents()) { return; } if (useMainBufferInteractiveShell()) { flushPendingAssistantText(null); // When transcript streaming is off, flushPendingAssistantText() // has already emitted the completed assistant block. Printing // finalOutput again duplicates the same answer at turn end. if (streamEnabled && !isBlank(finalOutput)) { mainBufferTurnPrinter.printAssistantBlock(finalOutput); } return; } // In one-shot CLI mode, onFinalOutput() has already reconciled the // streamed deltas with the final provider payload inside the live // text buffer. Flushing the pending assistant text emits the final // answer once; printing finalOutput directly here duplicates it. flushPendingAssistantText(null); tuiLiveTurnState.finishTurn(null, finalOutput); renderTuiIfEnabled(session); } private void flushPendingAssistantText(Integer step) { if (streamTranscriptEnabled() && renderMainBufferReasoningIncrementally()) { streamMainBufferReasoningDelta(); } if (streamTranscriptEnabled() && renderMainBufferAssistantIncrementally()) { streamMainBufferAssistantDelta(); } flushPendingReasoning(step); flushPendingText(step); } private void flushPendingReasoning(Integer step) { String pendingReasoning = tuiLiveTurnState.flushPendingReasoning(); if (isBlank(pendingReasoning)) { return; } appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(pendingReasoning, 200), payloadOf( "kind", "reasoning", "output", clipPreserveNewlines(pendingReasoning, options.isVerbose() ? 4000 : 1200) )); if ((!useMainBufferInteractiveShell() || !streamEnabled || !renderMainBufferReasoningIncrementally()) && !suppressMainBufferReasoningBlocks()) { emitMainBufferReasoning(pendingReasoning); } } private void flushPendingText(Integer step) { String pendingText = tuiLiveTurnState.flushPendingText(); if (isBlank(pendingText)) { return; } appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(pendingText, 200), payloadOf( "kind", "assistant", "output", clipPreserveNewlines(pendingText, options.isVerbose() ? 4000 : 1200) )); if (!useMainBufferInteractiveShell() || !streamEnabled) { emitMainBufferAssistant(pendingText); } } private void streamMainBufferReasoningDelta() { String delta = tuiLiveTurnState.flushLiveReasoning(); if (!isBlank(delta)) { mainBufferTurnPrinter.streamReasoning(delta); } } private void streamMainBufferAssistantDelta() { if (!renderMainBufferAssistantIncrementally() || suppressAssistantStreamingAfterTool) { return; } String delta = tuiLiveTurnState.flushLiveText(); if (!isBlank(delta)) { mainBufferTurnPrinter.streamAssistant(delta); } } } private final class ActiveTuiTurn { private final ManagedCodingSession session; private final String input; private final String turnId = newTurnId(); private volatile Thread thread; private volatile boolean interrupted; private volatile boolean done; private volatile Exception failure; private ActiveTuiTurn(ManagedCodingSession session, String input) { this.session = session; this.input = input; } private void start() { Thread worker = new Thread(new Runnable() { @Override public void run() { try { runTurn(session, input, ActiveTuiTurn.this); } catch (Exception ex) { failure = ex; } finally { done = true; } } }, "ai4j-tui-turn"); thread = worker; worker.start(); } private boolean requestInterrupt() { if (done || interrupted) { return false; } interrupted = true; Thread worker = thread; if (worker != null) { worker.interrupt(); } return true; } private ManagedCodingSession getSession() { return session; } private String getTurnId() { return turnId; } private boolean isInterrupted() { return interrupted; } private boolean isDone() { return done; } private Exception getFailure() { return failure; } } private AssistantReplayPlan computeAssistantReplayPlan(String currentText, String finalText) { List finalLines = assistantTranscriptRenderer.plainLines(finalText); if (finalLines.isEmpty()) { return AssistantReplayPlan.none(); } List currentLines = assistantTranscriptRenderer.plainLines(currentText); if (currentLines.isEmpty()) { return AssistantReplayPlan.append(finalLines); } if (normalizeAssistantComparisonText(currentLines).equals(normalizeAssistantComparisonText(finalLines))) { return AssistantReplayPlan.none(); } int prefix = commonAssistantPrefixLength(currentLines, finalLines); if (prefix >= currentLines.size()) { return AssistantReplayPlan.append(finalLines.subList(prefix, finalLines.size())); } return AssistantReplayPlan.replace(computeRenderedSupplementalReplayLines(currentLines, finalLines)); } private List computeRenderedSupplementalReplayLines(List currentLines, List finalLines) { if (finalLines == null || finalLines.isEmpty()) { return Collections.emptyList(); } if (currentLines == null || currentLines.isEmpty()) { return new ArrayList(finalLines); } int prefix = commonAssistantPrefixLength(currentLines, finalLines); int suffix = 0; while (suffix < currentLines.size() - prefix && suffix < finalLines.size() - prefix && assistantComparisonLine(currentLines.get(currentLines.size() - 1 - suffix)) .equals(assistantComparisonLine(finalLines.get(finalLines.size() - 1 - suffix)))) { suffix++; } int finalStart = prefix; int finalEnd = finalLines.size() - suffix; if (finalStart >= finalEnd) { return Collections.emptyList(); } return new ArrayList(finalLines.subList(finalStart, finalEnd)); } private int commonAssistantPrefixLength(List currentLines, List finalLines) { int prefix = 0; while (prefix < currentLines.size() && prefix < finalLines.size() && assistantComparisonLine(currentLines.get(prefix)).equals(assistantComparisonLine(finalLines.get(prefix)))) { prefix++; } return prefix; } private String normalizeAssistantComparisonText(List lines) { if (lines == null || lines.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (String line : lines) { if (builder.length() > 0) { builder.append('\n'); } builder.append(assistantComparisonLine(line)); } return builder.toString().trim(); } private boolean assistantTextMatches(String currentText, String finalText) { return normalizeAssistantComparisonText(assistantTranscriptRenderer.plainLines(currentText)) .equals(normalizeAssistantComparisonText(assistantTranscriptRenderer.plainLines(finalText))); } private String assistantComparisonLine(String line) { String safe = line == null ? "" : line.trim(); if (codexStyleBlockFormatter.isCodeFenceLine(safe)) { return "```"; } safe = safe.replace("`", ""); safe = safe.replace("**", ""); safe = safe.replace("__", ""); safe = safe.replace("*", ""); safe = safe.replace("_", ""); return safe; } private String joinLines(List lines) { if (lines == null || lines.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (int index = 0; index < lines.size(); index++) { if (index > 0) { builder.append('\n'); } builder.append(lines.get(index) == null ? "" : lines.get(index)); } return builder.toString(); } private String clipPreserveNewlines(String value, int maxChars) { if (value == null) { return ""; } String normalized = value.replace("\r", "").trim(); if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, maxChars) + "..."; } private static final class McpAddCommand { private final String name; private final CliMcpServerDefinition definition; private McpAddCommand(String name, CliMcpServerDefinition definition) { this.name = name; this.definition = definition; } } private static final class ProviderProfileMutation { private final String profileName; private String provider; private String protocol; private String model; private String baseUrl; private String apiKey; private boolean clearModel; private boolean clearBaseUrl; private boolean clearApiKey; private ProviderProfileMutation(String profileName) { this.profileName = profileName; } private boolean hasAnyFieldChanges() { return provider != null || protocol != null || model != null || baseUrl != null || apiKey != null || clearModel || clearBaseUrl || clearApiKey; } } private static final class AssistantReplayPlan { private final boolean replaceBlock; private final List supplementalLines; private AssistantReplayPlan(boolean replaceBlock, List supplementalLines) { this.replaceBlock = replaceBlock; this.supplementalLines = supplementalLines == null ? Collections.emptyList() : new ArrayList(supplementalLines); } private static AssistantReplayPlan none() { return new AssistantReplayPlan(false, Collections.emptyList()); } private static AssistantReplayPlan append(List supplementalLines) { return new AssistantReplayPlan(false, supplementalLines); } private static AssistantReplayPlan replace(List supplementalLines) { return new AssistantReplayPlan(true, supplementalLines); } private boolean replaceBlock() { return replaceBlock; } private List supplementalLines() { return supplementalLines; } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CodingCliTuiSupport.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.tui.TuiConfig; import io.github.lnyocly.ai4j.tui.TuiRenderer; import io.github.lnyocly.ai4j.tui.TuiRuntime; import io.github.lnyocly.ai4j.tui.TuiTheme; public class CodingCliTuiSupport { private final TuiConfig config; private final TuiTheme theme; private final TuiRenderer renderer; private final TuiRuntime runtime; public CodingCliTuiSupport(TuiConfig config, TuiTheme theme, TuiRenderer renderer, TuiRuntime runtime) { this.config = config; this.theme = theme; this.renderer = renderer; this.runtime = runtime; } public TuiConfig getConfig() { return config; } public TuiTheme getTheme() { return theme; } public TuiRenderer getRenderer() { return renderer; } public TuiRuntime getRuntime() { return runtime; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CodingTaskSessionEventBridge.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.coding.runtime.CodingRuntimeListener; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.coding.task.CodingTask; import io.github.lnyocly.ai4j.coding.task.CodingTaskProgress; import io.github.lnyocly.ai4j.coding.task.CodingTaskStatus; import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public class CodingTaskSessionEventBridge implements CodingRuntimeListener { public interface SessionEventConsumer { void onEvent(SessionEvent event); } private final CodingSessionManager sessionManager; private final SessionEventConsumer consumer; public CodingTaskSessionEventBridge(CodingSessionManager sessionManager) { this(sessionManager, null); } public CodingTaskSessionEventBridge(CodingSessionManager sessionManager, SessionEventConsumer consumer) { this.sessionManager = sessionManager; this.consumer = consumer; } @Override public void onTaskCreated(CodingTask task, CodingSessionLink link) { append(toTaskCreatedEvent(task, link)); } @Override public void onTaskUpdated(CodingTask task) { append(toTaskUpdatedEvent(task)); } public SessionEvent toTaskCreatedEvent(CodingTask task, CodingSessionLink link) { if (task == null || isBlank(task.getParentSessionId())) { return null; } return SessionEvent.builder() .eventId(UUID.randomUUID().toString()) .sessionId(task.getParentSessionId()) .type(SessionEventType.TASK_CREATED) .timestamp(System.currentTimeMillis()) .summary(buildSummary(task)) .payload(buildPayload(task, link)) .build(); } public SessionEvent toTaskUpdatedEvent(CodingTask task) { if (task == null || isBlank(task.getParentSessionId())) { return null; } return SessionEvent.builder() .eventId(UUID.randomUUID().toString()) .sessionId(task.getParentSessionId()) .type(SessionEventType.TASK_UPDATED) .timestamp(System.currentTimeMillis()) .summary(buildSummary(task)) .payload(buildPayload(task, null)) .build(); } private void append(SessionEvent event) { if (event == null) { return; } if (sessionManager != null) { try { sessionManager.appendEvent(event.getSessionId(), event); } catch (IOException ignored) { } } if (consumer != null) { consumer.onEvent(event); } } private Map buildPayload(CodingTask task, CodingSessionLink link) { Map payload = new LinkedHashMap(); CodingTaskProgress progress = task == null ? null : task.getProgress(); String detail = progress == null ? null : trimToNull(progress.getMessage()); String output = trimToNull(task == null ? null : task.getOutputText()); String error = trimToNull(task == null ? null : task.getError()); payload.put("taskId", task == null ? null : task.getTaskId()); payload.put("callId", task == null ? null : task.getTaskId()); payload.put("tool", task == null ? null : task.getDefinitionName()); payload.put("title", buildTitle(task)); payload.put("detail", detail); payload.put("status", normalizeStatus(task == null ? null : task.getStatus())); payload.put("background", task != null && task.isBackground()); payload.put("childSessionId", task == null ? null : task.getChildSessionId()); payload.put("phase", progress == null ? null : progress.getPhase()); payload.put("percent", progress == null ? null : progress.getPercent()); payload.put("sessionMode", link == null || link.getSessionMode() == null ? null : link.getSessionMode().name().toLowerCase()); payload.put("output", output); payload.put("error", error); payload.put("previewLines", previewLines(firstNonBlank(error, output, detail))); return payload; } private String buildSummary(CodingTask task) { String title = buildTitle(task); String status = normalizeStatus(task == null ? null : task.getStatus()); return firstNonBlank(title, "delegate task") + " [" + firstNonBlank(status, "unknown") + "]"; } private String buildTitle(CodingTask task) { String definitionName = task == null ? null : trimToNull(task.getDefinitionName()); if (definitionName == null) { return "Delegate task"; } return "Delegate " + definitionName; } private List previewLines(String raw) { List lines = new ArrayList(); if (isBlank(raw)) { return lines; } String[] split = raw.replace("\r", "").split("\n"); int max = Math.min(4, split.length); for (int i = 0; i < max; i++) { String line = trimToNull(split[i]); if (line != null) { lines.add(line); } } return lines; } private String normalizeStatus(CodingTaskStatus status) { return status == null ? null : status.name().toLowerCase(); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/HeadlessCodingSessionRuntime.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.model.ResponsesModelClient; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.coding.CodingAgentResult; import io.github.lnyocly.ai4j.coding.CodingAgentRequest; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision; import io.github.lnyocly.ai4j.coding.loop.CodingStopReason; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import java.io.IOException; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; public class HeadlessCodingSessionRuntime { public static final String TURN_INTERRUPTED_MESSAGE = "Conversation interrupted by user."; private final CodeCommandOptions options; private final CodingSessionManager sessionManager; public HeadlessCodingSessionRuntime(CodeCommandOptions options, CodingSessionManager sessionManager) { this.options = options; this.sessionManager = sessionManager; } public PromptResult runPrompt(ManagedCodingSession session, String input, PromptControl control, HeadlessTurnObserver observer) throws Exception { if (session == null || session.getSession() == null) { throw new IllegalArgumentException("managed session is required"); } String turnId = newTurnId(); PromptControl activeControl = control == null ? new PromptControl() : control; HeadlessTurnObserver effectiveObserver = observer == null ? new HeadlessTurnObserver.Adapter() : observer; appendEvent(session, SessionEventType.USER_MESSAGE, turnId, null, clip(input, 200), payloadOf( "input", clipPreserveNewlines(input, options != null && options.isVerbose() ? 4000 : 1200) )); effectiveObserver.onTurnStarted(session, turnId, input); HeadlessAgentListener listener = new HeadlessAgentListener(session, turnId, activeControl, effectiveObserver); try { activeControl.attach(Thread.currentThread()); CodingAgentResult result = session.getSession().runStream(CodingAgentRequest.builder().input(input).build(), listener); if (activeControl.isCancelled()) { appendCancelledEvent(session, turnId); persistSession(session); effectiveObserver.onTurnCompleted(session, turnId, listener.getFinalOutput(), true); return PromptResult.cancelled(turnId, listener.getFinalOutput()); } String finalOutput = listener.flushFinalOutput(); appendLoopDecisionEvents(session, turnId, result, effectiveObserver); appendAutoCompactEvent(session, turnId); persistSession(session); effectiveObserver.onTurnCompleted(session, turnId, finalOutput, false); return PromptResult.completed(turnId, finalOutput, result == null ? null : result.getStopReason()); } catch (Exception ex) { if (activeControl.isCancelled() || Thread.currentThread().isInterrupted()) { appendCancelledEvent(session, turnId); persistSession(session); effectiveObserver.onTurnCompleted(session, turnId, listener.getFinalOutput(), true); return PromptResult.cancelled(turnId, listener.getFinalOutput()); } String message = safeMessage(ex); appendEvent(session, SessionEventType.ERROR, turnId, null, message, payloadOf("error", message)); effectiveObserver.onTurnError(session, turnId, null, message); throw ex; } finally { listener.close(); activeControl.detach(); } } private void appendCancelledEvent(ManagedCodingSession session, String turnId) { appendEvent(session, SessionEventType.ERROR, turnId, null, TURN_INTERRUPTED_MESSAGE, payloadOf( "error", TURN_INTERRUPTED_MESSAGE )); } private void appendAutoCompactEvent(ManagedCodingSession session, String turnId) { if (session == null || session.getSession() == null) { return; } List results = session.getSession().drainAutoCompactResults(); for (CodingSessionCompactResult result : results) { if (result == null) { continue; } appendEvent(session, SessionEventType.COMPACT, turnId, null, (result.isAutomatic() ? "auto" : "manual") + " compact " + result.getEstimatedTokensBefore() + "->" + result.getEstimatedTokensAfter() + " tokens", payloadOf( "automatic", result.isAutomatic(), "strategy", result.getStrategy(), "beforeItemCount", result.getBeforeItemCount(), "afterItemCount", result.getAfterItemCount(), "estimatedTokensBefore", result.getEstimatedTokensBefore(), "estimatedTokensAfter", result.getEstimatedTokensAfter(), "compactedToolResultCount", result.getCompactedToolResultCount(), "deltaItemCount", result.getDeltaItemCount(), "checkpointReused", result.isCheckpointReused(), "fallbackSummary", result.isFallbackSummary(), "splitTurn", result.isSplitTurn(), "summary", clip(result.getSummary(), options != null && options.isVerbose() ? 4000 : 1200), "checkpointGoal", result.getCheckpoint() == null ? null : result.getCheckpoint().getGoal() )); } List compactErrors = session.getSession().drainAutoCompactErrors(); for (Exception compactError : compactErrors) { if (compactError == null) { continue; } String message = safeMessage(compactError); appendEvent(session, SessionEventType.ERROR, turnId, null, message, payloadOf( "error", message, "source", "auto-compact" )); } } private void appendLoopDecisionEvents(ManagedCodingSession session, String turnId, CodingAgentResult result, HeadlessTurnObserver observer) { if (session == null || session.getSession() == null) { return; } List decisions = session.getSession().drainLoopDecisions(); for (CodingLoopDecision decision : decisions) { if (decision == null) { continue; } SessionEventType eventType = decision.isContinueLoop() ? SessionEventType.AUTO_CONTINUE : decision.isBlocked() ? SessionEventType.BLOCKED : SessionEventType.AUTO_STOP; SessionEvent event = SessionEvent.builder() .sessionId(session.getSessionId()) .type(eventType) .turnId(turnId) .summary(firstNonBlank(decision.getSummary(), formatStopReason(result == null ? null : result.getStopReason()))) .payload(payloadOf( "turnNumber", decision.getTurnNumber(), "continueReason", decision.getContinueReason(), "stopReason", decision.getStopReason() == null ? null : decision.getStopReason().name().toLowerCase(), "compactApplied", decision.isCompactApplied() )) .build(); appendEvent(session, event); observer.onSessionEvent(session, event); } } private void persistSession(ManagedCodingSession session) { if (session == null || sessionManager == null || options == null || !options.isAutoSaveSession()) { return; } try { sessionManager.save(session); } catch (IOException ignored) { } } private void appendEvent(ManagedCodingSession session, SessionEventType type, String turnId, Integer step, String summary, Map payload) { if (session == null || type == null || sessionManager == null) { return; } try { sessionManager.appendEvent(session.getSessionId(), SessionEvent.builder() .sessionId(session.getSessionId()) .type(type) .turnId(turnId) .step(step) .summary(summary) .payload(payload) .build()); } catch (IOException ignored) { } } private void appendEvent(ManagedCodingSession session, SessionEvent event) { if (event == null) { return; } appendEvent(session, event.getType(), event.getTurnId(), event.getStep(), event.getSummary(), event.getPayload()); } private String newTurnId() { return UUID.randomUUID().toString(); } private Map payloadOf(Object... values) { Map payload = new LinkedHashMap(); if (values == null) { return payload; } for (int i = 0; i + 1 < values.length; i += 2) { Object key = values[i]; if (key != null) { payload.put(String.valueOf(key), values[i + 1]); } } return payload; } private String clip(String value, int maxChars) { if (value == null) { return null; } String normalized = value.replace('\r', ' ').replace('\n', ' ').trim(); if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, maxChars); } private String clipPreserveNewlines(String value, int maxChars) { if (value == null) { return null; } if (value.length() <= maxChars) { return value; } return value.substring(0, maxChars); } private String safeMessage(Throwable throwable) { String message = null; Throwable current = throwable; Throwable last = throwable; while (current != null) { if (!isBlank(current.getMessage())) { message = current.getMessage().trim(); } last = current; current = current.getCause(); } return isBlank(message) ? (last == null ? "unknown error" : last.getClass().getSimpleName()) : message; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } public static final class PromptControl { private volatile boolean cancelled; private volatile Thread worker; void attach(Thread worker) { this.worker = worker; } void detach() { this.worker = null; } public void cancel() { cancelled = true; Thread currentWorker = worker; if (currentWorker != null) { currentWorker.interrupt(); ChatModelClient.cancelActiveStream(currentWorker); ResponsesModelClient.cancelActiveStream(currentWorker); } } public boolean isCancelled() { return cancelled; } } public static final class PromptResult { private final String turnId; private final String finalOutput; private final String stopReason; private final CodingStopReason codingStopReason; private PromptResult(String turnId, String finalOutput, String stopReason, CodingStopReason codingStopReason) { this.turnId = turnId; this.finalOutput = finalOutput; this.stopReason = stopReason; this.codingStopReason = codingStopReason; } public static PromptResult completed(String turnId, String finalOutput, CodingStopReason codingStopReason) { return new PromptResult(turnId, finalOutput, mapPromptStopReason(codingStopReason), codingStopReason); } public static PromptResult cancelled(String turnId, String finalOutput) { return new PromptResult(turnId, finalOutput, "cancelled", CodingStopReason.INTERRUPTED); } public String getTurnId() { return turnId; } public String getFinalOutput() { return finalOutput; } public String getStopReason() { return stopReason; } public CodingStopReason getCodingStopReason() { return codingStopReason; } private static String mapPromptStopReason(CodingStopReason codingStopReason) { if (codingStopReason == CodingStopReason.BLOCKED_BY_APPROVAL || codingStopReason == CodingStopReason.BLOCKED_BY_TOOL_ERROR) { return "blocked"; } return "end_turn"; } } private final class HeadlessAgentListener implements AgentListener { private final ManagedCodingSession session; private final String turnId; private final PromptControl control; private final HeadlessTurnObserver observer; private final Map toolCalls = new LinkedHashMap(); private final StringBuilder pendingReasoning = new StringBuilder(); private final StringBuilder pendingText = new StringBuilder(); private String finalOutput; private boolean closed; private HeadlessAgentListener(ManagedCodingSession session, String turnId, PromptControl control, HeadlessTurnObserver observer) { this.session = session; this.turnId = turnId; this.control = control; this.observer = observer; } @Override public void onEvent(AgentEvent event) { if (closed || control.isCancelled() || event == null || event.getType() == null) { return; } AgentEventType type = event.getType(); if (type == AgentEventType.MODEL_REASONING) { handleReasoning(event); return; } if (type == AgentEventType.MODEL_RESPONSE) { handleAssistant(event); return; } if (type == AgentEventType.TOOL_CALL) { flushPendingAssistantText(event.getStep()); handleToolCall(event); return; } if (type == AgentEventType.TOOL_RESULT) { handleToolResult(event); return; } if (AgentHandoffSessionEventSupport.supports(event)) { handleHandoffEvent(event); return; } if (AgentTeamSessionEventSupport.supports(event)) { handleTeamEvent(event); return; } if (AgentTeamMessageSessionEventSupport.supports(event)) { handleTeamMessageEvent(event); return; } if (type == AgentEventType.FINAL_OUTPUT) { finalOutput = event.getMessage(); return; } if (type == AgentEventType.ERROR) { flushPendingAssistantText(event.getStep()); observer.onTurnError(session, turnId, event.getStep(), event.getMessage()); } } private void handleReasoning(AgentEvent event) { if (event.getMessage() == null || event.getMessage().isEmpty()) { return; } pendingReasoning.append(event.getMessage()); observer.onReasoningDelta(session, turnId, event.getStep(), event.getMessage()); } private void handleAssistant(AgentEvent event) { if (event.getMessage() == null || event.getMessage().isEmpty()) { return; } if (pendingReasoning.length() > 0) { flushPendingReasoning(event.getStep()); } pendingText.append(event.getMessage()); observer.onAssistantDelta(session, turnId, event.getStep(), event.getMessage()); } private void handleToolCall(AgentEvent event) { AgentToolCall call = event.getPayload() instanceof AgentToolCall ? (AgentToolCall) event.getPayload() : null; if (call == null) { return; } String toolCallKey = resolveToolCallKey(call); if (toolCallKey != null && toolCalls.containsKey(toolCallKey)) { return; } if (toolCallKey != null) { toolCalls.put(toolCallKey, call); } appendEvent(session, SessionEventType.TOOL_CALL, turnId, event.getStep(), call.getName() + " " + clip(call.getArguments(), 120), payloadOf( "tool", call.getName(), "callId", call.getCallId(), "arguments", clipPreserveNewlines(call.getArguments(), options != null && options.isVerbose() ? 4000 : 1200) )); observer.onToolCall(session, turnId, event.getStep(), call); } private void handleToolResult(AgentEvent event) { AgentToolResult result = event.getPayload() instanceof AgentToolResult ? (AgentToolResult) event.getPayload() : null; if (result == null) { return; } AgentToolCall call = toolCalls.remove(resolveToolResultKey(result)); boolean failed = isApprovalRejectedToolResult(result); appendEvent(session, SessionEventType.TOOL_RESULT, turnId, event.getStep(), result.getName() + " " + clip(result.getOutput(), 120), payloadOf( "tool", result.getName(), "callId", result.getCallId(), "arguments", call == null ? null : clipPreserveNewlines(call.getArguments(), options != null && options.isVerbose() ? 4000 : 1200), "output", clipPreserveNewlines(result.getOutput(), options != null && options.isVerbose() ? 4000 : 1200) )); observer.onToolResult(session, turnId, event.getStep(), call, result, failed); } private void handleHandoffEvent(AgentEvent event) { SessionEvent sessionEvent = AgentHandoffSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event); if (sessionEvent == null) { return; } appendEvent(session, sessionEvent); observer.onSessionEvent(session, sessionEvent); } private void handleTeamEvent(AgentEvent event) { SessionEvent sessionEvent = AgentTeamSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event); if (sessionEvent == null) { return; } appendEvent(session, sessionEvent); observer.onSessionEvent(session, sessionEvent); } private void handleTeamMessageEvent(AgentEvent event) { SessionEvent sessionEvent = AgentTeamMessageSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event); if (sessionEvent == null) { return; } appendEvent(session, sessionEvent); observer.onSessionEvent(session, sessionEvent); } private void flushPendingAssistantText(Integer step) { flushPendingReasoning(step); flushPendingText(step); } private void flushPendingReasoning(Integer step) { if (pendingReasoning.length() == 0) { return; } appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(pendingReasoning.toString(), 200), payloadOf( "kind", "reasoning", "output", clipPreserveNewlines(pendingReasoning.toString(), options != null && options.isVerbose() ? 4000 : 1200) )); pendingReasoning.setLength(0); } private void flushPendingText(Integer step) { String text = pendingText.length() == 0 ? finalOutput : pendingText.toString(); if (isBlank(text)) { return; } appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(text, 200), payloadOf( "kind", "assistant", "output", clipPreserveNewlines(text, options != null && options.isVerbose() ? 4000 : 1200) )); pendingText.setLength(0); } private String flushFinalOutput() { flushPendingAssistantText(null); return finalOutput; } private String getFinalOutput() { return finalOutput; } private void close() { closed = true; } private String resolveToolCallKey(AgentToolCall call) { return call == null ? null : firstNonBlank(call.getCallId(), call.getName()); } private String resolveToolResultKey(AgentToolResult result) { return result == null ? null : firstNonBlank(result.getCallId(), result.getName()); } private boolean isApprovalRejectedToolResult(AgentToolResult result) { return result != null && result.getOutput() != null && result.getOutput().startsWith(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } } private String formatStopReason(CodingStopReason stopReason) { if (stopReason == null) { return null; } String normalized = stopReason.name().toLowerCase().replace('_', ' '); return Character.toUpperCase(normalized.charAt(0)) + normalized.substring(1) + "."; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/HeadlessTurnObserver.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; public interface HeadlessTurnObserver { void onTurnStarted(ManagedCodingSession session, String turnId, String input); void onReasoningDelta(ManagedCodingSession session, String turnId, Integer step, String delta); void onAssistantDelta(ManagedCodingSession session, String turnId, Integer step, String delta); void onToolCall(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call); void onToolResult(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call, AgentToolResult result, boolean failed); void onTurnCompleted(ManagedCodingSession session, String turnId, String finalOutput, boolean cancelled); void onTurnError(ManagedCodingSession session, String turnId, Integer step, String message); void onSessionEvent(ManagedCodingSession session, SessionEvent event); class Adapter implements HeadlessTurnObserver { @Override public void onTurnStarted(ManagedCodingSession session, String turnId, String input) { } @Override public void onReasoningDelta(ManagedCodingSession session, String turnId, Integer step, String delta) { } @Override public void onAssistantDelta(ManagedCodingSession session, String turnId, Integer step, String delta) { } @Override public void onToolCall(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call) { } @Override public void onToolResult(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call, AgentToolResult result, boolean failed) { } @Override public void onTurnCompleted(ManagedCodingSession session, String turnId, String finalOutput, boolean cancelled) { } @Override public void onTurnError(ManagedCodingSession session, String turnId, Integer step, String message) { } @Override public void onSessionEvent(ManagedCodingSession session, SessionEvent event) { } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/TeamBoardRenderSupport.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberSnapshot; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; public final class TeamBoardRenderSupport { private static final int MAX_RECENT_MESSAGES_PER_LANE = 2; private TeamBoardRenderSupport() { } public static List renderBoardLines(List events) { Aggregation aggregation = aggregate(events); if (aggregation.tasksById.isEmpty() && aggregation.messages.isEmpty()) { return Collections.emptyList(); } List lanes = buildLanes(aggregation); List lines = new ArrayList(); lines.add("summary tasks=" + aggregation.tasksById.size() + " running=" + aggregation.runningCount + " completed=" + aggregation.completedCount + " failed=" + aggregation.failedCount + " blocked=" + aggregation.blockedCount + " members=" + lanes.size()); for (LaneState lane : lanes) { if (!lines.isEmpty()) { lines.add(""); } lines.add("lane " + lane.label); if (lane.tasks.isEmpty()) { lines.add(" (no tasks)"); } else { for (TaskState task : lane.tasks) { lines.add(" [" + taskBadge(task) + "] " + taskLabel(task)); if (!isBlank(task.detail)) { lines.add(" " + clip(singleLine(task.detail), 96)); } if (!isBlank(task.childSessionId)) { lines.add(" child session: " + clip(task.childSessionId, 84)); } if (task.heartbeatCount > 0) { lines.add(" heartbeats: " + task.heartbeatCount); } } } if (!lane.messages.isEmpty()) { lines.add(" messages:"); for (MessageState message : lane.messages) { lines.add(" - " + messageLabel(message)); } } } return lines; } public static List renderBoardLines(AgentTeamState state) { Aggregation aggregation = aggregate(state); if (aggregation.tasksById.isEmpty() && aggregation.messages.isEmpty()) { return Collections.emptyList(); } List lanes = buildLanes(aggregation); List lines = new ArrayList(); lines.add("summary tasks=" + aggregation.tasksById.size() + " running=" + aggregation.runningCount + " completed=" + aggregation.completedCount + " failed=" + aggregation.failedCount + " blocked=" + aggregation.blockedCount + " members=" + lanes.size()); for (LaneState lane : lanes) { if (!lines.isEmpty()) { lines.add(""); } lines.add("lane " + lane.label); if (lane.tasks.isEmpty()) { lines.add(" (no tasks)"); } else { for (TaskState task : lane.tasks) { lines.add(" [" + taskBadge(task) + "] " + taskLabel(task)); if (!isBlank(task.detail)) { lines.add(" " + clip(singleLine(task.detail), 96)); } if (!isBlank(task.childSessionId)) { lines.add(" child session: " + clip(task.childSessionId, 84)); } if (task.heartbeatCount > 0) { lines.add(" heartbeats: " + task.heartbeatCount); } } } if (!lane.messages.isEmpty()) { lines.add(" messages:"); for (MessageState message : lane.messages) { lines.add(" - " + messageLabel(message)); } } } return lines; } public static String renderBoardOutput(List lines) { if (lines == null || lines.isEmpty()) { return "team: (none)"; } StringBuilder builder = new StringBuilder("team board:\n"); for (String line : lines) { builder.append(line == null ? "" : line).append('\n'); } return builder.toString().trim(); } private static Aggregation aggregate(List events) { Aggregation aggregation = new Aggregation(); if (events == null || events.isEmpty()) { return aggregation; } for (SessionEvent event : events) { if (event == null || event.getType() == null) { continue; } if (isTeamTaskEvent(event)) { TaskState next = aggregation.tasksById.get(resolveTaskId(event)); if (next == null) { next = new TaskState(); next.order = aggregation.nextTaskOrder++; aggregation.tasksById.put(resolveTaskId(event), next); } applyTaskEvent(next, event); continue; } if (event.getType() == SessionEventType.TEAM_MESSAGE) { MessageState message = toMessageState(event); if (message != null) { aggregation.messages.add(message); } } } for (TaskState task : aggregation.tasksById.values()) { String normalized = normalizeStatus(task.status); if ("running".equals(normalized) || "in_progress".equals(normalized)) { aggregation.runningCount++; } else if ("completed".equals(normalized)) { aggregation.completedCount++; } else if ("failed".equals(normalized)) { aggregation.failedCount++; } else if ("blocked".equals(normalized)) { aggregation.blockedCount++; } } return aggregation; } private static Aggregation aggregate(AgentTeamState state) { Aggregation aggregation = new Aggregation(); if (state == null) { return aggregation; } Map memberLabels = memberLabelMap(state.getMembers()); if (state.getTaskStates() != null) { for (AgentTeamTaskState persistedTask : state.getTaskStates()) { if (persistedTask == null) { continue; } TaskState next = new TaskState(); next.order = aggregation.nextTaskOrder++; next.taskId = firstNonBlank(trimToNull(persistedTask.getTaskId()), trimToNull(persistedTask.getTask() == null ? null : persistedTask.getTask().getId()), "team-task-" + next.order); next.title = trimToNull(persistedTask.getTask() == null ? null : persistedTask.getTask().getTask()); next.task = trimToNull(persistedTask.getTask() == null ? null : persistedTask.getTask().getTask()); next.status = persistedTask.getStatus() == null ? null : persistedTask.getStatus().name().toLowerCase(Locale.ROOT); next.phase = trimToNull(persistedTask.getPhase()); next.detail = firstNonBlank(trimToNull(persistedTask.getDetail()), trimToNull(persistedTask.getError()), trimToNull(persistedTask.getOutput())); next.percent = persistedTask.getPercent() == null ? null : String.valueOf(persistedTask.getPercent()); String memberId = firstNonBlank(trimToNull(persistedTask.getClaimedBy()), trimToNull(taskMemberId(persistedTask.getTask()))); next.memberKey = memberId; next.memberLabel = firstNonBlank(memberLabels.get(memberId), memberId, "unassigned"); next.heartbeatCount = persistedTask.getHeartbeatCount(); next.updatedAtEpochMs = firstPositive( persistedTask.getUpdatedAtEpochMs(), persistedTask.getLastHeartbeatTime(), persistedTask.getEndTime(), persistedTask.getStartTime() ); aggregation.tasksById.put(next.taskId, next); } } if (state.getMessages() != null) { for (AgentTeamMessage message : state.getMessages()) { if (message == null) { continue; } MessageState next = new MessageState(); next.messageId = trimToNull(message.getId()); next.fromMemberId = trimToNull(message.getFromMemberId()); next.toMemberId = trimToNull(message.getToMemberId()); next.messageType = trimToNull(message.getType()); next.taskId = trimToNull(message.getTaskId()); next.text = trimToNull(message.getContent()); next.createdAtEpochMs = message.getCreatedAt(); next.memberKey = firstNonBlank(next.fromMemberId, next.toMemberId, "team"); next.memberLabel = firstNonBlank(memberLabels.get(next.memberKey), next.memberKey, "team"); aggregation.messages.add(next); } } for (TaskState task : aggregation.tasksById.values()) { String normalized = normalizeStatus(task.status); if ("running".equals(normalized) || "in_progress".equals(normalized)) { aggregation.runningCount++; } else if ("completed".equals(normalized)) { aggregation.completedCount++; } else if ("failed".equals(normalized)) { aggregation.failedCount++; } else if ("blocked".equals(normalized)) { aggregation.blockedCount++; } } return aggregation; } private static List buildLanes(Aggregation aggregation) { LinkedHashMap lanes = new LinkedHashMap(); for (TaskState task : aggregation.tasksById.values()) { lane(lanes, task.memberKey, task.memberLabel).tasks.add(task); } for (MessageState message : aggregation.messages) { lane(lanes, message.memberKey, message.memberLabel).messages.add(message); } List ordered = new ArrayList(lanes.values()); for (LaneState lane : ordered) { Collections.sort(lane.tasks, new Comparator() { @Override public int compare(TaskState left, TaskState right) { int priorityCompare = taskPriority(left) - taskPriority(right); if (priorityCompare != 0) { return priorityCompare; } long updatedCompare = right.updatedAtEpochMs - left.updatedAtEpochMs; if (updatedCompare != 0L) { return updatedCompare > 0L ? 1 : -1; } return safeText(left.taskId).compareToIgnoreCase(safeText(right.taskId)); } }); Collections.sort(lane.messages, new Comparator() { @Override public int compare(MessageState left, MessageState right) { long createdCompare = right.createdAtEpochMs - left.createdAtEpochMs; if (createdCompare != 0L) { return createdCompare > 0L ? 1 : -1; } return safeText(left.messageId).compareToIgnoreCase(safeText(right.messageId)); } }); if (lane.messages.size() > MAX_RECENT_MESSAGES_PER_LANE) { lane.messages = new ArrayList(lane.messages.subList(0, MAX_RECENT_MESSAGES_PER_LANE)); } } Collections.sort(ordered, new Comparator() { @Override public int compare(LaneState left, LaneState right) { int leftPriority = lanePriority(left); int rightPriority = lanePriority(right); if (leftPriority != rightPriority) { return leftPriority - rightPriority; } return safeText(left.label).compareToIgnoreCase(safeText(right.label)); } }); return ordered; } private static LaneState lane(Map lanes, String key, String label) { String laneKey = isBlank(key) ? "__unassigned__" : key; LaneState lane = lanes.get(laneKey); if (lane != null) { if (isBlank(lane.label) && !isBlank(label)) { lane.label = label; } return lane; } LaneState created = new LaneState(); created.key = laneKey; created.label = firstNonBlank(label, "unassigned"); lanes.put(laneKey, created); return created; } private static void applyTaskEvent(TaskState state, SessionEvent event) { Map payload = event.getPayload(); state.taskId = firstNonBlank(trimToNull(payloadString(payload, "taskId")), state.taskId); state.title = firstNonBlank(trimToNull(payloadString(payload, "title")), state.title); state.task = firstNonBlank(trimToNull(payloadString(payload, "task")), state.task); state.status = firstNonBlank(trimToNull(payloadString(payload, "status")), state.status); state.phase = firstNonBlank(trimToNull(payloadString(payload, "phase")), state.phase); state.detail = firstNonBlank(trimToNull(payloadString(payload, "detail")), trimToNull(payloadString(payload, "error")), trimToNull(payloadString(payload, "output")), state.detail); state.percent = firstNonBlank(trimToNull(payloadString(payload, "percent")), state.percent); state.memberKey = firstNonBlank(trimToNull(payloadString(payload, "memberId")), trimToNull(payloadString(payload, "memberName")), state.memberKey); state.memberLabel = firstNonBlank(trimToNull(payloadString(payload, "memberName")), trimToNull(payloadString(payload, "memberId")), state.memberLabel, "unassigned"); state.childSessionId = firstNonBlank(trimToNull(payloadString(payload, "childSessionId")), state.childSessionId); state.heartbeatCount = intValue(payload, "heartbeatCount", state.heartbeatCount); state.updatedAtEpochMs = longValue(payload, "updatedAtEpochMs", event.getTimestamp()); if (state.updatedAtEpochMs <= 0L) { state.updatedAtEpochMs = event.getTimestamp(); } } private static MessageState toMessageState(SessionEvent event) { Map payload = event.getPayload(); String text = firstNonBlank(trimToNull(payloadString(payload, "content")), trimToNull(payloadString(payload, "detail"))); String from = trimToNull(payloadString(payload, "fromMemberId")); String to = trimToNull(payloadString(payload, "toMemberId")); String memberLabel = firstNonBlank(from, to, "team"); MessageState state = new MessageState(); state.messageId = trimToNull(payloadString(payload, "messageId")); state.memberKey = memberLabel; state.memberLabel = memberLabel; state.text = text; state.messageType = trimToNull(payloadString(payload, "messageType")); state.taskId = trimToNull(payloadString(payload, "taskId")); state.fromMemberId = from; state.toMemberId = to; state.createdAtEpochMs = longValue(payload, "createdAt", event.getTimestamp()); if (state.createdAtEpochMs <= 0L) { state.createdAtEpochMs = event.getTimestamp(); } return state; } private static boolean isTeamTaskEvent(SessionEvent event) { if (event == null) { return false; } if (event.getType() != SessionEventType.TASK_CREATED && event.getType() != SessionEventType.TASK_UPDATED) { return false; } Map payload = event.getPayload(); String callId = trimToNull(payloadString(payload, "callId")); String title = firstNonBlank(trimToNull(payloadString(payload, "title")), trimToNull(event.getSummary())); return (callId != null && callId.startsWith("team-task:")) || !isBlank(payloadString(payload, "memberId")) || !isBlank(payloadString(payload, "memberName")) || !isBlank(payloadString(payload, "heartbeatCount")) || (title != null && title.toLowerCase(Locale.ROOT).startsWith("team task")); } private static String resolveTaskId(SessionEvent event) { Map payload = event == null ? null : event.getPayload(); String taskId = trimToNull(payloadString(payload, "taskId")); if (!isBlank(taskId)) { return taskId; } String callId = trimToNull(payloadString(payload, "callId")); if (!isBlank(callId)) { return callId; } return firstNonBlank(trimToNull(event == null ? null : event.getSummary()), "team-task"); } private static String taskBadge(TaskState task) { StringBuilder badge = new StringBuilder(); if (!isBlank(task.status)) { badge.append(normalizeStatus(task.status)); } if (!isBlank(task.phase)) { if (badge.length() > 0) { badge.append('/'); } badge.append(task.phase.toLowerCase(Locale.ROOT)); } if (!isBlank(task.percent)) { if (badge.length() > 0) { badge.append(' '); } badge.append(task.percent).append('%'); } return badge.length() == 0 ? "unknown" : badge.toString(); } private static String taskLabel(TaskState task) { String label = firstNonBlank(trimToNull(task.task), trimToNull(task.title), trimToNull(task.taskId), "task"); if (!isBlank(task.taskId) && !safeText(label).toLowerCase(Locale.ROOT).contains(task.taskId.toLowerCase(Locale.ROOT))) { return label + " (" + task.taskId + ")"; } return label; } private static String messageLabel(MessageState message) { StringBuilder builder = new StringBuilder(); if (!isBlank(message.messageType)) { builder.append('[').append(message.messageType).append(']').append(' '); } if (!isBlank(message.fromMemberId) || !isBlank(message.toMemberId)) { builder.append(firstNonBlank(message.fromMemberId, "?")) .append(" -> ") .append(firstNonBlank(message.toMemberId, "?")); } else { builder.append(firstNonBlank(message.memberLabel, "team")); } if (!isBlank(message.taskId)) { builder.append(" | task=").append(message.taskId); } if (!isBlank(message.text)) { builder.append(" | ").append(clip(singleLine(message.text), 76)); } return builder.toString(); } private static int lanePriority(LaneState lane) { if (lane == null || lane.tasks.isEmpty()) { return 3; } int best = Integer.MAX_VALUE; for (TaskState task : lane.tasks) { best = Math.min(best, taskPriority(task)); } return best; } private static int taskPriority(TaskState task) { String normalized = normalizeStatus(task == null ? null : task.status); if ("running".equals(normalized) || "in_progress".equals(normalized)) { return 0; } if ("ready".equals(normalized) || "pending".equals(normalized)) { return 1; } if ("failed".equals(normalized) || "blocked".equals(normalized)) { return 2; } if ("completed".equals(normalized)) { return 3; } return 4; } private static int intValue(Map payload, String key, int fallback) { String value = trimToNull(payloadString(payload, key)); if (value == null) { return fallback; } try { return Integer.parseInt(value); } catch (NumberFormatException ignored) { return fallback; } } private static long longValue(Map payload, String key, long fallback) { String value = trimToNull(payloadString(payload, key)); if (value == null) { return fallback; } try { return Long.parseLong(value); } catch (NumberFormatException ignored) { return fallback; } } private static String payloadString(Map payload, String key) { if (payload == null || key == null) { return null; } Object value = payload.get(key); return value == null ? null : String.valueOf(value); } private static String taskMemberId(AgentTeamTask task) { return task == null ? null : task.getMemberId(); } private static long firstPositive(long... values) { if (values == null) { return 0L; } for (long value : values) { if (value > 0L) { return value; } } return 0L; } private static Map memberLabelMap(List members) { if (members == null || members.isEmpty()) { return Collections.emptyMap(); } Map labels = new LinkedHashMap(); for (AgentTeamMemberSnapshot member : members) { if (member == null || isBlank(member.getId())) { continue; } labels.put(member.getId(), firstNonBlank(trimToNull(member.getName()), trimToNull(member.getId()))); } return labels; } private static String singleLine(String value) { if (value == null) { return null; } return value.replace('\r', ' ').replace('\n', ' ').trim(); } private static String clip(String value, int maxChars) { String normalized = trimToNull(value); if (normalized == null || normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, Math.max(0, maxChars)) + "..."; } private static String normalizeStatus(String value) { return value == null ? null : value.trim().toLowerCase(Locale.ROOT); } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private static String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static String safeText(String value) { return value == null ? "" : value; } private static final class Aggregation { private final LinkedHashMap tasksById = new LinkedHashMap(); private final List messages = new ArrayList(); private int nextTaskOrder; private int runningCount; private int completedCount; private int failedCount; private int blockedCount; } private static final class LaneState { private String key; private String label; private List tasks = new ArrayList(); private List messages = new ArrayList(); } private static final class TaskState { private int order; private String taskId; private String title; private String task; private String status; private String phase; private String detail; private String percent; private String memberKey; private String memberLabel; private String childSessionId; private int heartbeatCount; private long updatedAtEpochMs; } private static final class MessageState { private String messageId; private String memberKey; private String memberLabel; private String fromMemberId; private String toMemberId; private String messageType; private String taskId; private String text; private long createdAtEpochMs; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/CodingSessionManager.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import java.io.IOException; import java.nio.file.Path; import java.util.List; public interface CodingSessionManager { ManagedCodingSession create(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options) throws Exception; ManagedCodingSession resume(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, String sessionId) throws Exception; ManagedCodingSession fork(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, String sourceSessionId, String targetSessionId) throws Exception; StoredCodingSession save(ManagedCodingSession session) throws IOException; StoredCodingSession load(String sessionId) throws IOException; List list() throws IOException; void delete(String sessionId) throws IOException; SessionEvent appendEvent(String sessionId, SessionEvent event) throws IOException; List listEvents(String sessionId, Integer limit, Long offset) throws IOException; Path getDirectory(); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/CodingSessionStore.java ================================================ package io.github.lnyocly.ai4j.cli.session; import java.io.IOException; import java.nio.file.Path; import java.util.List; public interface CodingSessionStore { StoredCodingSession save(StoredCodingSession session) throws IOException; StoredCodingSession load(String sessionId) throws IOException; List list() throws IOException; Path getDirectory(); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/DefaultCodingSessionManager.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionSnapshot; import io.github.lnyocly.ai4j.coding.CodingSessionState; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.UUID; public class DefaultCodingSessionManager implements CodingSessionManager { private final CodingSessionStore sessionStore; private final SessionEventStore eventStore; public DefaultCodingSessionManager(CodingSessionStore sessionStore, SessionEventStore eventStore) { this.sessionStore = sessionStore; this.eventStore = eventStore; } @Override public ManagedCodingSession create(io.github.lnyocly.ai4j.coding.CodingAgent agent, CliProtocol protocol, CodeCommandOptions options) throws Exception { String sessionId = isBlank(options.getSessionId()) ? null : options.getSessionId(); CodingSession session = sessionId == null ? agent.newSession() : agent.newSession(sessionId, null); long now = System.currentTimeMillis(); ManagedCodingSession managed = new ManagedCodingSession( session, options.getProvider().getPlatform(), protocol.getValue(), options.getModel(), options.getWorkspace(), options.getWorkspaceDescription(), options.getSystemPrompt(), options.getInstructions(), session.getSessionId(), null, now, now ); appendEvent(managed.getSessionId(), baseEvent(managed.getSessionId(), SessionEventType.SESSION_CREATED, "session created", null)); return managed; } @Override public ManagedCodingSession resume(io.github.lnyocly.ai4j.coding.CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, String sessionId) throws Exception { StoredCodingSession storedSession = load(sessionId); if (storedSession == null) { throw new IllegalArgumentException("Saved session not found: " + sessionId); } assertWorkspaceMatches(storedSession, options.getWorkspace()); CodingSessionState state = storedSession.getState(); CodingSession session = agent.newSession(storedSession.getSessionId(), state); ManagedCodingSession managed = new ManagedCodingSession( session, storedSession.getProvider(), storedSession.getProtocol(), storedSession.getModel(), storedSession.getWorkspace(), storedSession.getWorkspaceDescription(), storedSession.getSystemPrompt(), storedSession.getInstructions(), normalizeRootSessionId(storedSession), storedSession.getParentSessionId(), storedSession.getCreatedAtEpochMs(), storedSession.getUpdatedAtEpochMs() ); appendEvent(managed.getSessionId(), baseEvent(managed.getSessionId(), SessionEventType.SESSION_RESUMED, "session resumed", null)); return managed; } @Override public ManagedCodingSession fork(io.github.lnyocly.ai4j.coding.CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, String sourceSessionId, String targetSessionId) throws Exception { StoredCodingSession source = load(sourceSessionId); if (source == null) { throw new IllegalArgumentException("Saved session not found: " + sourceSessionId); } assertWorkspaceMatches(source, options.getWorkspace()); String forkSessionId = isBlank(targetSessionId) ? source.getSessionId() + "-fork-" + UUID.randomUUID().toString().substring(0, 8).toLowerCase(Locale.ROOT) : targetSessionId; CodingSession session = agent.newSession(forkSessionId, source.getState()); long now = System.currentTimeMillis(); ManagedCodingSession managed = new ManagedCodingSession( session, source.getProvider(), source.getProtocol(), source.getModel(), source.getWorkspace(), source.getWorkspaceDescription(), source.getSystemPrompt(), source.getInstructions(), normalizeRootSessionId(source), source.getSessionId(), now, now ); appendEvent(managed.getSessionId(), baseEvent( managed.getSessionId(), SessionEventType.SESSION_FORKED, "session forked from " + source.getSessionId(), java.util.Collections.singletonMap("sourceSessionId", source.getSessionId()) )); return managed; } @Override public StoredCodingSession save(ManagedCodingSession managedSession) throws IOException { if (managedSession == null || managedSession.getSession() == null) { throw new IllegalArgumentException("managed session is required"); } CodingSessionSnapshot snapshot = managedSession.getSession().snapshot(); StoredCodingSession stored = sessionStore.save(StoredCodingSession.builder() .sessionId(managedSession.getSessionId()) .rootSessionId(isBlank(managedSession.getRootSessionId()) ? managedSession.getSessionId() : managedSession.getRootSessionId()) .parentSessionId(managedSession.getParentSessionId()) .provider(managedSession.getProvider()) .protocol(managedSession.getProtocol()) .model(managedSession.getModel()) .workspace(managedSession.getWorkspace()) .workspaceDescription(managedSession.getWorkspaceDescription()) .systemPrompt(managedSession.getSystemPrompt()) .instructions(managedSession.getInstructions()) .summary(snapshot.getSummary()) .memoryItemCount(snapshot.getMemoryItemCount()) .processCount(snapshot.getProcessCount()) .activeProcessCount(snapshot.getActiveProcessCount()) .restoredProcessCount(snapshot.getRestoredProcessCount()) .createdAtEpochMs(managedSession.getCreatedAtEpochMs()) .updatedAtEpochMs(System.currentTimeMillis()) .state(managedSession.getSession().exportState()) .build()); managedSession.touch(stored.getUpdatedAtEpochMs()); appendEvent(managedSession.getSessionId(), baseEvent(managedSession.getSessionId(), SessionEventType.SESSION_SAVED, "session saved", null)); return stored; } @Override public StoredCodingSession load(String sessionId) throws IOException { StoredCodingSession storedSession = sessionStore.load(sessionId); if (storedSession == null) { return null; } if (isBlank(storedSession.getRootSessionId())) { storedSession.setRootSessionId(storedSession.getSessionId()); } return storedSession; } @Override public List list() throws IOException { List sessions = sessionStore.list(); List descriptors = new ArrayList(); for (StoredCodingSession session : sessions) { descriptors.add(CodingSessionDescriptor.builder() .sessionId(session.getSessionId()) .rootSessionId(normalizeRootSessionId(session)) .parentSessionId(session.getParentSessionId()) .provider(session.getProvider()) .protocol(session.getProtocol()) .model(session.getModel()) .workspace(session.getWorkspace()) .summary(session.getSummary()) .memoryItemCount(session.getMemoryItemCount()) .processCount(session.getProcessCount()) .activeProcessCount(session.getActiveProcessCount()) .restoredProcessCount(session.getRestoredProcessCount()) .createdAtEpochMs(session.getCreatedAtEpochMs()) .updatedAtEpochMs(session.getUpdatedAtEpochMs()) .build()); } return descriptors; } @Override public void delete(String sessionId) throws IOException { if (isBlank(sessionId)) { throw new IllegalArgumentException("sessionId is required"); } Path file = sessionStore.getDirectory().resolve(sessionId.replaceAll("[^a-zA-Z0-9._-]", "_") + ".json"); java.nio.file.Files.deleteIfExists(file); if (eventStore != null) { eventStore.delete(sessionId); } } @Override public SessionEvent appendEvent(String sessionId, SessionEvent event) throws IOException { if (eventStore == null) { return event; } SessionEvent normalized = event == null ? null : event.toBuilder() .eventId(isBlank(event.getEventId()) ? UUID.randomUUID().toString() : event.getEventId()) .sessionId(isBlank(event.getSessionId()) ? sessionId : event.getSessionId()) .timestamp(event.getTimestamp() <= 0 ? System.currentTimeMillis() : event.getTimestamp()) .build(); return eventStore.append(normalized); } @Override public List listEvents(String sessionId, Integer limit, Long offset) throws IOException { if (eventStore == null) { return new ArrayList(); } return eventStore.list(sessionId, limit, offset); } @Override public Path getDirectory() { return sessionStore.getDirectory(); } public CodingSessionDescriptor describe(ManagedCodingSession session) { return session == null ? null : session.toDescriptor(); } private SessionEvent baseEvent(String sessionId, SessionEventType type, String summary, java.util.Map payload) { return SessionEvent.builder() .sessionId(sessionId) .type(type) .summary(summary) .payload(payload) .build(); } private String normalizeRootSessionId(StoredCodingSession session) { return session == null || isBlank(session.getRootSessionId()) ? (session == null ? null : session.getSessionId()) : session.getRootSessionId(); } private void assertWorkspaceMatches(StoredCodingSession storedSession, String workspace) { if (storedSession == null || isBlank(storedSession.getWorkspace()) || isBlank(workspace)) { return; } if (!storedSession.getWorkspace().equals(workspace)) { throw new IllegalArgumentException( "Saved session " + storedSession.getSessionId() + " belongs to workspace " + storedSession.getWorkspace() + ", current workspace is " + workspace ); } } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/FileCodingSessionStore.java ================================================ package io.github.lnyocly.ai4j.cli.session; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONWriter; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; public class FileCodingSessionStore implements CodingSessionStore { private final Path directory; public FileCodingSessionStore(Path directory) { this.directory = directory; } @Override public StoredCodingSession save(StoredCodingSession session) throws IOException { if (session == null || isBlank(session.getSessionId())) { throw new IllegalArgumentException("sessionId is required"); } Files.createDirectories(directory); Path file = resolveFile(session.getSessionId()); long now = System.currentTimeMillis(); StoredCodingSession existing = Files.exists(file) ? load(session.getSessionId()) : null; StoredCodingSession toPersist = session.toBuilder() .createdAtEpochMs(existing == null || existing.getCreatedAtEpochMs() <= 0 ? now : existing.getCreatedAtEpochMs()) .updatedAtEpochMs(now) .rootSessionId(firstNonBlank( session.getRootSessionId(), existing == null ? null : existing.getRootSessionId(), session.getSessionId() )) .parentSessionId(firstNonBlank( session.getParentSessionId(), existing == null ? null : existing.getParentSessionId() )) .storePath(file.toAbsolutePath().normalize().toString()) .build(); String json = JSON.toJSONString(toPersist, JSONWriter.Feature.PrettyFormat); Files.write(file, json.getBytes(StandardCharsets.UTF_8)); return toPersist; } @Override public StoredCodingSession load(String sessionId) throws IOException { if (isBlank(sessionId)) { throw new IllegalArgumentException("sessionId is required"); } Path file = resolveFile(sessionId); if (!Files.exists(file)) { return null; } StoredCodingSession session = JSON.parseObject( Files.readAllBytes(file), StoredCodingSession.class ); if (session == null) { return null; } return normalize(session, file); } @Override public List list() throws IOException { if (!Files.exists(directory)) { return Collections.emptyList(); } List sessions = new ArrayList(); try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.json")) { for (Path file : stream) { StoredCodingSession session = JSON.parseObject( Files.readAllBytes(file), StoredCodingSession.class ); if (session != null) { sessions.add(normalize(session, file)); } } } Collections.sort(sessions, Comparator.comparingLong(StoredCodingSession::getUpdatedAtEpochMs).reversed()); return sessions; } @Override public Path getDirectory() { return directory; } private Path resolveFile(String sessionId) { return directory.resolve(sanitizeSessionId(sessionId) + ".json"); } private String sanitizeSessionId(String sessionId) { return sessionId.replaceAll("[^a-zA-Z0-9._-]", "_"); } private StoredCodingSession normalize(StoredCodingSession session, Path file) { session.setStorePath(file.toAbsolutePath().normalize().toString()); if (isBlank(session.getRootSessionId())) { session.setRootSessionId(session.getSessionId()); } return session; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/FileSessionEventStore.java ================================================ package io.github.lnyocly.ai4j.cli.session; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class FileSessionEventStore implements SessionEventStore { private final Path directory; public FileSessionEventStore(Path directory) { this.directory = directory; } @Override public SessionEvent append(SessionEvent event) throws IOException { if (event == null || isBlank(event.getSessionId())) { throw new IllegalArgumentException("sessionId is required"); } Files.createDirectories(directory); Path file = resolveFile(event.getSessionId()); String line = JSON.toJSONString(event) + System.lineSeparator(); Files.write(file, line.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); return event; } @Override public List list(String sessionId, Integer limit, Long offset) throws IOException { if (isBlank(sessionId)) { throw new IllegalArgumentException("sessionId is required"); } Path file = resolveFile(sessionId); if (!Files.exists(file)) { return Collections.emptyList(); } List lines = Files.readAllLines(file, StandardCharsets.UTF_8); List events = new ArrayList(); for (String line : lines) { if (!isBlank(line)) { SessionEvent event = JSON.parseObject(line, SessionEvent.class); if (event != null) { events.add(event); } } } if (events.isEmpty()) { return events; } long safeOffset = offset == null ? -1L : Math.max(0L, offset.longValue()); int safeLimit = limit == null || limit <= 0 ? events.size() : limit.intValue(); int from; if (safeOffset >= 0L) { from = (int) Math.min(events.size(), safeOffset); } else { from = Math.max(0, events.size() - safeLimit); } int to = Math.min(events.size(), from + safeLimit); return new ArrayList(events.subList(from, to)); } @Override public void delete(String sessionId) throws IOException { if (isBlank(sessionId)) { throw new IllegalArgumentException("sessionId is required"); } Files.deleteIfExists(resolveFile(sessionId)); } @Override public Path getDirectory() { return directory; } private Path resolveFile(String sessionId) { return directory.resolve(sessionId.replaceAll("[^a-zA-Z0-9._-]", "_") + ".jsonl"); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/InMemoryCodingSessionStore.java ================================================ package io.github.lnyocly.ai4j.cli.session; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class InMemoryCodingSessionStore implements CodingSessionStore { private final Path directory; private final Map sessions = new LinkedHashMap(); public InMemoryCodingSessionStore(Path directory) { this.directory = directory; } @Override public synchronized StoredCodingSession save(StoredCodingSession session) throws IOException { if (session == null || isBlank(session.getSessionId())) { throw new IllegalArgumentException("sessionId is required"); } long now = System.currentTimeMillis(); StoredCodingSession existing = sessions.get(session.getSessionId()); StoredCodingSession stored = session.toBuilder() .createdAtEpochMs(existing == null || existing.getCreatedAtEpochMs() <= 0 ? now : existing.getCreatedAtEpochMs()) .updatedAtEpochMs(now) .rootSessionId(firstNonBlank( session.getRootSessionId(), existing == null ? null : existing.getRootSessionId(), session.getSessionId() )) .parentSessionId(firstNonBlank( session.getParentSessionId(), existing == null ? null : existing.getParentSessionId() )) .storePath("memory://" + session.getSessionId()) .build(); sessions.put(stored.getSessionId(), stored); return stored; } @Override public synchronized StoredCodingSession load(String sessionId) throws IOException { if (isBlank(sessionId)) { throw new IllegalArgumentException("sessionId is required"); } StoredCodingSession stored = sessions.get(sessionId); if (stored == null) { return null; } return normalize(stored); } @Override public synchronized List list() throws IOException { if (sessions.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(); for (StoredCodingSession session : sessions.values()) { copy.add(normalize(session)); } Collections.sort(copy, Comparator.comparingLong(StoredCodingSession::getUpdatedAtEpochMs).reversed()); return copy; } @Override public Path getDirectory() { return directory; } private StoredCodingSession normalize(StoredCodingSession session) { StoredCodingSession copy = session.toBuilder().build(); if (isBlank(copy.getRootSessionId())) { copy.setRootSessionId(copy.getSessionId()); } if (isBlank(copy.getStorePath())) { copy.setStorePath("memory://" + copy.getSessionId()); } return copy; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/InMemorySessionEventStore.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class InMemorySessionEventStore implements SessionEventStore { private final Path directory = Paths.get("(memory-events)"); private final Map> events = new LinkedHashMap>(); @Override public synchronized SessionEvent append(SessionEvent event) throws IOException { if (event == null || isBlank(event.getSessionId())) { throw new IllegalArgumentException("sessionId is required"); } List sessionEvents = events.get(event.getSessionId()); if (sessionEvents == null) { sessionEvents = new ArrayList(); events.put(event.getSessionId(), sessionEvents); } sessionEvents.add(event); return event; } @Override public synchronized List list(String sessionId, Integer limit, Long offset) throws IOException { if (isBlank(sessionId)) { throw new IllegalArgumentException("sessionId is required"); } List sessionEvents = events.get(sessionId); if (sessionEvents == null || sessionEvents.isEmpty()) { return new ArrayList(); } int safeLimit = limit == null || limit <= 0 ? sessionEvents.size() : limit.intValue(); int from; if (offset != null && offset.longValue() > 0) { from = (int) Math.min(sessionEvents.size(), offset.longValue()); } else { from = Math.max(0, sessionEvents.size() - safeLimit); } int to = Math.min(sessionEvents.size(), from + safeLimit); return new ArrayList(sessionEvents.subList(from, to)); } @Override public synchronized void delete(String sessionId) throws IOException { if (isBlank(sessionId)) { throw new IllegalArgumentException("sessionId is required"); } events.remove(sessionId); } @Override public Path getDirectory() { return directory; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/SessionEventStore.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import java.io.IOException; import java.nio.file.Path; import java.util.List; public interface SessionEventStore { SessionEvent append(SessionEvent event) throws IOException; List list(String sessionId, Integer limit, Long offset) throws IOException; void delete(String sessionId) throws IOException; Path getDirectory(); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/StoredCodingSession.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.coding.CodingSessionState; public class StoredCodingSession { private String sessionId; private String rootSessionId; private String parentSessionId; private String provider; private String protocol; private String model; private String workspace; private String workspaceDescription; private String systemPrompt; private String instructions; private String summary; private int memoryItemCount; private int processCount; private int activeProcessCount; private int restoredProcessCount; private long createdAtEpochMs; private long updatedAtEpochMs; private String storePath; private CodingSessionState state; public StoredCodingSession() { } public StoredCodingSession(String sessionId, String rootSessionId, String parentSessionId, String provider, String protocol, String model, String workspace, String workspaceDescription, String systemPrompt, String instructions, String summary, int memoryItemCount, int processCount, int activeProcessCount, int restoredProcessCount, long createdAtEpochMs, long updatedAtEpochMs, String storePath, CodingSessionState state) { this.sessionId = sessionId; this.rootSessionId = rootSessionId; this.parentSessionId = parentSessionId; this.provider = provider; this.protocol = protocol; this.model = model; this.workspace = workspace; this.workspaceDescription = workspaceDescription; this.systemPrompt = systemPrompt; this.instructions = instructions; this.summary = summary; this.memoryItemCount = memoryItemCount; this.processCount = processCount; this.activeProcessCount = activeProcessCount; this.restoredProcessCount = restoredProcessCount; this.createdAtEpochMs = createdAtEpochMs; this.updatedAtEpochMs = updatedAtEpochMs; this.storePath = storePath; this.state = state; } public static Builder builder() { return new Builder(); } public Builder toBuilder() { return new Builder() .sessionId(sessionId) .rootSessionId(rootSessionId) .parentSessionId(parentSessionId) .provider(provider) .protocol(protocol) .model(model) .workspace(workspace) .workspaceDescription(workspaceDescription) .systemPrompt(systemPrompt) .instructions(instructions) .summary(summary) .memoryItemCount(memoryItemCount) .processCount(processCount) .activeProcessCount(activeProcessCount) .restoredProcessCount(restoredProcessCount) .createdAtEpochMs(createdAtEpochMs) .updatedAtEpochMs(updatedAtEpochMs) .storePath(storePath) .state(state); } public String getSessionId() { return sessionId; } public void setSessionId(String sessionId) { this.sessionId = sessionId; } public String getRootSessionId() { return rootSessionId; } public void setRootSessionId(String rootSessionId) { this.rootSessionId = rootSessionId; } public String getParentSessionId() { return parentSessionId; } public void setParentSessionId(String parentSessionId) { this.parentSessionId = parentSessionId; } public String getProvider() { return provider; } public void setProvider(String provider) { this.provider = provider; } public String getProtocol() { return protocol; } public void setProtocol(String protocol) { this.protocol = protocol; } public String getModel() { return model; } public void setModel(String model) { this.model = model; } public String getWorkspace() { return workspace; } public void setWorkspace(String workspace) { this.workspace = workspace; } public String getWorkspaceDescription() { return workspaceDescription; } public void setWorkspaceDescription(String workspaceDescription) { this.workspaceDescription = workspaceDescription; } public String getSystemPrompt() { return systemPrompt; } public void setSystemPrompt(String systemPrompt) { this.systemPrompt = systemPrompt; } public String getInstructions() { return instructions; } public void setInstructions(String instructions) { this.instructions = instructions; } public String getSummary() { return summary; } public void setSummary(String summary) { this.summary = summary; } public int getMemoryItemCount() { return memoryItemCount; } public void setMemoryItemCount(int memoryItemCount) { this.memoryItemCount = memoryItemCount; } public int getProcessCount() { return processCount; } public void setProcessCount(int processCount) { this.processCount = processCount; } public int getActiveProcessCount() { return activeProcessCount; } public void setActiveProcessCount(int activeProcessCount) { this.activeProcessCount = activeProcessCount; } public int getRestoredProcessCount() { return restoredProcessCount; } public void setRestoredProcessCount(int restoredProcessCount) { this.restoredProcessCount = restoredProcessCount; } public long getCreatedAtEpochMs() { return createdAtEpochMs; } public void setCreatedAtEpochMs(long createdAtEpochMs) { this.createdAtEpochMs = createdAtEpochMs; } public long getUpdatedAtEpochMs() { return updatedAtEpochMs; } public void setUpdatedAtEpochMs(long updatedAtEpochMs) { this.updatedAtEpochMs = updatedAtEpochMs; } public String getStorePath() { return storePath; } public void setStorePath(String storePath) { this.storePath = storePath; } public CodingSessionState getState() { return state; } public void setState(CodingSessionState state) { this.state = state; } public static final class Builder { private String sessionId; private String rootSessionId; private String parentSessionId; private String provider; private String protocol; private String model; private String workspace; private String workspaceDescription; private String systemPrompt; private String instructions; private String summary; private int memoryItemCount; private int processCount; private int activeProcessCount; private int restoredProcessCount; private long createdAtEpochMs; private long updatedAtEpochMs; private String storePath; private CodingSessionState state; private Builder() { } public Builder sessionId(String sessionId) { this.sessionId = sessionId; return this; } public Builder rootSessionId(String rootSessionId) { this.rootSessionId = rootSessionId; return this; } public Builder parentSessionId(String parentSessionId) { this.parentSessionId = parentSessionId; return this; } public Builder provider(String provider) { this.provider = provider; return this; } public Builder protocol(String protocol) { this.protocol = protocol; return this; } public Builder model(String model) { this.model = model; return this; } public Builder workspace(String workspace) { this.workspace = workspace; return this; } public Builder workspaceDescription(String workspaceDescription) { this.workspaceDescription = workspaceDescription; return this; } public Builder systemPrompt(String systemPrompt) { this.systemPrompt = systemPrompt; return this; } public Builder instructions(String instructions) { this.instructions = instructions; return this; } public Builder summary(String summary) { this.summary = summary; return this; } public Builder memoryItemCount(int memoryItemCount) { this.memoryItemCount = memoryItemCount; return this; } public Builder processCount(int processCount) { this.processCount = processCount; return this; } public Builder activeProcessCount(int activeProcessCount) { this.activeProcessCount = activeProcessCount; return this; } public Builder restoredProcessCount(int restoredProcessCount) { this.restoredProcessCount = restoredProcessCount; return this; } public Builder createdAtEpochMs(long createdAtEpochMs) { this.createdAtEpochMs = createdAtEpochMs; return this; } public Builder updatedAtEpochMs(long updatedAtEpochMs) { this.updatedAtEpochMs = updatedAtEpochMs; return this; } public Builder storePath(String storePath) { this.storePath = storePath; return this; } public Builder state(CodingSessionState state) { this.state = state; return this; } public StoredCodingSession build() { return new StoredCodingSession( sessionId, rootSessionId, parentSessionId, provider, protocol, model, workspace, workspaceDescription, systemPrompt, instructions, summary, memoryItemCount, processCount, activeProcessCount, restoredProcessCount, createdAtEpochMs, updatedAtEpochMs, storePath, state ); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/JlineCodeCommandRunner.java ================================================ package io.github.lnyocly.ai4j.cli.shell; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.SlashCommandController; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.runtime.CodingCliSessionRunner; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiInteractionState; public final class JlineCodeCommandRunner { private final CodingAgent agent; private final CliProtocol protocol; private final CliMcpRuntimeManager mcpRuntimeManager; private final CodeCommandOptions options; private final TerminalIO terminal; private final CodingSessionManager sessionManager; private final TuiInteractionState interactionState; private final JlineShellContext shellContext; private final SlashCommandController slashCommandController; private final CodingCliAgentFactory agentFactory; private final java.util.Map env; private final java.util.Properties properties; public JlineCodeCommandRunner(CodingAgent agent, CliProtocol protocol, CliMcpRuntimeManager mcpRuntimeManager, CodeCommandOptions options, TerminalIO terminal, CodingSessionManager sessionManager, TuiInteractionState interactionState, JlineShellContext shellContext, SlashCommandController slashCommandController, CodingCliAgentFactory agentFactory, java.util.Map env, java.util.Properties properties) { this.agent = agent; this.protocol = protocol; this.mcpRuntimeManager = mcpRuntimeManager; this.options = options; this.terminal = terminal; this.sessionManager = sessionManager; this.interactionState = interactionState; this.shellContext = shellContext; this.slashCommandController = slashCommandController; this.agentFactory = agentFactory; this.env = env; this.properties = properties; } public int runCommand() throws Exception { try { if (slashCommandController != null) { slashCommandController.setSessionManager(sessionManager); } if (terminal instanceof JlineShellTerminalIO) { ((JlineShellTerminalIO) terminal).updateSessionContext(null, options.getModel(), options.getWorkspace()); ((JlineShellTerminalIO) terminal).showIdle("Enter a prompt or /command"); } CodingCliSessionRunner runner = new CodingCliSessionRunner( agent, protocol, options, terminal, sessionManager, interactionState, null, slashCommandController, agentFactory, env, properties ); runner.setMcpRuntimeManager(mcpRuntimeManager); return runner.run(); } finally { if (terminal != null) { terminal.close(); } if (shellContext != null) { shellContext.close(); } } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/JlineShellContext.java ================================================ package io.github.lnyocly.ai4j.cli.shell; import io.github.lnyocly.ai4j.cli.SlashCommandController; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; import org.jline.reader.impl.DefaultParser; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; import org.jline.utils.Status; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public final class JlineShellContext implements AutoCloseable { private final Terminal terminal; private final LineReader lineReader; private final Status status; private JlineShellContext(Terminal terminal, LineReader lineReader, Status status) { this.terminal = terminal; this.lineReader = lineReader; this.status = status; } public static JlineShellContext openSystem(SlashCommandController slashCommandController) throws IOException { TerminalBuilder builder = TerminalBuilder.builder() .system(true) .encoding(resolveCharset()) .nativeSignals(true); if (isWindows()) { builder.provider("jni"); } Terminal terminal = builder.build(); DefaultParser parser = new DefaultParser(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli") .parser(parser) .completer(slashCommandController) .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true) .build(); if (slashCommandController != null) { slashCommandController.configure(lineReader); } Status status = Status.getStatus(terminal, false); if (status != null) { status.setBorder(false); } return new JlineShellContext(terminal, lineReader, status); } Terminal terminal() { return terminal; } LineReader lineReader() { return lineReader; } Status status() { return status; } @Override public void close() throws IOException { if (status != null) { status.close(); } if (terminal != null) { terminal.close(); } } private static Charset resolveCharset() { String[] candidates = new String[]{ System.getProperty("stdin.encoding"), System.getProperty("sun.stdin.encoding"), System.getProperty("stdout.encoding"), System.getProperty("sun.stdout.encoding"), System.getProperty("native.encoding"), System.getProperty("sun.jnu.encoding"), System.getProperty("file.encoding") }; for (String candidate : candidates) { if (candidate == null || candidate.trim().isEmpty()) { continue; } try { return Charset.forName(candidate.trim()); } catch (Exception ignored) { } } return StandardCharsets.UTF_8; } private static boolean isWindows() { String osName = System.getProperty("os.name", ""); return osName.toLowerCase().contains("win"); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/JlineShellTerminalIO.java ================================================ package io.github.lnyocly.ai4j.cli.shell; import io.github.lnyocly.ai4j.cli.SlashCommandController; import io.github.lnyocly.ai4j.cli.render.AssistantTranscriptRenderer; import io.github.lnyocly.ai4j.cli.render.CliDisplayWidth; import io.github.lnyocly.ai4j.cli.render.CliThemeStyler; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiTheme; import org.jline.reader.EndOfFileException; import org.jline.reader.LineReader; import org.jline.reader.UserInterruptException; import org.jline.terminal.Attributes; import org.jline.terminal.Terminal; import org.jline.utils.AttributedString; import org.jline.utils.NonBlockingReader; import org.jline.utils.Status; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; public final class JlineShellTerminalIO implements TerminalIO { private static final String[] SPINNER = new String[]{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}; private static final long DEFAULT_STATUS_TICK_MS = 120L; private static final long DEFAULT_WAITING_THRESHOLD_MS = 8000L; private static final long DEFAULT_STALLED_THRESHOLD_MS = 30000L; private final JlineShellContext shellContext; private final SlashCommandController slashCommandController; private final Object statusLock = new Object(); private final Object outputLock = new Object(); private final Object interruptLock = new Object(); private final Thread spinnerThread; private final CliThemeStyler themeStyler; private final WindowsConsoleKeyPoller windowsConsoleKeyPoller = new WindowsConsoleKeyPoller(); private final AssistantTranscriptRenderer assistantTranscriptRenderer = new AssistantTranscriptRenderer(); private final CliThemeStyler.TranscriptStyleState transcriptStyleState = new CliThemeStyler.TranscriptStyleState(); private final boolean statusComponentEnabled; private final boolean statusAnimationEnabled; private final long statusTickMs; private final long waitingThresholdMs; private final long stalledThresholdMs; private boolean inputClosed; private volatile boolean closed; private String statusLabel = "Idle"; private String statusDetail = "Type /help for commands"; private String sessionId = "(new)"; private String model = "(unknown)"; private String workspace = "."; private String hint = "Enter a prompt or /command"; private boolean spinnerActive; private int spinnerIndex; private int outputColumn; private boolean trackingAssistantBlock; private int trackedAssistantRows; private int trackedInlineStatusRows; private String lastRenderedStatusLine; private boolean forceDirectOutput; private Thread turnInterruptThread; private boolean turnInterruptRunning; private Attributes turnInterruptPollingAttributes; private boolean turnInterruptPollingRawMode; private BusyState busyState = BusyState.IDLE; private long lastBusyProgressAtNanos; public JlineShellTerminalIO(JlineShellContext shellContext, SlashCommandController slashCommandController) { this.shellContext = shellContext; this.slashCommandController = slashCommandController; this.themeStyler = new CliThemeStyler(new TuiTheme(), supportsAnsi()); this.statusComponentEnabled = resolveStatusComponentEnabled(); this.statusAnimationEnabled = statusComponentEnabled && !isJetBrainsTerminal(); this.statusTickMs = Math.max(20L, resolveDurationProperty("ai4j.jline.status-tick-ms", DEFAULT_STATUS_TICK_MS)); this.waitingThresholdMs = Math.max(1L, resolveDurationProperty("ai4j.jline.waiting-ms", DEFAULT_WAITING_THRESHOLD_MS)); this.stalledThresholdMs = Math.max(this.waitingThresholdMs + 1L, resolveDurationProperty("ai4j.jline.stalled-ms", DEFAULT_STALLED_THRESHOLD_MS)); if (this.slashCommandController != null) { this.slashCommandController.setStatusRefresh(new Runnable() { @Override public void run() { redrawStatus(); } }); } if (statusComponentEnabled) { this.spinnerThread = new Thread(new Runnable() { @Override public void run() { runSpinnerLoop(); } }, "ai4j-jline-status-spinner"); this.spinnerThread.setDaemon(true); this.spinnerThread.start(); } else { this.spinnerThread = null; } } @Override public String readLine(String prompt) throws IOException { try { redrawStatus(); return lineReader().readLine(prompt == null ? "" : prompt); } catch (EndOfFileException ex) { inputClosed = true; return null; } catch (UserInterruptException ex) { inputClosed = true; return null; } finally { redrawStatus(); } } @Override public void print(String message) { writeOutput(message, false); } @Override public void println(String message) { writeOutput(message, true); } @Override public void errorln(String message) { writeOutput(message, true); } @Override public boolean supportsAnsi() { return terminal() != null && !"dumb".equalsIgnoreCase(terminal().getType()); } @Override public boolean supportsRawInput() { return false; } @Override public boolean isInputClosed() { return inputClosed; } @Override public int getTerminalRows() { return terminal() == null ? 0 : Math.max(0, terminal().getHeight()); } @Override public int getTerminalColumns() { return terminal() == null ? 0 : Math.max(0, terminal().getWidth()); } public void updateSessionContext(String sessionId, String model, String workspace) { boolean changed = false; synchronized (statusLock) { if (!isBlank(sessionId)) { changed = changed || !sameText(this.sessionId, sessionId); this.sessionId = sessionId; } if (!isBlank(model)) { changed = changed || !sameText(this.model, model); this.model = model; } if (!isBlank(workspace)) { changed = changed || !sameText(this.workspace, workspace); this.workspace = workspace; } } if (changed) { redrawStatus(); } } public void updateTheme(TuiTheme theme) { themeStyler.updateTheme(theme); synchronized (statusLock) { lastRenderedStatusLine = null; } redrawStatus(); } public void showIdle(String hint) { boolean changed; synchronized (statusLock) { changed = !sameText(statusLabel, "Idle") || !sameText(statusDetail, "Ready") || !sameText(this.hint, firstNonBlank(hint, "Enter a prompt or /command")) || spinnerActive; statusLabel = "Idle"; statusDetail = "Ready"; this.hint = firstNonBlank(hint, "Enter a prompt or /command"); spinnerActive = false; busyState = BusyState.IDLE; lastBusyProgressAtNanos = 0L; } if (changed) { redrawStatus(); } } public void beginTurn(String input) { transcriptStyleState.reset(); showBusyState( BusyState.THINKING, "Thinking", isBlank(input) ? "Analyzing workspace and tool context" : "Analyzing: " + clip(input, 72) ); } public void beginTurnInterruptWatch(Runnable interruptHandler) { synchronized (interruptLock) { stopTurnInterruptWatchLocked(false); if (interruptHandler == null || closed || terminal() == null) { return; } turnInterruptRunning = true; Thread thread = new Thread(new Runnable() { @Override public void run() { watchForTurnInterrupt(interruptHandler); } }, "ai4j-jline-turn-interrupt"); thread.setDaemon(true); turnInterruptThread = thread; thread.start(); } } public void beginTurnInterruptPolling() { synchronized (interruptLock) { stopTurnInterruptWatchLocked(true); stopTurnInterruptPollingLocked(); if (closed || terminal() == null) { return; } turnInterruptRunning = true; try { turnInterruptPollingAttributes = terminal().enterRawMode(); turnInterruptPollingRawMode = true; windowsConsoleKeyPoller.resetEscapeState(); } catch (Exception ignored) { turnInterruptPollingAttributes = null; turnInterruptPollingRawMode = false; turnInterruptRunning = false; } } } public boolean pollTurnInterrupt(long timeoutMs) { synchronized (interruptLock) { if (!turnInterruptRunning || closed || terminal() == null) { return false; } try { int next = readTurnInterruptChar(terminal(), timeoutMs <= 0L ? 120L : timeoutMs); if (next == 27) { return true; } return windowsConsoleKeyPoller.pollEscapePressed(); } catch (Exception ignored) { return windowsConsoleKeyPoller.pollEscapePressed(); } } } public void showThinking() { showBusyState(BusyState.THINKING, "Thinking", "Analyzing workspace and tool context"); } public void showConnecting(String detail) { showBusyState(BusyState.CONNECTING, "Connecting", firstNonBlank(detail, "Opening model stream")); } public void showResponding() { showBusyState(BusyState.RESPONDING, "Responding", "Streaming model output"); } public void showWorking(String detail) { showBusyState(BusyState.WORKING, "Working", firstNonBlank(detail, "Running tool")); } public void showRetrying(String detail, int attempt, int maxAttempts) { String suffix = maxAttempts > 0 ? " (" + Math.max(1, attempt) + "/" + maxAttempts + ")" : ""; showBusyState(BusyState.RETRYING, "Retrying", firstNonBlank(detail, "Retrying request") + suffix); } public void clearTransient() { transcriptStyleState.reset(); showIdle("Enter a prompt or /command"); } public void finishTurn() { endTurnInterruptWatch(); transcriptStyleState.reset(); showIdle("Enter a prompt or /command"); } public void endTurnInterruptWatch() { synchronized (interruptLock) { stopTurnInterruptWatchLocked(true); } } public void endTurnInterruptPolling() { synchronized (interruptLock) { stopTurnInterruptPollingLocked(); } } public void printAssistantFragment(String message) { writeStyledOutput(themeStyler.styleAssistantFragment(message == null ? "" : message), false); } public void printReasoningFragment(String message) { writeStyledOutput(themeStyler.styleReasoningFragment(message == null ? "" : message), false); } public void printTranscriptLine(String line, boolean newline) { writeOutput(line == null ? "" : line, newline, true); } public void printTranscriptBlock(List lines) { if (lines == null || lines.isEmpty()) { return; } StringBuilder builder = new StringBuilder(); for (int index = 0; index < lines.size(); index++) { if (index > 0) { builder.append('\n'); } builder.append(lines.get(index) == null ? "" : lines.get(index)); } writeOutput(builder.toString(), true, false); } public void printAssistantMarkdownBlock(String markdown) { String styled = assistantTranscriptRenderer.styleBlock(markdown, themeStyler); if (styled == null || styled.isEmpty()) { return; } writeStyledOutput(styled, true, true); } public void enterTranscriptCodeBlock(String language) { transcriptStyleState.enterCodeBlock(language); } public void exitTranscriptCodeBlock() { transcriptStyleState.exitCodeBlock(); } public void beginAssistantBlockTracking() { synchronized (outputLock) { trackingAssistantBlock = true; trackedAssistantRows = 0; } } public void beginDirectOutputWindow() { synchronized (outputLock) { forceDirectOutput = true; } } public void endDirectOutputWindow() { synchronized (outputLock) { forceDirectOutput = false; } } public int assistantBlockRows() { synchronized (outputLock) { return trackedAssistantRows; } } public void clearAssistantBlock() { synchronized (outputLock) { clearAssistantBlockDirect(trackedAssistantRows, lineReader() != null && lineReader().isReading()); trackedAssistantRows = 0; trackingAssistantBlock = false; transcriptStyleState.reset(); outputColumn = 0; } } public void forgetAssistantBlock() { synchronized (outputLock) { trackedAssistantRows = 0; trackingAssistantBlock = false; transcriptStyleState.reset(); } } public boolean rewriteAssistantBlock(int previousRows, String replacementMarkdown) { String replacement = assistantTranscriptRenderer.styleBlock(replacementMarkdown, themeStyler); if (previousRows <= 0 || isBlank(replacement)) { return false; } synchronized (outputLock) { return rewriteAssistantBlockDirect(previousRows, replacement, lineReader() != null && lineReader().isReading()); } } @Override public void close() { closed = true; endTurnInterruptWatch(); if (spinnerThread != null) { spinnerThread.interrupt(); } } private LineReader lineReader() { return shellContext.lineReader(); } private Terminal terminal() { return shellContext.terminal(); } private Status status() { return shellContext.status(); } private void runSpinnerLoop() { while (!closed) { try { Thread.sleep(statusTickMs); } catch (InterruptedException ex) { if (closed) { return; } Thread.currentThread().interrupt(); return; } boolean shouldRedraw = false; synchronized (statusLock) { if (spinnerActive) { if (statusAnimationEnabled) { spinnerIndex = (spinnerIndex + 1) % SPINNER.length; } shouldRedraw = true; } } if (shouldRedraw) { redrawStatus(); } } } private void watchForTurnInterrupt(Runnable interruptHandler) { Terminal terminal = terminal(); if (terminal == null) { return; } Attributes previous = null; boolean rawMode = false; try { previous = terminal.enterRawMode(); rawMode = true; while (isTurnInterruptRunning()) { int next = readTurnInterruptChar(terminal); if (next == NonBlockingReader.READ_EXPIRED || next < 0) { continue; } if (next == 27) { interruptHandler.run(); return; } } } catch (Exception ignored) { } finally { if (rawMode && previous != null) { try { terminal.setAttributes(previous); } catch (Exception ignored) { } } } } private int readTurnInterruptChar(Terminal terminal) throws IOException { return readTurnInterruptChar(terminal, 120L); } private int readTurnInterruptChar(Terminal terminal, long timeoutMs) throws IOException { if (terminal == null || terminal.reader() == null) { return -1; } if (terminal.reader() instanceof NonBlockingReader) { return ((NonBlockingReader) terminal.reader()).read(timeoutMs); } return terminal.reader().read(); } private boolean isTurnInterruptRunning() { synchronized (interruptLock) { return turnInterruptRunning && !closed; } } private void stopTurnInterruptWatchLocked(boolean join) { turnInterruptRunning = false; Thread thread = turnInterruptThread; turnInterruptThread = null; if (thread == null) { return; } thread.interrupt(); if (join && thread != Thread.currentThread()) { try { thread.join(100L); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } } private void stopTurnInterruptPollingLocked() { turnInterruptRunning = false; if (turnInterruptPollingRawMode && turnInterruptPollingAttributes != null && terminal() != null) { try { terminal().setAttributes(turnInterruptPollingAttributes); } catch (Exception ignored) { } } turnInterruptPollingAttributes = null; turnInterruptPollingRawMode = false; } private void showBusyState(BusyState nextBusyState, String label, String detail) { boolean changed; synchronized (statusLock) { String nextLabel = firstNonBlank(label, "Working"); String nextDetail = firstNonBlank(detail, "Working"); changed = !sameText(statusLabel, nextLabel) || !sameText(statusDetail, nextDetail) || !sameText(hint, "Esc to interrupt the current task") || !spinnerActive; statusLabel = nextLabel; statusDetail = nextDetail; hint = "Esc to interrupt the current task"; spinnerActive = true; busyState = nextBusyState == null ? BusyState.WORKING : nextBusyState; lastBusyProgressAtNanos = System.nanoTime(); spinnerIndex = 0; } if (changed) { redrawStatus(); } } private void redrawStatus() { synchronized (outputLock) { if (closed) { return; } List lines; String statusPayload; synchronized (statusLock) { lines = buildStatusLines(); statusPayload = joinStatusPayload(lines); if (lines.isEmpty() && isBlank(lastRenderedStatusLine)) { return; } if (sameText(lastRenderedStatusLine, statusPayload)) { return; } } Status status = status(); if (status == null) { redrawInlineStatus(statusPayload); return; } try { status.update(lines); synchronized (statusLock) { lastRenderedStatusLine = statusPayload; } Terminal terminal = terminal(); if (terminal != null) { terminal.flush(); } } catch (Exception ignored) { } } } private void redrawInlineStatus(String statusPayload) { LineReader lineReader = lineReader(); boolean reading = lineReader != null && lineReader.isReading(); String nextPayload = statusPayload == null ? "" : statusPayload; String previousPayload; synchronized (statusLock) { previousPayload = lastRenderedStatusLine; lastRenderedStatusLine = nextPayload; } if (isBlank(nextPayload)) { if (trackedInlineStatusRows > 0) { if (clearAssistantBlockDirect(trackedInlineStatusRows, reading)) { trackedInlineStatusRows = 0; } else { synchronized (statusLock) { lastRenderedStatusLine = previousPayload; } } } return; } if (!reading) { synchronized (statusLock) { lastRenderedStatusLine = previousPayload; } return; } if (trackedInlineStatusRows > 0 && !clearAssistantBlockDirect(trackedInlineStatusRows, true)) { synchronized (statusLock) { lastRenderedStatusLine = previousPayload; } return; } try { String rendered = normalizeReadingPrintAboveMessage(nextPayload); lineReader.printAbove(rendered); trackedInlineStatusRows = countWrappedRows(rendered, 0); } catch (Exception ignored) { synchronized (statusLock) { lastRenderedStatusLine = previousPayload; } } } private String buildPrimaryStatusLine() { return themeStyler.buildPrimaryStatusLine( statusLabel, spinnerActive, spinnerActive ? SPINNER[Math.floorMod(spinnerIndex, SPINNER.length)] : null, statusDetail ); } private List buildStatusLines() { SlashCommandController.PaletteSnapshot paletteSnapshot = slashCommandController == null ? SlashCommandController.PaletteSnapshot.closed() : slashCommandController.getPaletteSnapshot(); List ansiLines = new ArrayList(); if (statusComponentEnabled) { ansiLines.add(buildStatusLine()); } if (paletteSnapshot.isOpen()) { ansiLines.addAll(buildSlashPaletteLines(paletteSnapshot)); } if (ansiLines.isEmpty()) { return Collections.emptyList(); } List lines = new ArrayList(ansiLines.size()); for (String ansiLine : ansiLines) { lines.add(AttributedString.fromAnsi(ansiLine, terminal())); } return lines; } private List buildSlashPaletteLines(SlashCommandController.PaletteSnapshot snapshot) { if (snapshot == null || !snapshot.isOpen()) { return Collections.emptyList(); } List lines = new ArrayList(); List items = snapshot.getItems(); String query = firstNonBlank(snapshot.getQuery(), "/"); String matchSummary = items.isEmpty() ? "no matches" : items.size() + " matches"; lines.add( themeStyler.styleMutedFragment("commands") + " " + themeStyler.styleAssistantFragment(query) + " " + themeStyler.styleMutedFragment(matchSummary + " ↑↓ move Tab apply Enter run") ); if (items.isEmpty()) { lines.add(themeStyler.styleReasoningFragment(" No matching slash commands")); return lines; } int maxItems = 6; int selectedIndex = snapshot.getSelectedIndex(); int start = Math.max(0, selectedIndex - (maxItems / 2)); if (start + maxItems > items.size()) { start = Math.max(0, items.size() - maxItems); } int end = Math.min(items.size(), start + maxItems); for (int index = start; index < end; index++) { SlashCommandController.PaletteItemSnapshot item = items.get(index); boolean selected = index == selectedIndex; String prefix = selected ? themeStyler.styleAssistantFragment("›") : themeStyler.styleMutedFragment(" "); String command = selected ? themeStyler.styleAssistantFragment(item.getDisplay()) : themeStyler.styleMutedFragment(item.getDisplay()); String description = item.getDescription(); if (isBlank(description)) { lines.add(prefix + " " + command); } else { lines.add(prefix + " " + command + " " + themeStyler.styleReasoningFragment(clip(description, 72))); } } return lines; } private String buildStatusLine() { BusySnapshot snapshot = snapshotBusyState(); return themeStyler.buildCompactStatusLine( snapshot.label, snapshot.spinnerActive, snapshot.spinnerActive ? SPINNER[Math.floorMod(spinnerIndex, SPINNER.length)] : null, clip(snapshot.detail, 64), clip(firstNonBlank(model, "(unknown)"), 20), clip(lastPathSegment(firstNonBlank(workspace, ".")), 24), clip(firstNonBlank(hint, "Enter a prompt or /command"), 40) ); } public String currentStatusLine() { return buildStatusLine(); } private BusySnapshot snapshotBusyState() { synchronized (statusLock) { return snapshotBusyStateLocked(); } } private BusySnapshot snapshotBusyStateLocked() { String label = statusLabel; String detail = statusDetail; boolean active = spinnerActive; if (!active || busyState == BusyState.IDLE || lastBusyProgressAtNanos <= 0L) { return new BusySnapshot(label, detail, active); } long idleMs = Math.max(0L, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastBusyProgressAtNanos)); long idleSeconds = Math.max(1L, idleMs / 1000L); if (idleMs >= stalledThresholdMs) { return new BusySnapshot("Stalled", stalledDetailFor(busyState, detail, idleSeconds), true); } if (idleMs >= waitingThresholdMs) { return new BusySnapshot(waitingLabelFor(busyState, label), waitingDetailFor(busyState, detail, idleSeconds), true); } return new BusySnapshot(label, detail, active); } private String waitingLabelFor(BusyState state, String fallback) { if (state == BusyState.THINKING || state == BusyState.RESPONDING) { return "Waiting"; } return firstNonBlank(fallback, "Working"); } private String waitingDetailFor(BusyState state, String detail, long idleSeconds) { switch (state) { case CONNECTING: return firstNonBlank(detail, "Opening model stream") + " (" + idleSeconds + "s)"; case THINKING: case RESPONDING: return "No new model output for " + idleSeconds + "s"; case RETRYING: return firstNonBlank(detail, "Retrying request") + " (" + idleSeconds + "s)"; case WORKING: return firstNonBlank(detail, "Running tool") + " still running (" + idleSeconds + "s)"; default: return firstNonBlank(detail, "Working") + " (" + idleSeconds + "s)"; } } private String stalledDetailFor(BusyState state, String detail, long idleSeconds) { switch (state) { case CONNECTING: return "No response from model stream for " + idleSeconds + "s - press Esc to interrupt"; case THINKING: case RESPONDING: return "No new model output for " + idleSeconds + "s - press Esc to interrupt"; case RETRYING: return firstNonBlank(detail, "Retrying request") + " appears stuck - press Esc to interrupt"; case WORKING: return firstNonBlank(detail, "Running tool") + " still running after " + idleSeconds + "s - press Esc to interrupt"; default: return firstNonBlank(detail, "Current task") + " appears stuck - press Esc to interrupt"; } } private String buildSessionLine() { return themeStyler.buildSessionLine( clip(firstNonBlank(sessionId, "(new)"), 18), clip(firstNonBlank(model, "(unknown)"), 24), clip(lastPathSegment(firstNonBlank(workspace, ".")), 24) ); } private String buildHintLine() { return themeStyler.buildHintLine(firstNonBlank(hint, "Enter a prompt or /command")); } private String joinStatusPayload(List lines) { if (lines == null || lines.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (int i = 0; i < lines.size(); i++) { if (i > 0) { builder.append('\n'); } builder.append(lines.get(i).toAnsi(terminal())); } return builder.toString(); } private String stylePrintedMessage(String message) { if (message == null || message.isEmpty()) { return message; } String[] rawLines = message.replace("\r", "").split("\n", -1); StringBuilder builder = new StringBuilder(); for (int i = 0; i < rawLines.length; i++) { if (i > 0) { builder.append('\n'); } builder.append(themeStyler.styleTranscriptLine(rawLines[i], transcriptStyleState)); } return builder.toString(); } private void writeOutput(String message, boolean newline) { writeOutput(message, newline, false); } private void writeOutput(String message, boolean newline, boolean trackAssistantBlock) { String value = message == null ? "" : message; if (!newline && value.isEmpty()) { return; } writeStyledOutput(stylePrintedMessage(value), newline, trackAssistantBlock); } private void writeStyledOutput(String message, boolean newline) { writeStyledOutput(message, newline, false); } private void writeStyledOutput(String message, boolean newline, boolean trackAssistantBlock) { synchronized (outputLock) { LineReader lineReader = lineReader(); boolean reading = !forceDirectOutput && lineReader != null && lineReader.isReading(); if (trackAssistantBlock && trackingAssistantBlock) { trackedAssistantRows += countOutputRows(message, newline, reading, outputColumn); } if (reading) { String readingMessage = normalizeReadingPrintAboveMessage(message == null ? "" : message); CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi( readingMessage, getTerminalColumns(), newline ? 0 : outputColumn ); String wrappedText = wrapped.text(); if (!wrappedText.isEmpty()) { lineReader.printAbove(wrappedText); outputColumn = newline ? 0 : wrapped.endColumn(); } else if (newline) { outputColumn = 0; } redrawStatus(); return; } Status status = status(); boolean suspended = false; try { if (status != null) { status.suspend(); suspended = true; } Terminal terminal = terminal(); if (terminal != null) { CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi( message == null ? "" : message, getTerminalColumns(), outputColumn ); terminal.writer().print(wrapped.text()); outputColumn = wrapped.endColumn(); if (newline) { terminal.writer().println(); outputColumn = 0; } terminal.writer().flush(); terminal.flush(); } } catch (Exception ignored) { } finally { if (suspended) { try { status.restore(); } catch (Exception ignored) { } } redrawStatus(); } } } private boolean rewriteAssistantBlockDirect(int previousRows, String replacement, boolean reading) { Terminal terminal = terminal(); if (terminal == null) { return false; } Status status = status(); boolean suspended = false; try { if (status != null) { status.suspend(); suspended = true; } StringBuilder builder = new StringBuilder(); builder.append('\r'); if (reading) { // Clear the prompt line first so the replacement can be redrawn // independently from JLine's input buffer redisplay. builder.append("\u001b[2K"); } for (int row = 0; row < previousRows; row++) { builder.append("\u001b[1A"); builder.append('\r'); builder.append("\u001b[2K"); } builder.append('\r'); builder.append(replacement); builder.append('\n'); terminal.writer().print(builder.toString()); terminal.writer().flush(); terminal.flush(); if (reading) { redrawInputLine(); } if (trackingAssistantBlock) { trackedAssistantRows = countWrappedRows(replacement, 0); } outputColumn = 0; return true; } catch (Exception ignored) { return false; } finally { if (suspended) { try { status.restore(); } catch (Exception ignored) { } } redrawStatus(); } } private boolean clearAssistantBlockDirect(int previousRows, boolean reading) { if (previousRows <= 0) { return false; } Terminal terminal = terminal(); if (terminal == null) { return false; } Status status = status(); boolean suspended = false; try { if (status != null) { status.suspend(); suspended = true; } StringBuilder builder = new StringBuilder(); builder.append('\r'); if (reading) { builder.append("\u001b[2K"); } for (int row = 0; row < previousRows; row++) { builder.append("\u001b[1A"); builder.append('\r'); builder.append("\u001b[2K"); } terminal.writer().print(builder.toString()); terminal.writer().flush(); terminal.flush(); if (reading) { redrawInputLine(); } return true; } catch (Exception ignored) { return false; } finally { if (suspended) { try { status.restore(); } catch (Exception ignored) { } } redrawStatus(); } } private void redrawInputLine() { LineReader lineReader = lineReader(); if (lineReader == null) { return; } try { lineReader.callWidget(LineReader.REDRAW_LINE); } catch (Exception ignored) { } try { lineReader.callWidget(LineReader.REDISPLAY); } catch (Exception ignored) { } } private int countOutputRows(String message, boolean newline, boolean reading, int startColumn) { String safe = message == null ? "" : message; if (reading) { if (safe.isEmpty()) { return newline ? 1 : 0; } return countWrappedRows(safe, 0); } if (safe.isEmpty()) { if (!newline) { return 0; } return startColumn == 0 ? 1 : 0; } return countWrappedRows(safe, startColumn); } private int countWrappedRows(String message, int startColumn) { String safe = message == null ? "" : message; if (safe.isEmpty()) { return 0; } String wrapped = CliDisplayWidth.wrapAnsi(safe, getTerminalColumns(), startColumn).text(); if (wrapped.isEmpty()) { return 0; } return Math.max(1, wrapped.split("\n", -1).length); } private String normalizeReadingPrintAboveMessage(String message) { String safe = message == null ? "" : message.replace("\r", ""); if (safe.isEmpty()) { return " "; } String[] rawLines = safe.split("\n", -1); StringBuilder builder = new StringBuilder(); for (int index = 0; index < rawLines.length; index++) { if (index > 0) { builder.append('\n'); } String rawLine = rawLines[index]; builder.append(rawLine.isEmpty() ? " " : rawLine); } return builder.toString(); } private String lastPathSegment(String value) { if (isBlank(value)) { return "."; } String normalized = value.replace('\\', '/'); int index = normalized.lastIndexOf('/'); return index >= 0 && index + 1 < normalized.length() ? normalized.substring(index + 1) : normalized; } private String clip(String value, int maxChars) { return CliDisplayWidth.clip(value, maxChars); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private boolean sameText(String left, String right) { if (left == null) { return right == null; } return left.equals(right); } private long resolveDurationProperty(String key, long fallback) { String value = System.getProperty(key); if (isBlank(value)) { return fallback; } try { long parsed = Long.parseLong(value.trim()); return parsed > 0L ? parsed : fallback; } catch (NumberFormatException ignored) { return fallback; } } private enum BusyState { IDLE, THINKING, CONNECTING, RESPONDING, WORKING, RETRYING } private static final class BusySnapshot { private final String label; private final String detail; private final boolean spinnerActive; private BusySnapshot(String label, String detail, boolean spinnerActive) { this.label = label; this.detail = detail; this.spinnerActive = spinnerActive; } } private boolean isJetBrainsTerminal() { String[] candidates = new String[]{ System.getenv("TERMINAL_EMULATOR"), System.getenv("TERM_PROGRAM"), System.getProperty("terminal.emulator"), terminal() == null ? null : terminal().getName(), terminal() == null ? null : terminal().getType() }; for (String candidate : candidates) { if (candidate == null) { continue; } String normalized = candidate.toLowerCase(); if (normalized.contains("jetbrains") || normalized.contains("jediterm")) { return true; } } return false; } private boolean resolveStatusComponentEnabled() { String property = System.getProperty("ai4j.jline.status"); if (!isBlank(property)) { return "true".equalsIgnoreCase(property.trim()); } String environment = System.getenv("AI4J_JLINE_STATUS"); if (!isBlank(environment)) { return "true".equalsIgnoreCase(environment.trim()); } // Disable the JLine footer status by default. In JetBrains terminals it // can drift the scroll region and create the blank streaming area the // user is seeing; callers can explicitly re-enable it when needed. return false; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/WindowsConsoleKeyPoller.java ================================================ package io.github.lnyocly.ai4j.cli.shell; import com.sun.jna.Library; import com.sun.jna.Native; public final class WindowsConsoleKeyPoller { private static final int VK_ESCAPE = 0x1B; private static final User32 USER32 = loadUser32(); private boolean escapeDown; boolean isSupported() { return USER32 != null; } void resetEscapeState() { if (!isSupported()) { return; } short state = USER32.GetAsyncKeyState(VK_ESCAPE); escapeDown = isDown(state); } boolean pollEscapePressed() { if (!isSupported()) { return false; } short state = USER32.GetAsyncKeyState(VK_ESCAPE); boolean down = isDown(state); boolean pressed = wasPressedSinceLastPoll(state) || (down && !escapeDown); escapeDown = down; return pressed; } private static boolean isDown(short state) { return (state & 0x8000) != 0; } private static boolean wasPressedSinceLastPoll(short state) { return (state & 0x0001) != 0; } private static User32 loadUser32() { if (!isWindows()) { return null; } try { return Native.load("user32", User32.class); } catch (Throwable ignored) { return null; } } private static boolean isWindows() { String osName = System.getProperty("os.name", ""); return osName.toLowerCase().contains("win"); } interface User32 extends Library { short GetAsyncKeyState(int vKey); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/AnsiTuiRuntime.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.tui.runtime.DefaultAnsiTuiRuntime; public class AnsiTuiRuntime extends DefaultAnsiTuiRuntime { public AnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer) { super(terminal, renderer); } public AnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer, boolean useAlternateScreen) { super(terminal, renderer, useAlternateScreen); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/AppendOnlyTuiRuntime.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.tui.runtime.DefaultAppendOnlyTuiRuntime; public class AppendOnlyTuiRuntime extends DefaultAppendOnlyTuiRuntime { public AppendOnlyTuiRuntime(TerminalIO terminal) { super(terminal); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/JlineTerminalIO.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.tui.io.DefaultJlineTerminalIO; import io.github.lnyocly.ai4j.tui.io.DefaultStreamsTerminalIO; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.Charset; public class JlineTerminalIO extends DefaultJlineTerminalIO { private JlineTerminalIO(Terminal terminal, OutputStream errStream) { super(terminal, errStream); } public static JlineTerminalIO openSystem(OutputStream errStream) throws IOException { Charset charset = DefaultStreamsTerminalIO.resolveTerminalCharset(); Terminal terminal = TerminalBuilder.builder() .system(true) .encoding(charset) .build(); return new JlineTerminalIO(terminal, errStream); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/StreamsTerminalIO.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.tui.io.DefaultStreamsTerminalIO; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.Charset; public class StreamsTerminalIO extends DefaultStreamsTerminalIO { public StreamsTerminalIO(InputStream in, OutputStream out, OutputStream err) { super(in, out, err); } StreamsTerminalIO(InputStream in, OutputStream out, OutputStream err, boolean ansiSupported) { super(in, out, err, ansiSupported); } StreamsTerminalIO(InputStream in, OutputStream out, OutputStream err, Charset charset, boolean ansiSupported) { super(in, out, err, charset, ansiSupported); } public static Charset resolveTerminalCharset() { return DefaultStreamsTerminalIO.resolveTerminalCharset(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TerminalIO.java ================================================ package io.github.lnyocly.ai4j.tui; import java.io.IOException; public interface TerminalIO { String readLine(String prompt) throws IOException; default TuiKeyStroke readKeyStroke() throws IOException { return null; } default TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException { return readKeyStroke(); } void print(String message); void println(String message); void errorln(String message); default boolean supportsAnsi() { return false; } default boolean supportsRawInput() { return false; } default boolean isInputClosed() { return false; } default void clearScreen() { } default void enterAlternateScreen() { } default void exitAlternateScreen() { } default void hideCursor() { } default void showCursor() { } default void moveCursorHome() { } default int getTerminalRows() { return 0; } default int getTerminalColumns() { return 0; } default void close() throws IOException { } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAnsi.java ================================================ package io.github.lnyocly.ai4j.tui; final class TuiAnsi { private static final String ESC = "\u001b["; private static final String RESET = ESC + "0m"; private TuiAnsi() { } static String bold(String value, boolean ansi) { return style(value, null, null, ansi, true); } static String fg(String value, String hexColor, boolean ansi) { return style(value, hexColor, null, ansi, false); } static String bg(String value, String hexColor, boolean ansi) { return style(value, null, hexColor, ansi, false); } static String badge(String value, String foreground, String background, boolean ansi) { if (!ansi) { return "[" + value + "]"; } return style(" " + value + " ", foreground, background, true, true); } static String colorize(String value, String hexColor, boolean ansi, boolean bold) { return style(value, hexColor, null, ansi, bold); } static String style(String value, String foreground, String background, boolean ansi, boolean bold) { if (!ansi || isEmpty(value)) { return value; } StringBuilder codes = new StringBuilder(); if (bold) { codes.append('1'); } if (!isBlank(foreground)) { appendColorCode(codes, "38;2;", parseHex(foreground)); } if (!isBlank(background)) { appendColorCode(codes, "48;2;", parseHex(background)); } if (codes.length() == 0) { return value; } return ESC + codes + "m" + value + RESET; } private static void appendColorCode(StringBuilder codes, String prefix, int[] rgb) { if (codes == null || rgb == null || rgb.length < 3) { return; } if (codes.length() > 0) { codes.append(';'); } codes.append(prefix) .append(rgb[0]).append(';') .append(rgb[1]).append(';') .append(rgb[2]); } private static int[] parseHex(String hexColor) { String normalized = hexColor == null ? "" : hexColor.trim(); if (normalized.startsWith("#")) { normalized = normalized.substring(1); } if (normalized.length() != 6) { return new int[]{255, 255, 255}; } return new int[]{ Integer.parseInt(normalized.substring(0, 2), 16), Integer.parseInt(normalized.substring(2, 4), 16), Integer.parseInt(normalized.substring(4, 6), 16) }; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static boolean isEmpty(String value) { return value == null || value.isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAssistantPhase.java ================================================ package io.github.lnyocly.ai4j.tui; public enum TuiAssistantPhase { IDLE, THINKING, GENERATING, WAITING_TOOL_RESULT, COMPLETE, ERROR } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAssistantToolView.java ================================================ package io.github.lnyocly.ai4j.tui; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class TuiAssistantToolView { private String callId; private String toolName; private String status; private String title; private String detail; @Builder.Default private List previewLines = new ArrayList(); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAssistantViewModel.java ================================================ package io.github.lnyocly.ai4j.tui; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class TuiAssistantViewModel { @Builder.Default private TuiAssistantPhase phase = TuiAssistantPhase.IDLE; private Integer step; private String phaseDetail; private String reasoningText; private String text; private long updatedAtEpochMs; @Builder.Default private int animationTick = 0; @Builder.Default private List tools = new ArrayList(); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiConfig.java ================================================ package io.github.lnyocly.ai4j.tui; public class TuiConfig { private String theme = "default"; private boolean denseMode; private boolean showTimestamps = true; private boolean showFooter = true; private int maxEvents = 10; private boolean useAlternateScreen; public String getTheme() { return theme; } public void setTheme(String theme) { this.theme = theme; } public boolean isDenseMode() { return denseMode; } public void setDenseMode(boolean denseMode) { this.denseMode = denseMode; } public boolean isShowTimestamps() { return showTimestamps; } public void setShowTimestamps(boolean showTimestamps) { this.showTimestamps = showTimestamps; } public boolean isShowFooter() { return showFooter; } public void setShowFooter(boolean showFooter) { this.showFooter = showFooter; } public int getMaxEvents() { return maxEvents; } public void setMaxEvents(int maxEvents) { this.maxEvents = maxEvents; } public boolean isUseAlternateScreen() { return useAlternateScreen; } public void setUseAlternateScreen(boolean useAlternateScreen) { this.useAlternateScreen = useAlternateScreen; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiConfigManager.java ================================================ package io.github.lnyocly.ai4j.tui; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONWriter; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; public class TuiConfigManager { private static final List BUILT_IN_THEMES = Arrays.asList( "default", "amber", "ocean", "matrix", "github-dark", "github-light" ); private final Path workspaceRoot; public TuiConfigManager(Path workspaceRoot) { this.workspaceRoot = workspaceRoot == null ? Paths.get(".").toAbsolutePath().normalize() : workspaceRoot.toAbsolutePath().normalize(); } public TuiConfig load(String overrideTheme) { TuiConfig config = merge(loadConfig(homeConfigPath()), loadConfig(workspaceConfigPath())); if (config == null) { config = new TuiConfig(); } normalize(config); if (!isBlank(overrideTheme)) { config.setTheme(overrideTheme.trim()); } return config; } public TuiConfig save(TuiConfig config) throws IOException { TuiConfig normalized = config == null ? new TuiConfig() : config; normalize(normalized); Files.createDirectories(workspaceConfigPath().getParent()); String json = JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat); Files.write(workspaceConfigPath(), json.getBytes(StandardCharsets.UTF_8)); return normalized; } public TuiConfig switchTheme(String themeName) throws IOException { if (isBlank(themeName)) { throw new IllegalArgumentException("theme name is required"); } TuiTheme theme = resolveTheme(themeName); if (theme == null) { throw new IllegalArgumentException("Unknown TUI theme: " + themeName); } TuiConfig config = load(null); config.setTheme(theme.getName()); return save(config); } public TuiTheme resolveTheme(String name) { String target = isBlank(name) ? "default" : name.trim(); TuiTheme theme = loadTheme(workspaceThemePath(target)); if (theme != null) { normalize(theme, target); return theme; } theme = loadTheme(homeThemePath(target)); if (theme != null) { normalize(theme, target); return theme; } theme = loadBuiltInTheme(target); if (theme != null) { normalize(theme, target); return theme; } if (!"default".equals(target)) { return resolveTheme("default"); } return defaultTheme(); } public List listThemeNames() { Set names = new LinkedHashSet(); names.addAll(BUILT_IN_THEMES); names.addAll(scanThemeNames(homeThemesDir())); names.addAll(scanThemeNames(workspaceThemesDir())); return new ArrayList(names); } private TuiConfig merge(TuiConfig base, TuiConfig override) { return override != null ? override : base; } private TuiConfig loadConfig(Path path) { if (path == null || !Files.exists(path)) { return null; } try { return JSON.parseObject(Files.readAllBytes(path), TuiConfig.class); } catch (IOException ex) { return null; } } private TuiTheme loadTheme(Path path) { if (path == null || !Files.exists(path)) { return null; } try { return JSON.parseObject(Files.readAllBytes(path), TuiTheme.class); } catch (IOException ex) { return null; } } private TuiTheme loadBuiltInTheme(String name) { String resource = "/io/github/lnyocly/ai4j/tui/themes/" + name + ".json"; try (InputStream inputStream = TuiConfigManager.class.getResourceAsStream(resource)) { if (inputStream == null) { return null; } byte[] bytes = readAllBytes(inputStream); return JSON.parseObject(bytes, TuiTheme.class); } catch (IOException ex) { return null; } } private byte[] readAllBytes(InputStream inputStream) throws IOException { byte[] buffer = new byte[4096]; int read; java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); while ((read = inputStream.read(buffer)) >= 0) { out.write(buffer, 0, read); } return out.toByteArray(); } private List scanThemeNames(Path dir) { if (dir == null || !Files.exists(dir)) { return Collections.emptyList(); } try { List names = new ArrayList(); java.nio.file.DirectoryStream stream = Files.newDirectoryStream(dir, "*.json"); try { for (Path path : stream) { String fileName = path.getFileName().toString(); names.add(fileName.substring(0, fileName.length() - 5)); } } finally { stream.close(); } Collections.sort(names); return names; } catch (IOException ex) { return Collections.emptyList(); } } private void normalize(TuiConfig config) { if (config == null) { return; } if (isBlank(config.getTheme())) { config.setTheme("default"); } if (config.getMaxEvents() <= 0) { config.setMaxEvents(10); } } private void normalize(TuiTheme theme, String fallbackName) { if (theme == null) { return; } if (isBlank(theme.getName())) { theme.setName(fallbackName); } if (isBlank(theme.getBrand())) { theme.setBrand("#7cc6fe"); } if (isBlank(theme.getAccent())) { theme.setAccent("#f5b14c"); } if (isBlank(theme.getSuccess())) { theme.setSuccess("#8fd694"); } if (isBlank(theme.getWarning())) { theme.setWarning("#f4d35e"); } if (isBlank(theme.getDanger())) { theme.setDanger("#ef6f6c"); } if (isBlank(theme.getText())) { theme.setText("#f3f4f6"); } if (isBlank(theme.getMuted())) { theme.setMuted("#9ca3af"); } if (isBlank(theme.getPanelBorder())) { theme.setPanelBorder("#4b5563"); } if (isBlank(theme.getPanelTitle())) { theme.setPanelTitle(theme.getAccent()); } if (isBlank(theme.getBadgeForeground())) { theme.setBadgeForeground("#111827"); } if (isBlank(theme.getCodeBackground())) { theme.setCodeBackground("#161b22"); } if (isBlank(theme.getCodeBorder())) { theme.setCodeBorder("#30363d"); } if (isBlank(theme.getCodeText())) { theme.setCodeText("#c9d1d9"); } if (isBlank(theme.getCodeKeyword())) { theme.setCodeKeyword("#ff7b72"); } if (isBlank(theme.getCodeString())) { theme.setCodeString("#a5d6ff"); } if (isBlank(theme.getCodeComment())) { theme.setCodeComment("#8b949e"); } if (isBlank(theme.getCodeNumber())) { theme.setCodeNumber("#79c0ff"); } } private TuiTheme defaultTheme() { TuiTheme theme = new TuiTheme(); normalize(theme, "default"); return theme; } private Path workspaceConfigPath() { return workspaceRoot.resolve(".ai4j").resolve("tui.json"); } private Path workspaceThemesDir() { return workspaceRoot.resolve(".ai4j").resolve("themes"); } private Path workspaceThemePath(String name) { return workspaceThemesDir().resolve(name + ".json"); } private Path homeConfigPath() { String userHome = System.getProperty("user.home"); return isBlank(userHome) ? null : Paths.get(userHome).resolve(".ai4j").resolve("tui.json"); } private Path homeThemesDir() { String userHome = System.getProperty("user.home"); return isBlank(userHome) ? null : Paths.get(userHome).resolve(".ai4j").resolve("themes"); } private Path homeThemePath(String name) { Path homeThemes = homeThemesDir(); return homeThemes == null ? null : homeThemes.resolve(name + ".json"); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiInteractionState.java ================================================ package io.github.lnyocly.ai4j.tui; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; public class TuiInteractionState { public enum PaletteMode { GLOBAL, SLASH } private ApprovalSnapshot approvalSnapshot = ApprovalSnapshot.idle(null); private Runnable renderCallback; private TuiPanelId focusedPanel = TuiPanelId.INPUT; private final StringBuilder inputBuffer = new StringBuilder(); private final StringBuilder processInputBuffer = new StringBuilder(); private boolean paletteOpen; private PaletteMode paletteMode = PaletteMode.GLOBAL; private String paletteQuery = ""; private int paletteSelectedIndex; private List paletteItems = Collections.emptyList(); private String selectedProcessId; private boolean processInspectorOpen; private boolean replayViewerOpen; private boolean teamBoardOpen; private int replayScrollOffset; private int teamBoardScrollOffset; private int transcriptScrollOffset; public synchronized void setRenderCallback(Runnable renderCallback) { this.renderCallback = renderCallback; } public synchronized ApprovalSnapshot getApprovalSnapshot() { return approvalSnapshot; } public synchronized TuiPanelId getFocusedPanel() { return focusedPanel; } public synchronized String getInputBuffer() { return inputBuffer.toString(); } public synchronized String getProcessInputBuffer() { return processInputBuffer.toString(); } public synchronized boolean isPaletteOpen() { return paletteOpen; } public synchronized String getPaletteQuery() { return paletteQuery; } public synchronized PaletteMode getPaletteMode() { return paletteMode; } public synchronized int getPaletteSelectedIndex() { return paletteSelectedIndex; } public synchronized List getPaletteItems() { return new ArrayList(filterPaletteItems()); } public synchronized String getSelectedProcessId() { return selectedProcessId; } public synchronized boolean isProcessInspectorOpen() { return processInspectorOpen; } public synchronized boolean isReplayViewerOpen() { return replayViewerOpen; } public synchronized boolean isTeamBoardOpen() { return teamBoardOpen; } public synchronized int getReplayScrollOffset() { return replayScrollOffset; } public synchronized int getTeamBoardScrollOffset() { return teamBoardScrollOffset; } public synchronized int getTranscriptScrollOffset() { return transcriptScrollOffset; } public void setFocusedPanel(TuiPanelId focusedPanel) { updateState(() -> this.focusedPanel = focusedPanel == null ? TuiPanelId.INPUT : focusedPanel); } public void focusNextPanel() { updateState(() -> { TuiPanelId[] values = TuiPanelId.values(); int nextIndex = (focusedPanel.ordinal() + 1) % values.length; focusedPanel = values[nextIndex]; }); } public void appendInput(String text) { if (isBlank(text)) { return; } updateState(() -> inputBuffer.append(text)); } public void backspaceInput() { updateState(() -> { if (inputBuffer.length() > 0) { inputBuffer.deleteCharAt(inputBuffer.length() - 1); } }); } public synchronized String consumeInputBuffer() { String value = inputBuffer.toString(); inputBuffer.setLength(0); triggerRender(); return value; } public synchronized String consumeInputBufferSilently() { String value = inputBuffer.toString(); inputBuffer.setLength(0); return value; } public void clearInputBuffer() { updateState(() -> inputBuffer.setLength(0)); } public void replaceInputBuffer(String value) { updateState(() -> { inputBuffer.setLength(0); if (!isBlank(value)) { inputBuffer.append(value); } }); } public void replaceInputBufferAndClosePalette(String value) { updateState(() -> { inputBuffer.setLength(0); if (!isBlank(value)) { inputBuffer.append(value); } closePaletteState(); }); } public void appendInputAndSyncSlashPalette(String text, List items) { if (isBlank(text)) { return; } updateState(() -> { inputBuffer.append(text); syncSlashPaletteState(items); }); } public void backspaceInputAndSyncSlashPalette(List items) { updateState(() -> { if (inputBuffer.length() > 0) { inputBuffer.deleteCharAt(inputBuffer.length() - 1); } syncSlashPaletteState(items); }); } public void syncSlashPalette(List items) { updateState(() -> syncSlashPaletteState(items)); } public void appendProcessInput(String text) { if (isBlank(text)) { return; } updateState(() -> processInputBuffer.append(text)); } public void backspaceProcessInput() { updateState(() -> { if (processInputBuffer.length() > 0) { processInputBuffer.deleteCharAt(processInputBuffer.length() - 1); } }); } public synchronized String consumeProcessInputBuffer() { String value = processInputBuffer.toString(); processInputBuffer.setLength(0); triggerRender(); return value; } public void clearProcessInputBuffer() { updateState(() -> processInputBuffer.setLength(0)); } public void openPalette(List items) { openPalette(items, PaletteMode.GLOBAL, ""); } public void openSlashPalette(List items, String input) { openPalette(items, PaletteMode.SLASH, normalizeSlashQuery(input)); } public void refreshSlashPalette(List items, String input) { updateState(() -> { openSlashPaletteState(items, input); }); } private void openPalette(List items, PaletteMode mode, String query) { updateState(() -> { paletteOpen = true; paletteMode = mode == null ? PaletteMode.GLOBAL : mode; paletteQuery = query == null ? "" : query; paletteSelectedIndex = 0; paletteItems = items == null ? Collections.emptyList() : new ArrayList(items); }); } public void closePalette() { updateState(this::closePaletteState); } public synchronized void closePaletteSilently() { closePaletteState(); } public void appendPaletteQuery(String text) { if (isBlank(text)) { return; } updateState(() -> { paletteQuery = paletteQuery + text; paletteSelectedIndex = 0; }); } public void backspacePaletteQuery() { updateState(() -> { if (!isBlank(paletteQuery)) { paletteQuery = paletteQuery.substring(0, paletteQuery.length() - 1); } paletteSelectedIndex = 0; }); } public void movePaletteSelection(int delta) { updateState(() -> { List visible = filterPaletteItems(); if (visible.isEmpty()) { paletteSelectedIndex = 0; return; } int size = visible.size(); int index = paletteSelectedIndex + delta; while (index < 0) { index += size; } paletteSelectedIndex = index % size; }); } public void selectProcess(String processId) { updateState(() -> this.selectedProcessId = isBlank(processId) ? null : processId); } public void selectAdjacentProcess(List processIds, int delta) { if (processIds == null || processIds.isEmpty()) { updateState(() -> selectedProcessId = null); return; } updateState(() -> { int currentIndex = processIds.indexOf(selectedProcessId); if (currentIndex < 0) { selectedProcessId = delta >= 0 ? processIds.get(0) : processIds.get(processIds.size() - 1); return; } int index = currentIndex + delta; while (index < 0) { index += processIds.size(); } selectedProcessId = processIds.get(index % processIds.size()); }); } public void openProcessInspector(String processId) { updateState(() -> { if (!isBlank(processId)) { selectedProcessId = processId; } processInspectorOpen = true; replayViewerOpen = false; teamBoardOpen = false; processInputBuffer.setLength(0); }); } public void closeProcessInspector() { updateState(() -> { processInspectorOpen = false; processInputBuffer.setLength(0); }); } public void openReplayViewer() { updateState(() -> { replayViewerOpen = true; processInspectorOpen = false; teamBoardOpen = false; replayScrollOffset = 0; }); } public void closeReplayViewer() { updateState(() -> replayViewerOpen = false); } public void moveReplayScroll(int delta) { updateState(() -> replayScrollOffset = Math.max(0, replayScrollOffset + delta)); } public void openTeamBoard() { updateState(() -> { teamBoardOpen = true; replayViewerOpen = false; processInspectorOpen = false; teamBoardScrollOffset = 0; }); } public void closeTeamBoard() { updateState(() -> teamBoardOpen = false); } public void moveTeamBoardScroll(int delta) { updateState(() -> teamBoardScrollOffset = Math.max(0, teamBoardScrollOffset + delta)); } public void resetTranscriptScroll() { synchronized (this) { if (transcriptScrollOffset == 0) { return; } } updateState(() -> transcriptScrollOffset = 0); } public void moveTranscriptScroll(int delta) { updateState(() -> transcriptScrollOffset = Math.max(0, transcriptScrollOffset + delta)); } public synchronized TuiPaletteItem getSelectedPaletteItem() { List visible = filterPaletteItems(); if (visible.isEmpty()) { return null; } int index = Math.max(0, Math.min(paletteSelectedIndex, visible.size() - 1)); return visible.get(index); } public void showApproval(String mode, String toolName, String summary) { updateApproval(ApprovalSnapshot.pending(mode, toolName, summary)); } public void resolveApproval(String toolName, boolean approved) { updateApproval(ApprovalSnapshot.resolved(toolName, approved)); } public void clearApproval() { updateApproval(ApprovalSnapshot.idle(null)); } private synchronized List filterPaletteItems() { if (paletteItems == null || paletteItems.isEmpty()) { return Collections.emptyList(); } if (isBlank(paletteQuery)) { return new ArrayList(paletteItems); } if ("/".equals(paletteQuery.trim())) { return new ArrayList(paletteItems); } String query = paletteQuery.toLowerCase(Locale.ROOT); List filtered = new ArrayList(); for (TuiPaletteItem item : paletteItems) { if (item == null) { continue; } if (matches(query, item.getLabel()) || matches(query, item.getDetail()) || matches(query, item.getGroup()) || matches(query, item.getCommand())) { filtered.add(item); } } return filtered; } private boolean matches(String query, String value) { return !isBlank(value) && value.toLowerCase(Locale.ROOT).contains(query); } private void syncSlashPaletteState(List items) { String input = inputBuffer.toString(); if (shouldOpenSlashPalette(input)) { openSlashPaletteState(items, input); return; } if (paletteMode == PaletteMode.SLASH) { closePaletteState(); } } private void openSlashPaletteState(List items, String input) { paletteOpen = true; paletteMode = PaletteMode.SLASH; paletteQuery = normalizeSlashQuery(input); paletteSelectedIndex = 0; paletteItems = items == null ? Collections.emptyList() : new ArrayList(items); } private void closePaletteState() { paletteOpen = false; paletteMode = PaletteMode.GLOBAL; paletteQuery = ""; paletteSelectedIndex = 0; } private boolean shouldOpenSlashPalette(String input) { return !isBlank(input) && input.startsWith("/") && input.indexOf(' ') < 0 && input.indexOf('\t') < 0; } private void updateApproval(ApprovalSnapshot snapshot) { synchronized (this) { this.approvalSnapshot = snapshot == null ? ApprovalSnapshot.idle(null) : snapshot; } triggerRender(); } private void updateState(StateMutation mutation) { synchronized (this) { mutation.apply(); } triggerRender(); } private void triggerRender() { Runnable callback; synchronized (this) { callback = this.renderCallback; } if (callback != null) { callback.run(); } } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String normalizeSlashQuery(String input) { if (input == null) { return ""; } String normalized = input.trim(); while (normalized.startsWith("/")) { normalized = normalized.substring(1); } return normalized; } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private interface StateMutation { void apply(); } public static final class ApprovalSnapshot { private final boolean pending; private final String mode; private final String toolName; private final String summary; private final String lastDecision; private ApprovalSnapshot(boolean pending, String mode, String toolName, String summary, String lastDecision) { this.pending = pending; this.mode = defaultText(mode, "auto"); this.toolName = toolName; this.summary = summary; this.lastDecision = lastDecision; } public static ApprovalSnapshot pending(String mode, String toolName, String summary) { return new ApprovalSnapshot(true, mode, toolName, summary, null); } public static ApprovalSnapshot resolved(String toolName, boolean approved) { return new ApprovalSnapshot(false, "auto", toolName, null, "last=" + (approved ? "approved" : "rejected") + " tool=" + defaultText(toolName, "unknown")); } public static ApprovalSnapshot idle(String lastDecision) { return new ApprovalSnapshot(false, "auto", null, null, lastDecision); } public boolean isPending() { return pending; } public String getMode() { return mode; } public String getToolName() { return toolName; } public String getSummary() { return summary; } public String getLastDecision() { return lastDecision; } private static String defaultText(String value, String defaultValue) { return value == null || value.trim().isEmpty() ? defaultValue : value; } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiKeyStroke.java ================================================ package io.github.lnyocly.ai4j.tui; public class TuiKeyStroke { private final TuiKeyType type; private final String text; private TuiKeyStroke(TuiKeyType type, String text) { this.type = type == null ? TuiKeyType.UNKNOWN : type; this.text = text; } public static TuiKeyStroke of(TuiKeyType type) { return new TuiKeyStroke(type, null); } public static TuiKeyStroke character(String text) { return new TuiKeyStroke(TuiKeyType.CHARACTER, text); } public TuiKeyType getType() { return type; } public String getText() { return text; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiKeyType.java ================================================ package io.github.lnyocly.ai4j.tui; public enum TuiKeyType { CHARACTER, ENTER, BACKSPACE, TAB, ESCAPE, ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, CTRL_P, CTRL_R, CTRL_L, UNKNOWN } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiPaletteItem.java ================================================ package io.github.lnyocly.ai4j.tui; public class TuiPaletteItem { private final String id; private final String group; private final String label; private final String detail; private final String command; public TuiPaletteItem(String id, String group, String label, String detail, String command) { this.id = id; this.group = group; this.label = label; this.detail = detail; this.command = command; } public String getId() { return id; } public String getGroup() { return group; } public String getLabel() { return label; } public String getDetail() { return detail; } public String getCommand() { return command; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiPanelId.java ================================================ package io.github.lnyocly.ai4j.tui; public enum TuiPanelId { STATUS, SESSIONS, HISTORY, TREE, CHECKPOINT, PROCESSES, APPROVAL, COMMANDS, EVENTS, ASSISTANT, INPUT } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiRenderContext.java ================================================ package io.github.lnyocly.ai4j.tui; public class TuiRenderContext { private final String provider; private final String protocol; private final String model; private final String workspace; private final String sessionStore; private final String sessionMode; private final String approvalMode; private final int terminalRows; private final int terminalColumns; private TuiRenderContext(Builder builder) { this.provider = builder.provider; this.protocol = builder.protocol; this.model = builder.model; this.workspace = builder.workspace; this.sessionStore = builder.sessionStore; this.sessionMode = builder.sessionMode; this.approvalMode = builder.approvalMode; this.terminalRows = builder.terminalRows; this.terminalColumns = builder.terminalColumns; } public static Builder builder() { return new Builder(); } public String getProvider() { return provider; } public String getProtocol() { return protocol; } public String getModel() { return model; } public String getWorkspace() { return workspace; } public String getSessionStore() { return sessionStore; } public String getSessionMode() { return sessionMode; } public String getApprovalMode() { return approvalMode; } public int getTerminalRows() { return terminalRows; } public int getTerminalColumns() { return terminalColumns; } public static final class Builder { private String provider; private String protocol; private String model; private String workspace; private String sessionStore; private String sessionMode; private String approvalMode; private int terminalRows; private int terminalColumns; private Builder() { } public Builder provider(String provider) { this.provider = provider; return this; } public Builder protocol(String protocol) { this.protocol = protocol; return this; } public Builder model(String model) { this.model = model; return this; } public Builder workspace(String workspace) { this.workspace = workspace; return this; } public Builder sessionStore(String sessionStore) { this.sessionStore = sessionStore; return this; } public Builder sessionMode(String sessionMode) { this.sessionMode = sessionMode; return this; } public Builder approvalMode(String approvalMode) { this.approvalMode = approvalMode; return this; } public Builder terminalRows(int terminalRows) { this.terminalRows = terminalRows; return this; } public Builder terminalColumns(int terminalColumns) { this.terminalColumns = terminalColumns; return this; } public TuiRenderContext build() { return new TuiRenderContext(this); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiRenderer.java ================================================ package io.github.lnyocly.ai4j.tui; public interface TuiRenderer { int getMaxEvents(); String getThemeName(); void updateTheme(TuiConfig config, TuiTheme theme); String render(TuiScreenModel screenModel); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiRuntime.java ================================================ package io.github.lnyocly.ai4j.tui; import java.io.IOException; public interface TuiRuntime { boolean supportsRawInput(); void enter(); void exit(); TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException; void render(TuiScreenModel screenModel); } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiScreenModel.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint; import io.github.lnyocly.ai4j.coding.CodingSessionSnapshot; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import java.util.ArrayList; import java.util.List; public class TuiScreenModel { private final TuiConfig config; private final TuiTheme theme; private final CodingSessionDescriptor descriptor; private final CodingSessionSnapshot snapshot; private final CodingSessionCheckpoint checkpoint; private final TuiRenderContext renderContext; private final TuiInteractionState interactionState; private final List cachedSessions; private final List cachedHistory; private final List cachedTree; private final List cachedCommands; private final List cachedEvents; private final List cachedReplay; private final List cachedTeamBoard; private final BashProcessInfo inspectedProcess; private final BashProcessLogChunk inspectedProcessLogs; private final String assistantOutput; private final TuiAssistantViewModel assistantViewModel; private TuiScreenModel(Builder builder) { this.config = builder.config; this.theme = builder.theme; this.descriptor = builder.descriptor; this.snapshot = builder.snapshot; this.checkpoint = builder.checkpoint; this.renderContext = builder.renderContext; this.interactionState = builder.interactionState; this.cachedSessions = builder.cachedSessions; this.cachedHistory = builder.cachedHistory; this.cachedTree = builder.cachedTree; this.cachedCommands = builder.cachedCommands; this.cachedEvents = builder.cachedEvents; this.cachedReplay = builder.cachedReplay; this.cachedTeamBoard = builder.cachedTeamBoard; this.inspectedProcess = builder.inspectedProcess; this.inspectedProcessLogs = builder.inspectedProcessLogs; this.assistantOutput = builder.assistantOutput; this.assistantViewModel = builder.assistantViewModel; } public static Builder builder() { return new Builder(); } public TuiConfig getConfig() { return config; } public TuiTheme getTheme() { return theme; } public CodingSessionDescriptor getDescriptor() { return descriptor; } public CodingSessionSnapshot getSnapshot() { return snapshot; } public CodingSessionCheckpoint getCheckpoint() { return checkpoint; } public TuiRenderContext getRenderContext() { return renderContext; } public TuiInteractionState getInteractionState() { return interactionState; } public List getCachedSessions() { return cachedSessions; } public List getCachedHistory() { return cachedHistory; } public List getCachedTree() { return cachedTree; } public List getCachedCommands() { return cachedCommands; } public List getCachedEvents() { return cachedEvents; } public List getCachedReplay() { return cachedReplay; } public List getCachedTeamBoard() { return cachedTeamBoard; } public BashProcessInfo getInspectedProcess() { return inspectedProcess; } public BashProcessLogChunk getInspectedProcessLogs() { return inspectedProcessLogs; } public String getAssistantOutput() { return assistantOutput; } public TuiAssistantViewModel getAssistantViewModel() { return assistantViewModel; } public static final class Builder { private TuiConfig config; private TuiTheme theme; private CodingSessionDescriptor descriptor; private CodingSessionSnapshot snapshot; private CodingSessionCheckpoint checkpoint; private TuiRenderContext renderContext; private TuiInteractionState interactionState; private List cachedSessions = new ArrayList(); private List cachedHistory = new ArrayList(); private List cachedTree = new ArrayList(); private List cachedCommands = new ArrayList(); private List cachedEvents = new ArrayList(); private List cachedReplay = new ArrayList(); private List cachedTeamBoard = new ArrayList(); private BashProcessInfo inspectedProcess; private BashProcessLogChunk inspectedProcessLogs; private String assistantOutput; private TuiAssistantViewModel assistantViewModel; private Builder() { } public Builder config(TuiConfig config) { this.config = config; return this; } public Builder theme(TuiTheme theme) { this.theme = theme; return this; } public Builder descriptor(CodingSessionDescriptor descriptor) { this.descriptor = descriptor; return this; } public Builder snapshot(CodingSessionSnapshot snapshot) { this.snapshot = snapshot; return this; } public Builder checkpoint(CodingSessionCheckpoint checkpoint) { this.checkpoint = checkpoint; return this; } public Builder renderContext(TuiRenderContext renderContext) { this.renderContext = renderContext; return this; } public Builder interactionState(TuiInteractionState interactionState) { this.interactionState = interactionState; return this; } public Builder cachedSessions(List cachedSessions) { this.cachedSessions = copy(cachedSessions); return this; } public Builder cachedHistory(List cachedHistory) { this.cachedHistory = copy(cachedHistory); return this; } public Builder cachedTree(List cachedTree) { this.cachedTree = copy(cachedTree); return this; } public Builder cachedCommands(List cachedCommands) { this.cachedCommands = copy(cachedCommands); return this; } public Builder cachedEvents(List cachedEvents) { this.cachedEvents = copy(cachedEvents); return this; } public Builder cachedReplay(List cachedReplay) { this.cachedReplay = copy(cachedReplay); return this; } public Builder cachedTeamBoard(List cachedTeamBoard) { this.cachedTeamBoard = copy(cachedTeamBoard); return this; } public Builder inspectedProcess(BashProcessInfo inspectedProcess) { this.inspectedProcess = inspectedProcess; return this; } public Builder inspectedProcessLogs(BashProcessLogChunk inspectedProcessLogs) { this.inspectedProcessLogs = inspectedProcessLogs; return this; } public Builder assistantOutput(String assistantOutput) { this.assistantOutput = assistantOutput; return this; } public Builder assistantViewModel(TuiAssistantViewModel assistantViewModel) { this.assistantViewModel = assistantViewModel; return this; } public TuiScreenModel build() { return new TuiScreenModel(this); } private static List copy(List source) { return source == null ? new ArrayList() : new ArrayList(source); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiSessionView.java ================================================ package io.github.lnyocly.ai4j.tui; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint; import io.github.lnyocly.ai4j.coding.CodingSessionSnapshot; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; public class TuiSessionView implements TuiRenderer { private static final String ASSISTANT_PREFIX = "\u0000assistant>"; private static final String ASSISTANT_CONTINUATION_PREFIX = "\u0000assistant+>"; private static final String REASONING_PREFIX = "\u0000thinking>"; private static final String REASONING_CONTINUATION_PREFIX = "\u0000thinking+>"; private static final String CODE_LINE_PREFIX = "\u0000code>"; private static final String BULLET_PREFIX = "\u2022 "; private static final String BULLET_CONTINUATION = " "; private static final String THINKING_LABEL = "\u2022 Thinking: "; private static final String NOTE_LABEL = "\u2022 Note: "; private static final String ERROR_LABEL = "\u2022 Error: "; private static final String PROCESS_LABEL = "\u2022 Process: "; private static final String STARTUP_LABEL = "\u2022 "; private static final String[] STATUS_FRAMES = new String[]{"-", "\\", "|", "/"}; private static final int TRANSCRIPT_VIEWPORT_LINES = 24; private static final int MAX_REPLAY_LINES = 18; private static final int MAX_TOOL_PREVIEW_LINES = 4; private static final int MAX_PROCESS_LOG_LINES = 8; private static final int MAX_OVERLAY_ITEMS = 8; private static final Set CODE_KEYWORDS = Collections.unmodifiableSet(new HashSet(Arrays.asList( "abstract", "async", "await", "boolean", "break", "byte", "case", "catch", "cd", "char", "class", "const", "continue", "default", "delete", "do", "docker", "done", "double", "echo", "elif", "else", "enum", "export", "extends", "false", "fi", "final", "finally", "float", "for", "function", "git", "if", "implements", "import", "in", "int", "interface", "java", "kubectl", "let", "long", "mvn", "new", "null", "npm", "package", "pnpm", "private", "protected", "public", "return", "rg", "select", "set", "short", "static", "switch", "then", "this", "throw", "throws", "true", "try", "typeof", "unset", "var", "void", "while" ))); private final boolean ansi; private TuiConfig config; private TuiTheme theme; private List cachedSessions = Collections.emptyList(); private List cachedHistory = Collections.emptyList(); private List cachedTree = Collections.emptyList(); private List cachedCommands = Collections.emptyList(); private List cachedEvents = Collections.emptyList(); private List cachedReplay = Collections.emptyList(); private List cachedTeamBoard = Collections.emptyList(); private BashProcessInfo inspectedProcess; private BashProcessLogChunk inspectedProcessLogs; private String assistantOutput; private TuiAssistantViewModel assistantViewModel; private TuiInteractionState currentInteractionState; public TuiSessionView(TuiConfig config, TuiTheme theme, boolean ansi) { this.config = config == null ? new TuiConfig() : config; this.theme = theme == null ? new TuiTheme() : theme; this.ansi = ansi; } public void updateTheme(TuiConfig config, TuiTheme theme) { this.config = config == null ? new TuiConfig() : config; this.theme = theme == null ? new TuiTheme() : theme; } public int getMaxEvents() { return config == null || config.getMaxEvents() <= 0 ? 10 : config.getMaxEvents(); } public String getThemeName() { return theme == null ? "default" : theme.getName(); } public void setCachedSessions(List sessions) { this.cachedSessions = trimObjects(sessions, 12); } public void setCachedEvents(List events) { this.cachedEvents = events == null ? Collections.emptyList() : new ArrayList(events); } public void setCachedHistory(List historyLines) { this.cachedHistory = copyLines(historyLines, 12); } public void setCachedTree(List treeLines) { this.cachedTree = copyLines(treeLines, 12); } public void setCachedCommands(List commandLines) { this.cachedCommands = copyLines(commandLines, 20); } public void setAssistantOutput(String assistantOutput) { this.assistantOutput = assistantOutput; } public void setAssistantViewModel(TuiAssistantViewModel assistantViewModel) { this.assistantViewModel = assistantViewModel; } public void setCachedReplay(List replayLines) { this.cachedReplay = replayLines == null ? Collections.emptyList() : new ArrayList(replayLines); } public void setCachedTeamBoard(List teamBoardLines) { this.cachedTeamBoard = teamBoardLines == null ? Collections.emptyList() : new ArrayList(teamBoardLines); } public void setProcessInspector(BashProcessInfo process, BashProcessLogChunk logs) { this.inspectedProcess = process; this.inspectedProcessLogs = logs; } @Override public String render(TuiScreenModel screenModel) { TuiScreenModel model = screenModel == null ? TuiScreenModel.builder().build() : screenModel; applyModel(model); String header = renderHeader(model.getDescriptor(), model.getRenderContext()); String overlay = renderOverlay(model.getInteractionState()); String composer = renderComposer(model.getInteractionState()); String composerAddon = renderComposerAddon(model.getInteractionState()); int transcriptViewport = resolveTranscriptViewport(model.getRenderContext(), overlay, composerAddon); StringBuilder out = new StringBuilder(); out.append(header); List feedLines = buildFeedLines(transcriptViewport); if (!feedLines.isEmpty()) { out.append('\n').append('\n'); appendLines(out, feedLines); } if (!isBlank(overlay)) { out.append('\n').append('\n').append(overlay); } out.append('\n').append('\n').append(composer); if (!isBlank(composerAddon)) { out.append('\n').append('\n').append(composerAddon); } return out.toString(); } public String render(ManagedCodingSession session, TuiRenderContext context, TuiInteractionState interactionState) { return render(TuiScreenModel.builder() .config(config).theme(theme) .descriptor(session == null ? null : session.toDescriptor()) .snapshot(session == null || session.getSession() == null ? null : session.getSession().snapshot()) .checkpoint(session == null || session.getSession() == null ? null : session.getSession().exportState().getCheckpoint()) .renderContext(context).interactionState(interactionState) .cachedSessions(cachedSessions).cachedHistory(cachedHistory).cachedTree(cachedTree) .cachedCommands(cachedCommands).cachedEvents(cachedEvents).cachedReplay(cachedReplay) .cachedTeamBoard(cachedTeamBoard) .inspectedProcess(inspectedProcess).inspectedProcessLogs(inspectedProcessLogs) .assistantOutput(assistantOutput).assistantViewModel(assistantViewModel) .build()); } private void applyModel(TuiScreenModel screenModel) { if (screenModel == null) { return; } if (screenModel.getConfig() != null || screenModel.getTheme() != null) { updateTheme(screenModel.getConfig(), screenModel.getTheme()); } currentInteractionState = screenModel.getInteractionState(); setCachedSessions(screenModel.getCachedSessions()); setCachedHistory(screenModel.getCachedHistory()); setCachedTree(screenModel.getCachedTree()); setCachedCommands(screenModel.getCachedCommands()); setCachedEvents(screenModel.getCachedEvents()); setCachedReplay(screenModel.getCachedReplay()); setCachedTeamBoard(screenModel.getCachedTeamBoard()); setProcessInspector(screenModel.getInspectedProcess(), screenModel.getInspectedProcessLogs()); setAssistantOutput(screenModel.getAssistantOutput()); setAssistantViewModel(screenModel.getAssistantViewModel()); } private String renderHeader(CodingSessionDescriptor descriptor, TuiRenderContext context) { String model = clip(firstNonBlank( descriptor == null ? null : descriptor.getModel(), context == null ? null : context.getModel(), "model" ), 28); String workspace = clip(lastPathSegment(firstNonBlank( descriptor == null ? null : descriptor.getWorkspace(), context == null ? null : context.getWorkspace(), "." )), 32); String sessionId = shortenSessionId(descriptor == null ? null : descriptor.getSessionId()); StringBuilder line = new StringBuilder(); line.append(TuiAnsi.bold(TuiAnsi.fg("AI4J", theme.getBrand(), ansi), ansi)); line.append(" "); line.append(TuiAnsi.fg(model, theme.getSuccess(), ansi)); line.append(" "); line.append(TuiAnsi.fg(workspace, theme.getMuted(), ansi)); if (!isBlank(sessionId)) { line.append(" "); line.append(TuiAnsi.fg(sessionId, theme.getMuted(), ansi)); } return line.toString(); } private List buildFeedLines(int transcriptViewport) { List transcriptLines = new ArrayList(); appendEventFeed(transcriptLines); appendLiveAssistantFeed(transcriptLines); appendAssistantNote(transcriptLines); if (transcriptLines.isEmpty()) { appendStartupTips(transcriptLines); } trimTrailingBlankLines(transcriptLines); return sliceTranscriptLines(transcriptLines, transcriptViewport); } private void appendEventFeed(List lines) { if (cachedEvents == null || cachedEvents.isEmpty()) { return; } Set completedToolKeys = buildCompletedToolKeys(cachedEvents); for (int i = 0; i < cachedEvents.size(); i++) { SessionEvent event = cachedEvents.get(i); if (event == null || event.getType() == null) { continue; } SessionEventType type = event.getType(); if (type == SessionEventType.USER_MESSAGE) { appendWrappedText(lines, BULLET_PREFIX, firstNonBlank(payloadString(event.getPayload(), "input"), event.getSummary()), Integer.MAX_VALUE, Integer.MAX_VALUE); lines.add(""); continue; } if (type == SessionEventType.ASSISTANT_MESSAGE) { String output = firstNonBlank(payloadString(event.getPayload(), "output"), event.getSummary()); if ("reasoning".equalsIgnoreCase(payloadString(event.getPayload(), "kind"))) { appendReasoningMarkdown(lines, output, Integer.MAX_VALUE, Integer.MAX_VALUE); } else { appendAssistantMarkdown(lines, output, Integer.MAX_VALUE, Integer.MAX_VALUE); } appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.TOOL_CALL || type == SessionEventType.TOOL_RESULT) { if (type == SessionEventType.TOOL_CALL && completedToolKeys.contains(buildToolIdentity(event.getPayload()))) { continue; } appendToolEventLines(lines, event); appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.ERROR) { appendWrappedText(lines, ERROR_LABEL, firstNonBlank(payloadString(event.getPayload(), "error"), event.getSummary()), Integer.MAX_VALUE, Integer.MAX_VALUE); appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.SESSION_RESUMED) { appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), "session resumed"), Integer.MAX_VALUE, Integer.MAX_VALUE); appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.SESSION_FORKED) { appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), "session forked"), Integer.MAX_VALUE, Integer.MAX_VALUE); appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.TASK_CREATED || type == SessionEventType.TASK_UPDATED) { appendTaskEventLines(lines, event); appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.TEAM_MESSAGE) { appendTeamMessageLines(lines, event); appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.AUTO_CONTINUE || type == SessionEventType.AUTO_STOP || type == SessionEventType.BLOCKED) { appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), type.name().toLowerCase().replace('_', ' ')), Integer.MAX_VALUE, Integer.MAX_VALUE); appendBlankLineIfNeeded(lines); continue; } if (type == SessionEventType.COMPACT) { appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), payloadString(event.getPayload(), "summary"), "context compacted"), Integer.MAX_VALUE, Integer.MAX_VALUE); appendBlankLineIfNeeded(lines); } } } private void appendLiveAssistantFeed(List lines) { TuiAssistantViewModel viewModel = assistantViewModel; if (viewModel == null) { return; } String statusLine = buildAssistantStatusLine(viewModel); if (!isBlank(statusLine)) { lines.add(statusLine); } appendToolLines(lines, filterUnpersistedLiveTools(viewModel.getTools())); String reasoningText = trimToNull(viewModel.getReasoningText()); if (!isBlank(reasoningText)) { appendBlankLineIfNeeded(lines); appendReasoningMarkdown(lines, reasoningText, Integer.MAX_VALUE, Integer.MAX_VALUE); } String assistantText = trimToNull(viewModel.getText()); if (!isBlank(assistantText) && !(viewModel.getPhase() == TuiAssistantPhase.COMPLETE && assistantText.equals(lastAssistantMessage()))) { appendBlankLineIfNeeded(lines); appendAssistantMarkdown(lines, assistantText, Integer.MAX_VALUE, Integer.MAX_VALUE); } } private void appendTaskEventLines(List lines, SessionEvent event) { if (lines == null || event == null) { return; } Map payload = event.getPayload(); appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), payloadString(payload, "title"), "delegate task"), Integer.MAX_VALUE, Integer.MAX_VALUE); String detail = firstNonBlank(payloadString(payload, "detail"), payloadString(payload, "error"), payloadString(payload, "output")); if (!isBlank(detail)) { appendWrappedText(lines, BULLET_CONTINUATION, detail, Integer.MAX_VALUE, Integer.MAX_VALUE); } String member = firstNonBlank(payloadString(payload, "memberName"), payloadString(payload, "memberId")); if (!isBlank(member)) { appendWrappedText(lines, BULLET_CONTINUATION, "member: " + member, Integer.MAX_VALUE, Integer.MAX_VALUE); } String childSessionId = payloadString(payload, "childSessionId"); if (!isBlank(childSessionId)) { appendWrappedText(lines, BULLET_CONTINUATION, "child session: " + childSessionId, Integer.MAX_VALUE, Integer.MAX_VALUE); } String status = payloadString(payload, "status"); String phase = payloadString(payload, "phase"); String percent = payloadString(payload, "percent"); if (!isBlank(status) || !isBlank(phase) || !isBlank(percent)) { StringBuilder stateLine = new StringBuilder(); if (!isBlank(status)) { stateLine.append("status: ").append(status); } if (!isBlank(phase)) { if (stateLine.length() > 0) { stateLine.append(" | "); } stateLine.append("phase: ").append(phase); } if (!isBlank(percent)) { if (stateLine.length() > 0) { stateLine.append(" | "); } stateLine.append("progress: ").append(percent).append('%'); } appendWrappedText(lines, BULLET_CONTINUATION, stateLine.toString(), Integer.MAX_VALUE, Integer.MAX_VALUE); } String heartbeatCount = payloadString(payload, "heartbeatCount"); if (!isBlank(heartbeatCount) && !"0".equals(heartbeatCount)) { appendWrappedText(lines, BULLET_CONTINUATION, "heartbeats: " + heartbeatCount, Integer.MAX_VALUE, Integer.MAX_VALUE); } } private void appendTeamMessageLines(List lines, SessionEvent event) { if (lines == null || event == null) { return; } Map payload = event.getPayload(); appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), payloadString(payload, "title"), "team message"), Integer.MAX_VALUE, Integer.MAX_VALUE); String taskId = payloadString(payload, "taskId"); if (!isBlank(taskId)) { appendWrappedText(lines, BULLET_CONTINUATION, "task: " + taskId, Integer.MAX_VALUE, Integer.MAX_VALUE); } String detail = firstNonBlank(payloadString(payload, "content"), payloadString(payload, "detail")); if (!isBlank(detail)) { appendWrappedText(lines, BULLET_CONTINUATION, detail, Integer.MAX_VALUE, Integer.MAX_VALUE); } } private void appendAssistantNote(List lines) { String note = trimToNull(assistantOutput); if (isBlank(note)) { return; } String assistantText = assistantViewModel == null ? null : trimToNull(assistantViewModel.getText()); if (note.equals(assistantText)) { return; } appendBlankLineIfNeeded(lines); appendWrappedText(lines, NOTE_LABEL, note, Integer.MAX_VALUE, Integer.MAX_VALUE); } private int resolveTranscriptViewport(TuiRenderContext context, String overlay, String composerAddon) { int terminalRows = context == null ? 0 : context.getTerminalRows(); if (terminalRows <= 0) { return Math.max(8, TRANSCRIPT_VIEWPORT_LINES); } int reservedLines = 1; // header reservedLines += 2; // gap before transcript reservedLines += 2; // gap before composer reservedLines += 1; // composer if (!isBlank(overlay)) { reservedLines += 2 + countRenderedLines(overlay); } if (!isBlank(composerAddon)) { reservedLines += 2 + countRenderedLines(composerAddon); } return Math.max(4, terminalRows - reservedLines); } private List sliceTranscriptLines(List transcriptLines, int viewportHint) { if (transcriptLines == null || transcriptLines.isEmpty()) { return Collections.emptyList(); } int viewport = viewportHint > 0 ? viewportHint : Math.max(8, TRANSCRIPT_VIEWPORT_LINES); int offset = currentInteractionState == null ? 0 : Math.max(0, currentInteractionState.getTranscriptScrollOffset()); if (transcriptLines.size() <= viewport) { return new ArrayList(transcriptLines); } int maxOffset = Math.max(0, transcriptLines.size() - viewport); int clampedOffset = Math.min(offset, maxOffset); int to = transcriptLines.size() - clampedOffset; int from = Math.max(0, to - viewport); return new ArrayList(transcriptLines.subList(from, to)); } private int countRenderedLines(String value) { if (isBlank(value)) { return 0; } int count = 1; for (int i = 0; i < value.length(); i++) { if (value.charAt(i) == '\n') { count++; } } return count; } private void trimTrailingBlankLines(List lines) { if (lines == null || lines.isEmpty()) { return; } while (!lines.isEmpty() && isBlank(lines.get(lines.size() - 1))) { lines.remove(lines.size() - 1); } } private List filterUnpersistedLiveTools(List tools) { if (tools == null || tools.isEmpty()) { return Collections.emptyList(); } Set persistedKeys = buildPersistedToolKeys(); if (persistedKeys.isEmpty()) { return new ArrayList(tools); } List visibleTools = new ArrayList(); for (TuiAssistantToolView tool : tools) { if (tool == null) { continue; } if (!persistedKeys.contains(buildToolIdentity(tool))) { visibleTools.add(tool); } } return visibleTools; } private Set buildPersistedToolKeys() { if (cachedEvents == null || cachedEvents.isEmpty()) { return Collections.emptySet(); } Set keys = new HashSet(); for (SessionEvent event : cachedEvents) { if (event == null || event.getType() == null) { continue; } if (event.getType() != SessionEventType.TOOL_CALL && event.getType() != SessionEventType.TOOL_RESULT) { continue; } String key = buildToolIdentity(event.getPayload()); if (!isBlank(key)) { keys.add(key); } } return keys; } private Set buildCompletedToolKeys(List events) { if (events == null || events.isEmpty()) { return Collections.emptySet(); } Set keys = new HashSet(); for (SessionEvent event : events) { if (event == null || event.getType() != SessionEventType.TOOL_RESULT) { continue; } String key = buildToolIdentity(event.getPayload()); if (!isBlank(key)) { keys.add(key); } } return keys; } private String buildToolIdentity(Map payload) { if (payload == null || payload.isEmpty()) { return null; } String callId = trimToNull(payloadString(payload, "callId")); if (!isBlank(callId)) { return callId; } String toolName = payloadString(payload, "tool"); JSONObject arguments = parseObject(payloadString(payload, "arguments")); return firstNonBlank(toolName, "tool") + "|" + firstNonBlank(trimToNull(payloadString(payload, "title")), buildToolTitle(toolName, arguments)); } private String buildToolIdentity(TuiAssistantToolView tool) { if (tool == null) { return null; } String callId = trimToNull(tool.getCallId()); if (!isBlank(callId)) { return callId; } return firstNonBlank(tool.getToolName(), "tool") + "|" + firstNonBlank(trimToNull(tool.getTitle()), extractToolLabel(tool), "tool"); } private void appendAssistantMarkdown(List lines, String rawText, int maxLines, int maxChars) { appendMarkdownBlock(lines, rawText, ASSISTANT_PREFIX, ASSISTANT_CONTINUATION_PREFIX, maxLines, maxChars); } private void appendReasoningMarkdown(List lines, String rawText, int maxLines, int maxChars) { if (lines == null || isBlank(rawText) || maxLines <= 0) { return; } String[] rawLines = rawText.replace("\r", "").split("\n"); boolean inCodeBlock = false; int count = 0; for (String rawLine : rawLines) { if (count >= maxLines) { lines.add(REASONING_CONTINUATION_PREFIX + (inCodeBlock ? CODE_LINE_PREFIX + "..." : "...")); return; } String trimmed = rawLine == null ? "" : rawLine.trim(); if (trimmed.startsWith("```")) { inCodeBlock = !inCodeBlock; continue; } String renderedLine = inCodeBlock ? CODE_LINE_PREFIX + clipCodeLine(rawLine, Math.max(0, maxChars)) : clip(rawLine, maxChars); lines.add((count == 0 ? REASONING_PREFIX : REASONING_CONTINUATION_PREFIX) + renderedLine); count++; } } private void appendMarkdownBlock(List lines, String rawText, String firstPrefix, String continuationPrefix, int maxLines, int maxChars) { if (lines == null || isBlank(rawText) || maxLines <= 0) { return; } String[] rawLines = rawText.replace("\r", "").split("\n"); boolean inCodeBlock = false; int count = 0; for (String rawLine : rawLines) { if (count >= maxLines) { lines.add(continuationPrefix + (inCodeBlock ? CODE_LINE_PREFIX + "..." : "...")); return; } String trimmed = rawLine == null ? "" : rawLine.trim(); if (trimmed.startsWith("```")) { inCodeBlock = !inCodeBlock; continue; } String renderedLine = inCodeBlock ? CODE_LINE_PREFIX + clipCodeLine(rawLine, Math.max(0, maxChars)) : clip(rawLine, maxChars); lines.add((count == 0 ? firstPrefix : continuationPrefix) + renderedLine); count++; } } private void appendStartupTips(List lines) { String tips = trimToNull(assistantOutput); if (isBlank(tips)) { lines.add(STARTUP_LABEL + "Ask AI4J to inspect this repository"); lines.add(STARTUP_LABEL + "Type `/` for commands and prompt templates"); lines.add(STARTUP_LABEL + "Use `Ctrl+R` for replay, `/team` for team board, and `Tab` to accept slash-command completion"); appendRecentSessionHints(lines); return; } appendWrappedText(lines, STARTUP_LABEL, tips, Integer.MAX_VALUE, 108); } private String buildAssistantStatusLine(TuiAssistantViewModel viewModel) { if (viewModel == null || viewModel.getPhase() == null || viewModel.getPhase() == TuiAssistantPhase.IDLE) { return null; } if (viewModel.getPhase() == TuiAssistantPhase.COMPLETE && !isBlank(viewModel.getText())) { return null; } String phrase = normalizeStatusPhrase(viewModel.getPhaseDetail(), viewModel.getPhase()); if (isBlank(phrase)) { return null; } return statusPrefix(viewModel) + " " + phrase; } private void appendToolLines(List lines, List tools) { if (tools == null || tools.isEmpty()) { return; } int from = Math.max(0, tools.size() - 4); for (int i = from; i < tools.size(); i++) { TuiAssistantToolView tool = tools.get(i); if (tool == null) { continue; } appendBlankLineIfNeeded(lines); List primaryLines = formatToolPrimaryLines(tool); for (String primaryLine : primaryLines) { if (!isBlank(primaryLine)) { lines.add(primaryLine); } } String detail = formatToolDetail(tool); if (!isBlank(detail)) { lines.addAll(wrapPrefixedText(" └ ", " ", detail, 108)); } List previewLines = formatToolPreviewLines(tool); for (String previewLine : previewLines) { lines.add(previewLine); } } } private void appendToolEventLines(List lines, SessionEvent event) { if (lines == null || event == null || event.getType() == null) { return; } TuiAssistantToolView toolView = buildToolView(event); if (toolView == null) { return; } appendToolLines(lines, Collections.singletonList(toolView)); } private TuiAssistantToolView buildToolView(SessionEvent event) { if (event == null || event.getType() == null) { return null; } Map payload = event.getPayload(); String toolName = payloadString(payload, "tool"); if (isBlank(toolName)) { return null; } JSONObject arguments = parseObject(payloadString(payload, "arguments")); String title = firstNonBlank(trimToNull(payloadString(payload, "title")), buildToolTitle(toolName, arguments)); List previewLines = payloadLines(payload, "previewLines"); if (event.getType() == SessionEventType.TOOL_CALL) { if (previewLines.isEmpty()) { previewLines = buildPendingToolPreviewLines(toolName, arguments); } return TuiAssistantToolView.builder() .callId(payloadString(payload, "callId")) .toolName(toolName) .status("pending") .title(title) .detail(firstNonBlank(trimToNull(payloadString(payload, "detail")), buildPendingToolDetail(toolName, arguments))) .previewLines(previewLines) .build(); } String rawOutput = payloadString(payload, "output"); JSONObject output = parseObject(rawOutput); if (previewLines.isEmpty()) { previewLines = buildToolPreviewLines(toolName, arguments, output, rawOutput); } return TuiAssistantToolView.builder() .callId(payloadString(payload, "callId")) .toolName(toolName) .status(isToolError(output, rawOutput) ? "error" : "done") .title(title) .detail(firstNonBlank(trimToNull(payloadString(payload, "detail")), buildCompletedToolDetail(toolName, arguments, output, rawOutput))) .previewLines(previewLines) .build(); } private List formatToolPrimaryLines(TuiAssistantToolView tool) { List lines = new ArrayList(); String toolName = firstNonBlank(tool.getToolName(), "tool"); String status = firstNonBlank(tool.getStatus(), "pending").toLowerCase(Locale.ROOT); String title = firstNonBlank(trimToNull(tool.getTitle()), toolName); String label = normalizeToolPrimaryLabel(title); if ("error".equals(status)) { if ("bash".equals(toolName)) { return wrapPrefixedText("\u2022 Command failed ", " \u2502 ", label, 108); } lines.add("\u2022 Tool failed " + clip(label, 92)); return lines; } if ("apply_patch".equals(toolName)) { lines.add("pending".equals(status) ? "\u2022 Applying patch" : "\u2022 Applied patch"); return lines; } return wrapPrefixedText(resolveToolPrimaryPrefix(toolName, title, status), " \u2502 ", label, 108); } private String resolveToolPrimaryPrefix(String toolName, String title, String status) { String normalizedTool = firstNonBlank(toolName, "tool"); String normalizedTitle = firstNonBlank(title, normalizedTool); boolean pending = "pending".equalsIgnoreCase(status); if ("read_file".equals(normalizedTool)) { return pending ? "\u2022 Reading " : "\u2022 Read "; } if ("bash".equals(normalizedTool)) { if (normalizedTitle.startsWith("bash logs ")) { return pending ? "\u2022 Reading logs " : "\u2022 Read logs "; } if (normalizedTitle.startsWith("bash status ")) { return pending ? "\u2022 Checking " : "\u2022 Checked "; } if (normalizedTitle.startsWith("bash write ")) { return pending ? "\u2022 Writing to " : "\u2022 Wrote to "; } if (normalizedTitle.startsWith("bash stop ")) { return pending ? "\u2022 Stopping " : "\u2022 Stopped "; } } return pending ? "\u2022 Running " : "\u2022 Ran "; } private String normalizeToolPrimaryLabel(String title) { String normalizedTitle = firstNonBlank(title, "tool").trim(); if (normalizedTitle.startsWith("$ ")) { return normalizedTitle.substring(2).trim(); } if (normalizedTitle.startsWith("read ")) { return normalizedTitle.substring(5).trim(); } if (normalizedTitle.startsWith("bash logs ")) { return normalizedTitle.substring("bash logs ".length()).trim(); } if (normalizedTitle.startsWith("bash status ")) { return normalizedTitle.substring("bash status ".length()).trim(); } if (normalizedTitle.startsWith("bash write ")) { return normalizedTitle.substring("bash write ".length()).trim(); } if (normalizedTitle.startsWith("bash stop ")) { return normalizedTitle.substring("bash stop ".length()).trim(); } return normalizedTitle; } private String formatToolDetail(TuiAssistantToolView tool) { if (tool == null || isBlank(tool.getDetail())) { return null; } String detail = tool.getDetail().trim(); if ("bash".equals(firstNonBlank(tool.getToolName(), "tool")) && !"error".equalsIgnoreCase(firstNonBlank(tool.getStatus(), "")) && !"timed out".equalsIgnoreCase(detail)) { return null; } String normalized = detail.toLowerCase(Locale.ROOT); if (normalized.startsWith("running command") || normalized.startsWith("reading process logs") || normalized.startsWith("checking process status") || normalized.startsWith("writing to process") || normalized.startsWith("stopping process") || normalized.startsWith("running tool") || normalized.startsWith("fetching buffered logs") || normalized.startsWith("refreshing process metadata") || normalized.startsWith("writing to process stdin") || normalized.startsWith("executing bash tool") || normalized.startsWith("reading file content") || normalized.startsWith("applying workspace patch") || normalized.startsWith("waiting for tool result")) { return null; } return detail; } private List formatToolPreviewLines(TuiAssistantToolView tool) { List rawPreviewLines = tool == null || tool.getPreviewLines() == null ? Collections.emptyList() : tool.getPreviewLines(); List normalizedPreviewLines = normalizeToolPreviewLines(tool, rawPreviewLines); if (normalizedPreviewLines.isEmpty()) { return Collections.emptyList(); } List formatted = new ArrayList(); int max = Math.min(normalizedPreviewLines.size(), MAX_TOOL_PREVIEW_LINES); for (int i = 0; i < max; i++) { String prefix = i == 0 ? " \u2514 " : " "; formatted.addAll(wrapPrefixedText(prefix, " ", normalizedPreviewLines.get(i), 108)); } if (normalizedPreviewLines.size() > max) { formatted.add(" \u2026 +" + (normalizedPreviewLines.size() - max) + " lines"); } return formatted; } private List normalizeToolPreviewLines(TuiAssistantToolView tool, List previewLines) { if (previewLines == null || previewLines.isEmpty()) { return Collections.emptyList(); } String toolName = firstNonBlank(tool == null ? null : tool.getToolName(), "tool"); String status = firstNonBlank(tool == null ? null : tool.getStatus(), "pending").toLowerCase(Locale.ROOT); List normalized = new ArrayList(); for (String previewLine : previewLines) { String candidate = trimToNull(stripPreviewLabel(previewLine)); if (isBlank(candidate)) { continue; } if ("bash".equals(toolName)) { if ("pending".equals(status)) { continue; } if ("(no command output)".equalsIgnoreCase(candidate)) { continue; } } if ("apply_patch".equals(toolName) && "(no changed files)".equalsIgnoreCase(candidate)) { continue; } normalized.add(candidate); } return normalized; } private String stripPreviewLabel(String previewLine) { String value = trimToNull(previewLine); if (isBlank(value)) { return value; } int separator = value.indexOf("> "); if (separator > 0) { String prefix = value.substring(0, separator).trim().toLowerCase(Locale.ROOT); if ("stdout".equals(prefix) || "stderr".equals(prefix) || "log".equals(prefix) || "file".equals(prefix) || "path".equals(prefix) || "cwd".equals(prefix) || "timeout".equals(prefix) || "process".equals(prefix) || "status".equals(prefix) || "command".equals(prefix) || "stdin".equals(prefix) || "meta".equals(prefix) || "out".equals(prefix)) { return value.substring(separator + 2).trim(); } } return value; } private String extractToolLabel(TuiAssistantToolView tool) { if (tool == null) { return "tool"; } String title = trimToNull(tool.getTitle()); if (!isBlank(title)) { return normalizeToolPrimaryLabel(title); } return firstNonBlank(tool.getToolName(), "tool"); } private JSONObject parseObject(String rawJson) { if (isBlank(rawJson)) { return null; } try { return JSON.parseObject(rawJson); } catch (Exception ex) { return null; } } private boolean isToolError(JSONObject output, String rawOutput) { return !isBlank(extractToolError(output, rawOutput)); } private String extractToolError(JSONObject output, String rawOutput) { if (!isBlank(rawOutput) && rawOutput.startsWith("TOOL_ERROR:")) { JSONObject errorPayload = parseObject(rawOutput.substring("TOOL_ERROR:".length()).trim()); if (errorPayload != null && !isBlank(errorPayload.getString("error"))) { return errorPayload.getString("error"); } return rawOutput.substring("TOOL_ERROR:".length()).trim(); } if (!isBlank(rawOutput) && rawOutput.startsWith("CODE_ERROR:")) { return rawOutput.substring("CODE_ERROR:".length()).trim(); } return output == null ? null : trimToNull(output.getString("error")); } private String buildToolTitle(String toolName, JSONObject arguments) { if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) || "start".equals(action)) { String command = firstNonBlank(arguments == null ? null : arguments.getString("command"), null); return isBlank(command) ? "bash " + action : "$ " + command; } if ("write".equals(action)) { return "bash write " + firstNonBlank(arguments == null ? null : arguments.getString("processId"), "(process)"); } if ("logs".equals(action) || "status".equals(action) || "stop".equals(action)) { return "bash " + action + " " + firstNonBlank(arguments == null ? null : arguments.getString("processId"), "(process)"); } return "bash " + action; } if ("read_file".equals(toolName)) { String path = arguments == null ? null : arguments.getString("path"); Integer startLine = arguments == null ? null : arguments.getInteger("startLine"); Integer endLine = arguments == null ? null : arguments.getInteger("endLine"); String range = startLine == null ? "" : ":" + startLine + (endLine == null ? "" : "-" + endLine); return "read " + firstNonBlank(path, "(path)") + range; } if ("apply_patch".equals(toolName)) { return "apply_patch"; } return firstNonBlank(toolName, "tool"); } private String buildPendingToolDetail(String toolName, JSONObject arguments) { if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) || "start".equals(action)) { return "Running command..."; } if ("logs".equals(action)) { return "Reading process logs..."; } if ("status".equals(action)) { return "Checking process status..."; } if ("write".equals(action)) { return "Writing to process..."; } if ("stop".equals(action)) { return "Stopping process..."; } return "Running tool..."; } if ("read_file".equals(toolName)) { return "Reading file content..."; } if ("apply_patch".equals(toolName)) { return "Applying workspace patch..."; } return "Running tool..."; } private List buildPendingToolPreviewLines(String toolName, JSONObject arguments) { return new ArrayList(); } private String buildCompletedToolDetail(String toolName, JSONObject arguments, JSONObject output, String rawOutput) { String toolError = extractToolError(output, rawOutput); if (!isBlank(toolError)) { return clip(toolError, 96); } if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) && output != null) { if (output.getBooleanValue("timedOut")) { return "timed out"; } return null; } if (("start".equals(action) || "status".equals(action) || "stop".equals(action)) && output != null) { String processId = firstNonBlank(output.getString("processId"), "process"); String status = trimToNull(output.getString("status")); return isBlank(status) ? processId : processId + " | " + status.toLowerCase(Locale.ROOT); } if ("write".equals(action) && output != null) { return output.getIntValue("bytesWritten") + " bytes written"; } if ("logs".equals(action) && output != null) { return null; } return clip(rawOutput, 96); } if ("read_file".equals(toolName) && output != null) { return firstNonBlank(output.getString("path"), firstNonBlank(arguments == null ? null : arguments.getString("path"), "(path)")) + ":" + output.getIntValue("startLine") + "-" + output.getIntValue("endLine") + (output.getBooleanValue("truncated") ? " | truncated" : ""); } if ("apply_patch".equals(toolName) && output != null) { int filesChanged = output.getIntValue("filesChanged"); if (filesChanged <= 0) { return null; } return filesChanged == 1 ? "1 file changed" : filesChanged + " files changed"; } return clip(rawOutput, 96); } private List buildToolPreviewLines(String toolName, JSONObject arguments, JSONObject output, String rawOutput) { List previewLines = new ArrayList(); if (!isBlank(extractToolError(output, rawOutput))) { return previewLines; } if ("bash".equals(toolName)) { String action = firstNonBlank(arguments == null ? null : arguments.getString("action"), "exec"); if ("exec".equals(action) && output != null) { addCommandPreviewLines(previewLines, output.getString("stdout"), output.getString("stderr")); return previewLines; } if ("logs".equals(action) && output != null) { addPlainPreviewLines(previewLines, output.getString("content")); return previewLines; } if (("start".equals(action) || "status".equals(action) || "stop".equals(action)) && output != null) { addPreviewLine(previewLines, "command", output.getString("command")); return previewLines; } if ("write".equals(action) && output != null) { return previewLines; } return previewLines; } if ("read_file".equals(toolName) && output != null) { addPreviewLines(previewLines, "file", output.getString("content"), 4); if (previewLines.isEmpty()) { previewLines.add("file> (empty file)"); } return previewLines; } if ("apply_patch".equals(toolName) && output != null) { JSONArray changedFiles = output.getJSONArray("changedFiles"); if (changedFiles != null) { for (int i = 0; i < changedFiles.size() && i < 4; i++) { previewLines.add("file> " + String.valueOf(changedFiles.get(i))); } } return previewLines; } addPreviewLines(previewLines, "out", rawOutput, 4); return previewLines; } private void addPreviewLines(List target, String label, String raw, int maxLines) { if (target == null || isBlank(raw) || maxLines <= 0) { return; } String[] lines = raw.replace("\r", "").split("\n"); int count = 0; for (String line : lines) { if (isBlank(line)) { continue; } target.add(firstNonBlank(label, "out") + "> " + clip(line, 92)); count++; if (count >= maxLines) { break; } } } private void addCommandPreviewLines(List target, String stdout, String stderr) { if (target == null) { return; } List lines = collectNonBlankLines(stdout); if (lines.isEmpty()) { lines = collectNonBlankLines(stderr); } else { List stderrLines = collectNonBlankLines(stderr); for (String stderrLine : stderrLines) { lines.add("stderr: " + stderrLine); } } addSummarizedPreview(target, lines); } private void addPlainPreviewLines(List target, String raw) { if (target == null) { return; } addSummarizedPreview(target, collectNonBlankLines(raw)); } private void addSummarizedPreview(List target, List lines) { if (target == null || lines == null || lines.isEmpty()) { return; } if (lines.size() <= 3) { for (String line : lines) { target.add(clip(line, 92)); } return; } target.add(clip(lines.get(0), 92)); target.add("\u2026 +" + (lines.size() - 2) + " lines"); target.add(clip(lines.get(lines.size() - 1), 92)); } private List collectNonBlankLines(String raw) { if (isBlank(raw)) { return Collections.emptyList(); } String[] rawLines = raw.replace("\r", "").split("\n"); List lines = new ArrayList(); for (String rawLine : rawLines) { if (!isBlank(rawLine)) { lines.add(rawLine.trim()); } } return lines; } private void addPreviewLine(List target, String label, String value) { if (target == null || isBlank(value)) { return; } target.add(firstNonBlank(label, "meta") + "> " + clip(value, 92)); } private String renderOverlay(TuiInteractionState state) { if (hasPendingApproval(state)) { return renderModal("Approve command", buildApprovalLines(state)); } if (state != null && state.isProcessInspectorOpen()) { return renderModal(buildProcessOverlayTitle(state), buildProcessInspectorLines(state)); } if (state != null && state.isReplayViewerOpen()) { return renderModal("History", buildReplayViewerLines(state)); } if (state != null && state.isTeamBoardOpen()) { return renderModal("Team Board", buildTeamBoardViewerLines(state)); } if (state != null && state.isPaletteOpen() && state.getPaletteMode() != TuiInteractionState.PaletteMode.SLASH) { return renderModal("Commands", buildPaletteLines(state)); } return null; } private String renderComposerAddon(TuiInteractionState state) { if (state != null && state.isPaletteOpen() && state.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) { return renderInlinePalette(buildPaletteLines(state)); } return null; } private String renderModal(String title, List lines) { StringBuilder out = new StringBuilder(); out.append(TuiAnsi.bold(TuiAnsi.fg(BULLET_PREFIX + firstNonBlank(title, "dialog"), theme.getMuted(), ansi), ansi)); if (lines == null || lines.isEmpty()) { out.append('\n').append(TuiAnsi.fg(" (none)", theme.getMuted(), ansi)); } else { for (String line : lines) { out.append('\n').append(renderModalLine(line)); } } return out.toString(); } private String renderInlinePalette(List lines) { if (lines == null || lines.isEmpty()) { return TuiAnsi.fg(" (no matches)", theme.getMuted(), ansi); } StringBuilder out = new StringBuilder(); for (int i = 0; i < lines.size(); i++) { if (i > 0) { out.append('\n'); } out.append(styleLine(lines.get(i))); } return out.toString(); } private List buildApprovalLines(TuiInteractionState state) { TuiInteractionState.ApprovalSnapshot snapshot = state == null ? null : state.getApprovalSnapshot(); if (snapshot == null) { return Collections.singletonList("(none)"); } List lines = new ArrayList(); String command = extractApprovalCommand(snapshot.getSummary()); if (!isBlank(command)) { lines.add("`" + clip(command, 108) + "`"); } else if (!isBlank(snapshot.getSummary())) { lines.add(clip(snapshot.getSummary(), 108)); } lines.add("Y approve N reject Esc close"); return lines; } private String buildProcessOverlayTitle(TuiInteractionState state) { String processId = firstNonBlank(state == null ? null : state.getSelectedProcessId(), inspectedProcess == null ? null : inspectedProcess.getProcessId(), "process"); return "Process " + clip(processId, 32); } private List buildProcessInspectorLines(TuiInteractionState state) { if (inspectedProcess == null) { return Collections.singletonList("(no process selected)"); } List lines = new ArrayList(); lines.add("status " + firstNonBlank(inspectedProcess.getStatus() == null ? null : inspectedProcess.getStatus().name(), "unknown").toLowerCase(Locale.ROOT)); if (!isBlank(inspectedProcess.getWorkingDirectory())) { lines.add("cwd " + clip(inspectedProcess.getWorkingDirectory(), 104)); } if (!isBlank(inspectedProcess.getCommand())) { lines.add("cmd> " + clip(inspectedProcess.getCommand(), 103)); } if (inspectedProcessLogs != null && !isBlank(inspectedProcessLogs.getContent())) { appendMultiline(lines, inspectedProcessLogs.getContent(), MAX_PROCESS_LOG_LINES, 108); } if (state != null && inspectedProcess.isControlAvailable()) { lines.add("stdin> " + clip(state.getProcessInputBuffer(), 101)); } return lines; } private List buildReplayViewerLines(TuiInteractionState state) { if (cachedReplay == null || cachedReplay.isEmpty()) { return Collections.singletonList("(none)"); } int offset = state == null ? 0 : Math.max(0, state.getReplayScrollOffset()); int from = Math.min(offset, Math.max(0, cachedReplay.size() - 1)); int to = Math.min(cachedReplay.size(), from + MAX_REPLAY_LINES); List lines = new ArrayList(); if (from > 0) { lines.add("..."); } for (int i = from; i < to; i++) { lines.add(clip(normalizeLegacyTranscriptLine(cachedReplay.get(i)), 108)); } if (to < cachedReplay.size()) { lines.add("..."); } lines.add("\u2191/\u2193 scroll Esc close"); return lines; } private List buildTeamBoardViewerLines(TuiInteractionState state) { if (cachedTeamBoard == null || cachedTeamBoard.isEmpty()) { return Collections.singletonList("(none)"); } int offset = state == null ? 0 : Math.max(0, state.getTeamBoardScrollOffset()); int from = Math.min(offset, Math.max(0, cachedTeamBoard.size() - 1)); int to = Math.min(cachedTeamBoard.size(), from + MAX_REPLAY_LINES); List lines = new ArrayList(); if (from > 0) { lines.add("..."); } for (int i = from; i < to; i++) { lines.add(clip(cachedTeamBoard.get(i), 108)); } if (to < cachedTeamBoard.size()) { lines.add("..."); } lines.add("\u2191/\u2193 scroll Esc close"); return lines; } private List buildPaletteLines(TuiInteractionState state) { List items = state == null ? Collections.emptyList() : state.getPaletteItems(); if (items == null || items.isEmpty()) { return Collections.singletonList("(no matches)"); } List lines = new ArrayList(); boolean slashMode = state != null && state.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH; int selectedIndex = state == null ? 0 : Math.max(0, Math.min(state.getPaletteSelectedIndex(), items.size() - 1)); int windowSize = Math.max(1, Math.min(items.size(), MAX_OVERLAY_ITEMS)); int from = Math.max(0, Math.min(selectedIndex - (windowSize / 2), items.size() - windowSize)); int to = Math.min(items.size(), from + windowSize); if (from > 0) { lines.add("..."); } for (int i = from; i < to; i++) { TuiPaletteItem item = items.get(i); String prefix = i == selectedIndex ? "> " : " "; String label = slashMode ? firstNonBlank(item.getCommand(), item.getLabel()) : firstNonBlank(item.getCommand(), item.getLabel()); String detail = clip(firstNonBlank(item.getDetail(), ""), 72); String value = prefix + clip(label, 32); if (!isBlank(detail)) { value = clip(value + " " + detail, 108); } lines.add(value); } if (to < items.size()) { lines.add("..."); } return lines; } private String renderComposer(TuiInteractionState state) { String prompt = state != null && state.isProcessInspectorOpen() ? "stdin> " : "> "; String buffer = resolveActiveInputBuffer(state); if (isBlank(buffer) && (state == null || !state.isProcessInspectorOpen())) { StringBuilder line = new StringBuilder(); line.append(TuiAnsi.bold(TuiAnsi.fg(prompt, theme.getText(), ansi), ansi)); line.append(TuiAnsi.fg("Type a request or `/` for commands", theme.getMuted(), ansi)); return line.toString(); } return TuiAnsi.bold(TuiAnsi.fg(clip(prompt + defaultText(buffer, ""), 120), theme.getText(), ansi), ansi); } private String renderModalLine(String line) { String normalized = normalizeLegacyTranscriptLine(line); if (isBlank(normalized)) { return ""; } if (normalized.startsWith("> ")) { return styleLine(normalized); } if ("...".equals(normalized) || normalized.startsWith("\u2191/\u2193 ")) { return TuiAnsi.fg(" " + normalized, theme.getMuted(), ansi); } return styleLine(" " + normalized); } private void appendLines(StringBuilder out, List lines) { for (int i = 0; i < lines.size(); i++) { if (i > 0) { out.append('\n'); } out.append(styleLine(lines.get(i))); } } private String styleLine(String line) { if (isBlank(line)) { return ""; } String normalizedLine = stripStatusPrefix(line); String reasoningBody = extractReasoningBody(line); if (reasoningBody != null) { return renderReasoningLine(line, reasoningBody); } String assistantBody = extractAssistantBody(line); if (assistantBody != null) { return renderAssistantLine(line, assistantBody); } if (line.startsWith(ERROR_LABEL) || normalizedLine.startsWith("\u2022 Command failed ") || normalizedLine.startsWith("\u2022 Tool failed ") || normalizedLine.startsWith("command failed") || normalizedLine.startsWith("tool failed") || normalizedLine.startsWith("turn failed")) { return TuiAnsi.fg(line, theme.getDanger(), ansi); } String lowerLine = normalizedLine.toLowerCase(Locale.ROOT); if (normalizedLine.startsWith("\u2022 Running ") || normalizedLine.startsWith("\u2022 Reading ") || normalizedLine.startsWith("\u2022 Applying") || normalizedLine.startsWith("\u2022 Writing to process") || normalizedLine.startsWith("\u2022 Stopping process") || normalizedLine.startsWith("thinking") || normalizedLine.startsWith("planning") || normalizedLine.startsWith("reading") || normalizedLine.startsWith("running") || normalizedLine.startsWith("applying") || normalizedLine.startsWith("writing to process") || normalizedLine.startsWith("stopping process") || normalizedLine.startsWith("working") || normalizedLine.startsWith("$ ")) { return TuiAnsi.fg(line, theme.getAccent(), ansi); } if (normalizedLine.startsWith("\u2022 Ran ") || normalizedLine.startsWith("\u2022 Read ") || normalizedLine.startsWith("\u2022 Applied patch") || lowerLine.startsWith("ran ") || lowerLine.startsWith("read `") || lowerLine.startsWith("applied patch")) { return TuiAnsi.fg(line, theme.getSuccess(), ansi); } if (line.startsWith(NOTE_LABEL) || line.startsWith(PROCESS_LABEL)) { return TuiAnsi.fg(line, theme.getMuted(), ansi); } if (line.startsWith("> ")) { return TuiAnsi.bold(TuiAnsi.fg(line, theme.getBrand(), ansi), ansi); } if (line.startsWith(" \u2502 ") || line.startsWith(" \u2514 ") || line.startsWith(" ")) { return TuiAnsi.fg(line, theme.getMuted(), ansi); } return TuiAnsi.fg(line, theme.getText(), ansi); } private String normalizeStatusPhrase(String detail, TuiAssistantPhase phase) { String normalized = trimToNull(detail); if (!isBlank(normalized)) { String lower = normalized.toLowerCase(Locale.ROOT); if (lower.startsWith("thinking about:") || lower.startsWith("waiting for model output") || lower.startsWith("streaming model output") || lower.startsWith("tool finished, continuing")) { return "thinking..."; } if (lower.startsWith("preparing next step")) { return "planning..."; } if (lower.startsWith("running command")) { return "running command..."; } if (lower.startsWith("reading file content")) { return "reading files..."; } if (lower.startsWith("applying workspace patch")) { return "applying patch..."; } if (lower.startsWith("fetching buffered logs")) { return "reading logs..."; } if (lower.startsWith("refreshing process metadata")) { return "reading process state..."; } if (lower.startsWith("writing to process stdin")) { return "writing to process..."; } if (lower.startsWith("stopping process")) { return "stopping process..."; } if (lower.startsWith("turn failed") || lower.startsWith("agent run failed")) { return clip(lower, 96); } if (lower.startsWith("turn complete")) { return null; } return clip(lower, 96); } if (phase == TuiAssistantPhase.ERROR) { return "turn failed."; } if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT) { return "working..."; } if (phase == TuiAssistantPhase.COMPLETE) { return null; } return "thinking..."; } private String extractApprovalCommand(String summary) { if (isBlank(summary)) { return null; } int commandIndex = summary.indexOf("command="); if (commandIndex >= 0) { int start = commandIndex + "command=".length(); int end = summary.indexOf(", processId=", start); if (end < 0) { end = summary.length(); } return summary.substring(start, end).trim(); } int patchIndex = summary.indexOf("patch="); if (patchIndex >= 0) { return summary.substring(patchIndex + "patch=".length()).trim(); } return null; } private String statusPrefix(TuiAssistantViewModel viewModel) { if (viewModel == null || viewModel.getPhase() == null) { return "-"; } if (viewModel.getPhase() == TuiAssistantPhase.ERROR) { return "!"; } if (viewModel.getPhase() == TuiAssistantPhase.COMPLETE) { return "*"; } long tick = System.currentTimeMillis() / 160L; int index = (int) (tick % STATUS_FRAMES.length); return STATUS_FRAMES[index]; } private String stripStatusPrefix(String line) { if (line == null || line.length() < 3) { return defaultText(line, ""); } char first = line.charAt(0); if ((first == '-' || first == '\\' || first == '|' || first == '/' || first == '!' || first == '*') && line.charAt(1) == ' ') { return line.substring(2); } return line; } private String renderAssistantLine(String line, String assistantBody) { String trimmed = assistantBody.trim(); if (assistantBody.isEmpty()) { return ""; } boolean continuation = line.startsWith(ASSISTANT_CONTINUATION_PREFIX); String visiblePrefix = continuation ? BULLET_CONTINUATION : BULLET_PREFIX; if (assistantBody.startsWith(CODE_LINE_PREFIX)) { return renderInlineMarkdown(visiblePrefix, theme.getText()) + renderCodeBlockContent(assistantBody.substring(CODE_LINE_PREFIX.length())); } if (trimmed.startsWith("#")) { return renderInlineMarkdown(visiblePrefix, theme.getBrand()) + TuiAnsi.bold(renderInlineMarkdown(assistantBody, theme.getBrand()), ansi); } if (trimmed.startsWith(">")) { return renderInlineMarkdown(visiblePrefix, theme.getMuted()) + renderInlineMarkdown(assistantBody, theme.getMuted()); } return renderInlineMarkdown(visiblePrefix, theme.getText()) + renderInlineMarkdown(assistantBody, theme.getText()); } private String renderReasoningLine(String line, String reasoningBody) { String trimmed = reasoningBody.trim(); if (reasoningBody.isEmpty()) { return ""; } boolean continuation = line.startsWith(REASONING_CONTINUATION_PREFIX); String visiblePrefix = continuation ? repeat(' ', THINKING_LABEL.length()) : THINKING_LABEL; if (reasoningBody.startsWith(CODE_LINE_PREFIX)) { return renderInlineMarkdown(visiblePrefix, theme.getMuted()) + renderCodeBlockContent(reasoningBody.substring(CODE_LINE_PREFIX.length())); } return renderInlineMarkdown(visiblePrefix, theme.getMuted()) + renderInlineMarkdown(reasoningBody, theme.getMuted()); } private String renderInlineMarkdown(String text, String baseColor) { if (text == null) { return ""; } if (!ansi) { return stripInlineMarkdown(text); } StringBuilder out = new StringBuilder(); StringBuilder buffer = new StringBuilder(); boolean bold = false; boolean code = false; for (int i = 0; i < text.length(); i++) { char current = text.charAt(i); if (current == '*' && i + 1 < text.length() && text.charAt(i + 1) == '*') { appendStyledSegment(out, buffer.toString(), baseColor, bold, code); buffer.setLength(0); bold = !bold; i++; continue; } if (current == '`') { appendStyledSegment(out, buffer.toString(), baseColor, bold, code); buffer.setLength(0); code = !code; continue; } buffer.append(current); } appendStyledSegment(out, buffer.toString(), baseColor, bold, code); return out.toString(); } private void appendStyledSegment(StringBuilder out, String segment, String baseColor, boolean bold, boolean code) { if (out == null || segment == null || segment.isEmpty()) { return; } String color = code ? theme.getAccent() : baseColor; String rendered = TuiAnsi.style(segment, color, null, ansi, bold || code); out.append(rendered); } private String renderCodeBlockContent(String content) { if (!ansi) { return content == null ? "" : content; } return renderCodeTokens(content); } private String renderCodeTokens(String content) { if (content == null || content.isEmpty()) { return ""; } StringBuilder out = new StringBuilder(); int index = 0; while (index < content.length()) { char current = content.charAt(index); if (Character.isWhitespace(current)) { int end = index + 1; while (end < content.length() && Character.isWhitespace(content.charAt(end))) { end++; } appendCodeToken(out, content.substring(index, end), codeTextColor(), false); index = end; continue; } if (current == '/' && index + 1 < content.length() && content.charAt(index + 1) == '/') { appendCodeToken(out, content.substring(index), codeCommentColor(), false); break; } if (current == '#' && isHashCommentStart(content, index)) { appendCodeToken(out, content.substring(index), codeCommentColor(), false); break; } if (current == '"' || current == '\'') { int end = findStringEnd(content, index); appendCodeToken(out, content.substring(index, end), codeStringColor(), false); index = end; continue; } if (isNumberStart(content, index)) { int end = findNumberEnd(content, index); appendCodeToken(out, content.substring(index, end), codeNumberColor(), false); index = end; continue; } if (isWordStart(current)) { int end = index + 1; while (end < content.length() && isWordPart(content.charAt(end))) { end++; } String token = content.substring(index, end); boolean keyword = isCodeKeyword(token); appendCodeToken(out, token, keyword ? codeKeywordColor() : codeTextColor(), keyword); index = end; continue; } appendCodeToken(out, String.valueOf(current), codeTextColor(), false); index++; } return out.toString(); } private void appendCodeToken(StringBuilder out, String token, String foreground, boolean bold) { if (out == null || token == null || token.isEmpty()) { return; } out.append(TuiAnsi.style(token, foreground, codeBackgroundColor(), ansi, bold)); } private boolean isCodeKeyword(String token) { if (isBlank(token)) { return false; } return CODE_KEYWORDS.contains(token) || CODE_KEYWORDS.contains(token.toLowerCase(Locale.ROOT)); } private boolean isHashCommentStart(String content, int index) { return index == 0 || Character.isWhitespace(content.charAt(index - 1)); } private int findStringEnd(String content, int start) { char quote = content.charAt(start); int index = start + 1; boolean escaped = false; while (index < content.length()) { char current = content.charAt(index); if (escaped) { escaped = false; } else if (current == '\\') { escaped = true; } else if (current == quote) { return index + 1; } index++; } return content.length(); } private boolean isNumberStart(String content, int index) { char current = content.charAt(index); if (Character.isDigit(current)) { return true; } return current == '-' && index + 1 < content.length() && Character.isDigit(content.charAt(index + 1)); } private int findNumberEnd(String content, int start) { int index = start; if (content.charAt(index) == '-') { index++; } if (index + 1 < content.length() && content.charAt(index) == '0' && (content.charAt(index + 1) == 'x' || content.charAt(index + 1) == 'X')) { index += 2; while (index < content.length() && isHexDigit(content.charAt(index))) { index++; } return index; } while (index < content.length()) { char current = content.charAt(index); if (Character.isDigit(current) || current == '_' || current == '.' || current == 'e' || current == 'E' || current == '+' || current == '-' || current == 'f' || current == 'F' || current == 'd' || current == 'D' || current == 'l' || current == 'L') { index++; continue; } break; } return index; } private boolean isHexDigit(char value) { return Character.isDigit(value) || (value >= 'a' && value <= 'f') || (value >= 'A' && value <= 'F') || value == '_'; } private boolean isWordStart(char value) { return Character.isLetter(value) || value == '_' || value == '$'; } private boolean isWordPart(char value) { return Character.isLetterOrDigit(value) || value == '_' || value == '$'; } private String codeBackgroundColor() { return firstNonBlank(theme == null ? null : theme.getCodeBackground(), "#111827"); } private String codeTextColor() { return firstNonBlank(theme == null ? null : theme.getCodeText(), theme == null ? null : theme.getText(), "#f3f4f6"); } private String codeKeywordColor() { return firstNonBlank(theme == null ? null : theme.getCodeKeyword(), theme == null ? null : theme.getBrand(), "#7cc6fe"); } private String codeStringColor() { return firstNonBlank(theme == null ? null : theme.getCodeString(), theme == null ? null : theme.getSuccess(), "#8fd694"); } private String codeCommentColor() { return firstNonBlank(theme == null ? null : theme.getCodeComment(), theme == null ? null : theme.getMuted(), "#9ca3af"); } private String codeNumberColor() { return firstNonBlank(theme == null ? null : theme.getCodeNumber(), theme == null ? null : theme.getAccent(), "#f5b14c"); } private String stripInlineMarkdown(String text) { if (text == null) { return ""; } return text.replace("**", "").replace("`", ""); } private String normalizeLegacyTranscriptLine(String line) { if (line == null) { return null; } if (line.startsWith("you> ")) { return BULLET_PREFIX + line.substring("you> ".length()); } if (line.startsWith("assistant> ")) { return BULLET_PREFIX + line.substring("assistant> ".length()); } if (line.startsWith("thinking> ")) { return THINKING_LABEL + line.substring("thinking> ".length()); } return line; } private String extractAssistantBody(String line) { if (line == null) { return null; } if (line.startsWith(ASSISTANT_PREFIX)) { return line.substring(ASSISTANT_PREFIX.length()); } if (line.startsWith(ASSISTANT_CONTINUATION_PREFIX)) { return line.substring(ASSISTANT_CONTINUATION_PREFIX.length()); } return null; } private String extractReasoningBody(String line) { if (line == null) { return null; } if (line.startsWith(REASONING_PREFIX)) { return line.substring(REASONING_PREFIX.length()); } if (line.startsWith(REASONING_CONTINUATION_PREFIX)) { return line.substring(REASONING_CONTINUATION_PREFIX.length()); } return null; } private String resolveActiveInputBuffer(TuiInteractionState state) { if (state == null) { return ""; } if (state.isProcessInspectorOpen()) { return state.getProcessInputBuffer(); } return state.getInputBuffer(); } private boolean hasPendingApproval(TuiInteractionState state) { TuiInteractionState.ApprovalSnapshot snapshot = state == null ? null : state.getApprovalSnapshot(); return snapshot != null && snapshot.isPending(); } private String lastAssistantMessage() { if (cachedEvents == null || cachedEvents.isEmpty()) { return null; } for (int i = cachedEvents.size() - 1; i >= 0; i--) { SessionEvent event = cachedEvents.get(i); if (event != null && event.getType() == SessionEventType.ASSISTANT_MESSAGE) { return trimToNull(firstNonBlank(payloadString(event.getPayload(), "output"), event.getSummary())); } } return null; } private void appendRecentSessionHints(List lines) { if (lines == null || cachedSessions == null || cachedSessions.isEmpty()) { return; } int limit = Math.min(2, cachedSessions.size()); for (int i = 0; i < limit; i++) { CodingSessionDescriptor session = cachedSessions.get(i); if (session == null) { continue; } String model = trimToNull(session.getModel()); String workspace = trimToNull(lastPathSegment(session.getWorkspace())); String sessionId = shortenSessionId(session.getSessionId()); StringBuilder line = new StringBuilder(STARTUP_LABEL).append("Recent "); if (!isBlank(sessionId)) { line.append(sessionId); } else { line.append("session"); } if (!isBlank(workspace)) { line.append(" ").append(workspace); } if (!isBlank(model)) { line.append(" ").append(model); } lines.add(clip(line.toString(), 108)); } } private String shortenSessionId(String sessionId) { String value = trimToNull(sessionId); if (isBlank(value)) { return null; } if (value.length() <= 12) { return value; } return value.substring(0, 12); } private String payloadString(Map payload, String key) { if (payload == null || isBlank(key)) { return null; } Object value = payload.get(key); return value == null ? null : String.valueOf(value); } private List payloadLines(Map payload, String key) { if (payload == null || isBlank(key)) { return new ArrayList(); } Object value = payload.get(key); if (value == null) { return new ArrayList(); } if (value instanceof Iterable) { return toStringLines((Iterable) value); } if (value instanceof String) { String raw = (String) value; if (isBlank(raw)) { return new ArrayList(); } try { return toStringLines(JSON.parseArray(raw)); } catch (Exception ignored) { return new ArrayList(); } } return new ArrayList(); } private List toStringLines(Iterable source) { List lines = new ArrayList(); if (source == null) { return lines; } for (Object item : source) { if (item == null) { continue; } String line = String.valueOf(item); if (!isBlank(line)) { lines.add(line); } } return lines; } private void appendWrappedText(List lines, String prefix, String rawText, int maxLines, int maxChars) { if (lines == null || isBlank(rawText) || maxLines <= 0) { return; } String[] rawLines = rawText.replace("\r", "").split("\n"); String continuation = repeat(' ', prefix == null ? 0 : prefix.length()); int count = 0; for (String rawLine : rawLines) { if (count >= maxLines) { lines.add(continuation + "..."); return; } String safeLine = clip(rawLine, maxChars); lines.add((count == 0 ? defaultText(prefix, "") : continuation) + safeLine); count++; } } private void appendBlankLineIfNeeded(List lines) { if (lines == null || lines.isEmpty()) { return; } if (!isBlank(lines.get(lines.size() - 1))) { lines.add(""); } } private List wrapPrefixedText(String firstPrefix, String continuationPrefix, String rawText, int maxWidth) { List lines = new ArrayList(); if (isBlank(rawText)) { return lines; } String first = firstPrefix == null ? "" : firstPrefix; String continuation = continuationPrefix == null ? "" : continuationPrefix; int firstWidth = Math.max(12, maxWidth - first.length()); int continuationWidth = Math.max(12, maxWidth - continuation.length()); boolean firstLine = true; String[] paragraphs = rawText.replace("\r", "").split("\n"); for (String paragraph : paragraphs) { String text = trimToNull(paragraph); if (isBlank(text)) { continue; } while (!isBlank(text)) { int width = firstLine ? firstWidth : continuationWidth; int split = findWrapIndex(text, width); String chunk = text.substring(0, split).trim(); lines.add((firstLine ? first : continuation) + chunk); text = text.substring(split).trim(); firstLine = false; } } return lines; } private int findWrapIndex(String text, int width) { if (isBlank(text) || text.length() <= width) { return text == null ? 0 : text.length(); } int whitespace = -1; for (int i = Math.min(width, text.length() - 1); i >= 0; i--) { if (Character.isWhitespace(text.charAt(i))) { whitespace = i; break; } } return whitespace > 0 ? whitespace : width; } private void appendMultiline(List lines, String rawContent, int maxLines, int maxChars) { if (lines == null || isBlank(rawContent) || maxLines <= 0) { return; } String[] rawLines = rawContent.replace("\r", "").split("\n"); int count = 0; for (String rawLine : rawLines) { if (count >= maxLines) { lines.add("..."); return; } if (!isBlank(rawLine)) { lines.add(clip(rawLine, maxChars)); count++; } } } private List copyLines(List lines, int maxLines) { if (lines == null || lines.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(lines); return copy.size() > maxLines ? new ArrayList(copy.subList(0, maxLines)) : copy; } private List trimObjects(List source, int maxEntries) { if (source == null || source.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(source); return copy.size() > maxEntries ? new ArrayList(copy.subList(0, maxEntries)) : copy; } private List tailObjects(List source, int maxEntries) { if (source == null || source.isEmpty()) { return Collections.emptyList(); } List copy = new ArrayList(source); return copy.size() > maxEntries ? new ArrayList(copy.subList(copy.size() - maxEntries, copy.size())) : copy; } private List tailLines(List source, int maxEntries) { if (source == null || source.isEmpty()) { return Collections.emptyList(); } return source.size() > maxEntries ? new ArrayList(source.subList(source.size() - maxEntries, source.size())) : new ArrayList(source); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private String defaultText(String value, String fallback) { return isBlank(value) ? fallback : value; } private String trimToNull(String value) { return isBlank(value) ? null : value.trim(); } private String lastPathSegment(String value) { if (isBlank(value)) { return value; } String normalized = value.replace('\\', '/'); int index = normalized.lastIndexOf('/'); return index >= 0 && index + 1 < normalized.length() ? normalized.substring(index + 1) : normalized; } private String repeat(char c, int count) { if (count <= 0) { return ""; } StringBuilder builder = new StringBuilder(count); for (int i = 0; i < count; i++) { builder.append(c); } return builder.toString(); } private String line(char c, int width) { return repeat(c, width); } private String clip(String value, int maxChars) { if (value == null) { return ""; } String normalized = value.replace('\r', ' ').replace('\n', ' ').trim(); return normalized.length() <= maxChars ? normalized : normalized.substring(0, maxChars) + "..."; } private String clipCodeLine(String value, int maxChars) { if (value == null) { return ""; } String normalized = value.replace('\r', ' ').replace('\t', ' '); return normalized.length() <= maxChars ? normalized : normalized.substring(0, maxChars) + "..."; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiTheme.java ================================================ package io.github.lnyocly.ai4j.tui; public class TuiTheme { private String name; private String brand; private String accent; private String success; private String warning; private String danger; private String text; private String muted; private String panelBorder; private String panelTitle; private String badgeForeground; private String codeBackground; private String codeBorder; private String codeText; private String codeKeyword; private String codeString; private String codeComment; private String codeNumber; public String getName() { return name; } public void setName(String name) { this.name = name; } public String getBrand() { return brand; } public void setBrand(String brand) { this.brand = brand; } public String getAccent() { return accent; } public void setAccent(String accent) { this.accent = accent; } public String getSuccess() { return success; } public void setSuccess(String success) { this.success = success; } public String getWarning() { return warning; } public void setWarning(String warning) { this.warning = warning; } public String getDanger() { return danger; } public void setDanger(String danger) { this.danger = danger; } public String getText() { return text; } public void setText(String text) { this.text = text; } public String getMuted() { return muted; } public void setMuted(String muted) { this.muted = muted; } public String getPanelBorder() { return panelBorder; } public void setPanelBorder(String panelBorder) { this.panelBorder = panelBorder; } public String getPanelTitle() { return panelTitle; } public void setPanelTitle(String panelTitle) { this.panelTitle = panelTitle; } public String getBadgeForeground() { return badgeForeground; } public void setBadgeForeground(String badgeForeground) { this.badgeForeground = badgeForeground; } public String getCodeBackground() { return codeBackground; } public void setCodeBackground(String codeBackground) { this.codeBackground = codeBackground; } public String getCodeBorder() { return codeBorder; } public void setCodeBorder(String codeBorder) { this.codeBorder = codeBorder; } public String getCodeText() { return codeText; } public void setCodeText(String codeText) { this.codeText = codeText; } public String getCodeKeyword() { return codeKeyword; } public void setCodeKeyword(String codeKeyword) { this.codeKeyword = codeKeyword; } public String getCodeString() { return codeString; } public void setCodeString(String codeString) { this.codeString = codeString; } public String getCodeComment() { return codeComment; } public void setCodeComment(String codeComment) { this.codeComment = codeComment; } public String getCodeNumber() { return codeNumber; } public void setCodeNumber(String codeNumber) { this.codeNumber = codeNumber; } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/io/DefaultJlineTerminalIO.java ================================================ package io.github.lnyocly.ai4j.tui.io; import io.github.lnyocly.ai4j.tui.StreamsTerminalIO; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiKeyStroke; import io.github.lnyocly.ai4j.tui.TuiKeyType; import org.jline.reader.EndOfFileException; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; import org.jline.reader.UserInterruptException; import org.jline.terminal.Attributes; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; import org.jline.utils.InfoCmp; import org.jline.utils.NonBlockingReader; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public class DefaultJlineTerminalIO implements TerminalIO { private static final long ESCAPE_SEQUENCE_TIMEOUT_MS = 25L; private final Terminal terminal; private final LineReader lineReader; private final NonBlockingReader reader; private final PrintWriter out; private final PrintWriter err; private Attributes originalAttributes; private boolean rawMode; private boolean closed; private boolean inputClosed; private Integer pendingChar; protected DefaultJlineTerminalIO(Terminal terminal, OutputStream errStream) { this.terminal = terminal; this.lineReader = LineReaderBuilder.builder().terminal(terminal).build(); this.reader = terminal.reader(); this.out = terminal.writer(); Charset charset = terminal.encoding() == null ? StandardCharsets.UTF_8 : terminal.encoding(); this.err = new PrintWriter(new OutputStreamWriter(errStream, charset), true); } public static DefaultJlineTerminalIO openSystem(OutputStream errStream) throws IOException { Charset charset = StreamsTerminalIO.resolveTerminalCharset(); Terminal terminal = TerminalBuilder.builder() .system(true) .encoding(charset) .build(); return new DefaultJlineTerminalIO(terminal, errStream); } @Override public String readLine(String prompt) throws IOException { restoreRawMode(); print(prompt == null ? "" : prompt); StringBuilder builder = new StringBuilder(); while (true) { int ch; try { ch = readChar(); } catch (EndOfFileException ex) { inputClosed = true; return builder.length() == 0 ? null : builder.toString(); } catch (UserInterruptException ex) { return ""; } if (ch < 0) { inputClosed = true; return builder.length() == 0 ? null : builder.toString(); } if (ch == '\n') { return builder.toString(); } if (ch == '\r') { int next = readChar(5L); if (next >= 0 && next != '\n') { unreadChar(next); } return builder.toString(); } builder.append((char) ch); } } @Override public synchronized TuiKeyStroke readKeyStroke() throws IOException { ensureRawMode(); int ch = readChar(); return toKeyStroke(ch); } @Override public synchronized TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException { ensureRawMode(); int ch = timeoutMs <= 0L ? readChar() : readChar(timeoutMs); if (ch == NonBlockingReader.READ_EXPIRED) { return null; } return toKeyStroke(ch); } @Override public void print(String message) { out.print(message == null ? "" : message); out.flush(); terminal.flush(); } @Override public void println(String message) { out.println(message == null ? "" : message); out.flush(); terminal.flush(); } @Override public void errorln(String message) { err.println(message == null ? "" : message); err.flush(); } @Override public boolean supportsAnsi() { return terminal != null && !"dumb".equalsIgnoreCase(terminal.getType()); } @Override public boolean supportsRawInput() { return terminal != null; } @Override public synchronized boolean isInputClosed() { return inputClosed; } @Override public void clearScreen() { puts(InfoCmp.Capability.clear_screen); } @Override public void enterAlternateScreen() { puts(InfoCmp.Capability.enter_ca_mode); } @Override public void exitAlternateScreen() { puts(InfoCmp.Capability.exit_ca_mode); } @Override public void hideCursor() { puts(InfoCmp.Capability.cursor_invisible); } @Override public void showCursor() { puts(InfoCmp.Capability.cursor_normal); } @Override public void moveCursorHome() { puts(InfoCmp.Capability.cursor_home); } @Override public int getTerminalRows() { return terminal == null ? 0 : Math.max(0, terminal.getHeight()); } @Override public int getTerminalColumns() { return terminal == null ? 0 : Math.max(0, terminal.getWidth()); } @Override public synchronized void close() throws IOException { if (closed) { return; } restoreRawMode(); terminal.close(); closed = true; } private TuiKeyStroke toKeyStroke(int ch) throws IOException { if (ch < 0) { inputClosed = true; return null; } switch (ch) { case '\r': case '\n': return TuiKeyStroke.of(TuiKeyType.ENTER); case '\t': return TuiKeyStroke.of(TuiKeyType.TAB); case '\b': case 127: return TuiKeyStroke.of(TuiKeyType.BACKSPACE); case 12: return TuiKeyStroke.of(TuiKeyType.CTRL_L); case 16: return TuiKeyStroke.of(TuiKeyType.CTRL_P); case 18: return TuiKeyStroke.of(TuiKeyType.CTRL_R); case 27: return readEscapeSequence(); default: if (Character.isISOControl((char) ch)) { return TuiKeyStroke.of(TuiKeyType.UNKNOWN); } return TuiKeyStroke.character(String.valueOf((char) ch)); } } private TuiKeyStroke readEscapeSequence() throws IOException { int next = readChar(ESCAPE_SEQUENCE_TIMEOUT_MS); if (next == NonBlockingReader.READ_EXPIRED || next < 0) { return TuiKeyStroke.of(TuiKeyType.ESCAPE); } if (next != '[' && next != 'O') { unreadChar(next); return TuiKeyStroke.of(TuiKeyType.ESCAPE); } int code = readChar(ESCAPE_SEQUENCE_TIMEOUT_MS); if (code == NonBlockingReader.READ_EXPIRED || code < 0) { return TuiKeyStroke.of(TuiKeyType.ESCAPE); } switch (code) { case 'A': return TuiKeyStroke.of(TuiKeyType.ARROW_UP); case 'B': return TuiKeyStroke.of(TuiKeyType.ARROW_DOWN); case 'C': return TuiKeyStroke.of(TuiKeyType.ARROW_RIGHT); case 'D': return TuiKeyStroke.of(TuiKeyType.ARROW_LEFT); default: return TuiKeyStroke.of(TuiKeyType.ESCAPE); } } private void ensureRawMode() { if (rawMode || terminal == null) { return; } originalAttributes = terminal.enterRawMode(); rawMode = true; } private void restoreRawMode() { if (!rawMode || terminal == null || originalAttributes == null) { return; } terminal.setAttributes(originalAttributes); rawMode = false; } private void puts(InfoCmp.Capability capability) { if (!supportsAnsi() || capability == null) { return; } terminal.puts(capability); terminal.flush(); } private int readChar() throws IOException { if (pendingChar != null) { int value = pendingChar.intValue(); pendingChar = null; return value; } return reader.read(); } private int readChar(long timeoutMs) throws IOException { if (pendingChar != null) { int value = pendingChar.intValue(); pendingChar = null; return value; } return reader.read(timeoutMs); } private void unreadChar(int ch) { if (ch >= 0) { pendingChar = Integer.valueOf(ch); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/io/DefaultStreamsTerminalIO.java ================================================ package io.github.lnyocly.ai4j.tui.io; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiKeyStroke; import io.github.lnyocly.ai4j.tui.TuiKeyType; import java.io.Console; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.io.PushbackReader; import java.lang.reflect.Method; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public class DefaultStreamsTerminalIO implements TerminalIO { private static final String TERMINAL_ENCODING_PROPERTY = "ai4j.terminal.encoding"; private static final String TERMINAL_ENCODING_ENV = "AI4J_TERMINAL_ENCODING"; private static final String ANSI_CLEAR = "\u001b[2J\u001b[H"; private static final String ANSI_ALT_SCREEN_ON = "\u001b[?1049h"; private static final String ANSI_ALT_SCREEN_OFF = "\u001b[?1049l"; private static final String ANSI_CURSOR_HIDE = "\u001b[?25l"; private static final String ANSI_CURSOR_SHOW = "\u001b[?25h"; private static final String ANSI_CURSOR_HOME = "\u001b[H"; private final InputStream inputStream; private final PushbackReader reader; private final PrintWriter out; private final PrintWriter err; private final boolean ansiSupported; private boolean inputClosed; public DefaultStreamsTerminalIO(InputStream in, OutputStream out, OutputStream err) { this(in, out, err, resolveTerminalCharset(), detectAnsiSupport()); } protected DefaultStreamsTerminalIO(InputStream in, OutputStream out, OutputStream err, boolean ansiSupported) { this(in, out, err, resolveTerminalCharset(), ansiSupported); } protected DefaultStreamsTerminalIO(InputStream in, OutputStream out, OutputStream err, Charset charset, boolean ansiSupported) { this.inputStream = in; Charset ioCharset = charset == null ? resolveTerminalCharset() : charset; this.reader = new PushbackReader(new InputStreamReader(in, ioCharset), 8); this.out = new PrintWriter(new OutputStreamWriter(out, ioCharset), true); this.err = new PrintWriter(new OutputStreamWriter(err, ioCharset), true); this.ansiSupported = ansiSupported; } @Override public synchronized String readLine(String prompt) throws IOException { print(prompt); StringBuilder builder = new StringBuilder(); while (true) { int ch = reader.read(); if (ch < 0) { inputClosed = true; return builder.length() == 0 ? null : builder.toString(); } if (ch == '\n') { return builder.toString(); } if (ch == '\r') { int next = reader.read(); if (next >= 0 && next != '\n') { reader.unread(next); } return builder.toString(); } builder.append((char) ch); } } @Override public synchronized TuiKeyStroke readKeyStroke() throws IOException { int ch = reader.read(); if (ch < 0) { inputClosed = true; return null; } switch (ch) { case '\r': case '\n': return TuiKeyStroke.of(TuiKeyType.ENTER); case '\t': return TuiKeyStroke.of(TuiKeyType.TAB); case '\b': case 127: return TuiKeyStroke.of(TuiKeyType.BACKSPACE); case 12: return TuiKeyStroke.of(TuiKeyType.CTRL_L); case 16: return TuiKeyStroke.of(TuiKeyType.CTRL_P); case 18: return TuiKeyStroke.of(TuiKeyType.CTRL_R); case 27: return readEscapeSequence(); default: if (Character.isISOControl((char) ch)) { return TuiKeyStroke.of(TuiKeyType.UNKNOWN); } return TuiKeyStroke.character(String.valueOf((char) ch)); } } @Override public synchronized TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException { if (inputStream != System.in) { return readKeyStroke(); } if (timeoutMs <= 0L) { return readKeyStroke(); } long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { if (hasBufferedInput()) { return readKeyStroke(); } try { Thread.sleep(20L); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); return null; } } return hasBufferedInput() ? readKeyStroke() : null; } @Override public void print(String message) { out.print(message == null ? "" : message); out.flush(); } @Override public void println(String message) { out.println(message == null ? "" : message); out.flush(); } @Override public void errorln(String message) { err.println(message == null ? "" : message); err.flush(); } @Override public boolean supportsAnsi() { return ansiSupported; } @Override public boolean supportsRawInput() { return inputStream != System.in; } @Override public synchronized boolean isInputClosed() { return inputClosed; } @Override public void clearScreen() { if (!ansiSupported) { return; } out.print(ANSI_CLEAR); out.flush(); } @Override public void enterAlternateScreen() { emitAnsi(ANSI_ALT_SCREEN_ON); } @Override public void exitAlternateScreen() { emitAnsi(ANSI_ALT_SCREEN_OFF); } @Override public void hideCursor() { emitAnsi(ANSI_CURSOR_HIDE); } @Override public void showCursor() { emitAnsi(ANSI_CURSOR_SHOW); } @Override public void moveCursorHome() { emitAnsi(ANSI_CURSOR_HOME); } @Override public int getTerminalRows() { return parsePositiveInt(System.getenv("LINES"), 24); } @Override public int getTerminalColumns() { return parsePositiveInt(System.getenv("COLUMNS"), 80); } private TuiKeyStroke readEscapeSequence() throws IOException { int next = reader.read(); if (next < 0) { return TuiKeyStroke.of(TuiKeyType.ESCAPE); } if (next != '[' && next != 'O') { reader.unread(next); return TuiKeyStroke.of(TuiKeyType.ESCAPE); } int code = reader.read(); if (code < 0) { return TuiKeyStroke.of(TuiKeyType.ESCAPE); } switch (code) { case 'A': return TuiKeyStroke.of(TuiKeyType.ARROW_UP); case 'B': return TuiKeyStroke.of(TuiKeyType.ARROW_DOWN); case 'C': return TuiKeyStroke.of(TuiKeyType.ARROW_RIGHT); case 'D': return TuiKeyStroke.of(TuiKeyType.ARROW_LEFT); default: return TuiKeyStroke.of(TuiKeyType.ESCAPE); } } private void emitAnsi(String value) { if (!ansiSupported) { return; } out.print(value); out.flush(); } private boolean hasBufferedInput() throws IOException { return reader.ready() || (inputStream != null && inputStream.available() > 0); } private int parsePositiveInt(String value, int fallback) { if (value == null) { return fallback; } try { int parsed = Integer.parseInt(value.trim()); return parsed > 0 ? parsed : fallback; } catch (Exception ex) { return fallback; } } private static boolean detectAnsiSupport() { if (System.console() == null) { return false; } if (System.getenv("NO_COLOR") != null) { return false; } String os = System.getProperty("os.name", "").toLowerCase(); if (!os.contains("win")) { return true; } return System.getenv("WT_SESSION") != null || System.getenv("ANSICON") != null || "ON".equalsIgnoreCase(System.getenv("ConEmuANSI")) || hasTermSupport(System.getenv("TERM")); } private static boolean hasTermSupport(String term) { return term != null && term.toLowerCase().contains("xterm"); } public static Charset resolveTerminalCharset() { return resolveTerminalCharset( new String[]{ System.getProperty(TERMINAL_ENCODING_PROPERTY), System.getenv(TERMINAL_ENCODING_ENV) }, new String[]{ System.getProperty("stdin.encoding"), System.getProperty("sun.stdin.encoding"), System.getProperty("stdout.encoding"), System.getProperty("sun.stdout.encoding"), consoleCharsetName() }, new String[]{ System.getProperty("native.encoding"), System.getProperty("sun.jnu.encoding"), System.getProperty("file.encoding") }, shouldPreferUtf8() ); } static Charset resolveTerminalCharset(String[] explicitCandidates, String[] ioCandidates, String[] platformCandidates, boolean preferUtf8) { Charset explicit = firstSupportedCharset(explicitCandidates); if (explicit != null) { return explicit; } Charset io = firstSupportedCharset(ioCandidates); if (io != null) { return io; } if (preferUtf8) { return StandardCharsets.UTF_8; } Charset platform = firstSupportedCharset(platformCandidates); if (platform != null) { return platform; } return Charset.defaultCharset(); } private static Charset firstSupportedCharset(String[] candidates) { if (candidates == null) { return null; } for (String candidate : candidates) { Charset resolved = toCharset(candidate); if (resolved != null) { return resolved; } } return null; } private static boolean shouldPreferUtf8() { return hasUtf8Locale(System.getenv("LC_ALL")) || hasUtf8Locale(System.getenv("LC_CTYPE")) || hasUtf8Locale(System.getenv("LANG")) || System.getenv("WT_SESSION") != null || hasTermSupport(System.getenv("TERM")); } private static boolean hasUtf8Locale(String value) { return value != null && value.toUpperCase().contains("UTF-8"); } private static String consoleCharsetName() { Console console = System.console(); if (console == null) { return null; } try { Method method = console.getClass().getMethod("charset"); Object value = method.invoke(console); if (value instanceof Charset) { return ((Charset) value).name(); } } catch (Exception ignored) { return null; } return null; } private static Charset toCharset(String value) { if (isBlank(value)) { return null; } try { return Charset.forName(value.trim()); } catch (Exception ignored) { return null; } } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/runtime/DefaultAnsiTuiRuntime.java ================================================ package io.github.lnyocly.ai4j.tui.runtime; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiKeyStroke; import io.github.lnyocly.ai4j.tui.TuiRenderer; import io.github.lnyocly.ai4j.tui.TuiRuntime; import io.github.lnyocly.ai4j.tui.TuiScreenModel; import java.io.IOException; public class DefaultAnsiTuiRuntime implements TuiRuntime { private final TerminalIO terminal; private final TuiRenderer renderer; private final boolean useAlternateScreen; private String lastFrame; public DefaultAnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer) { this(terminal, renderer, true); } public DefaultAnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer, boolean useAlternateScreen) { this.terminal = terminal; this.renderer = renderer; this.useAlternateScreen = useAlternateScreen; } @Override public boolean supportsRawInput() { return terminal != null && terminal.supportsRawInput(); } @Override public void enter() { if (terminal == null) { return; } lastFrame = null; if (useAlternateScreen) { terminal.enterAlternateScreen(); } terminal.hideCursor(); } @Override public void exit() { if (terminal == null) { return; } lastFrame = null; terminal.showCursor(); if (useAlternateScreen) { terminal.exitAlternateScreen(); } } @Override public TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException { return terminal == null ? null : terminal.readKeyStroke(timeoutMs); } @Override public synchronized void render(TuiScreenModel screenModel) { if (terminal == null || renderer == null) { return; } String frame = renderer.render(screenModel); if (frame == null) { frame = ""; } if (frame.equals(lastFrame)) { return; } lastFrame = frame; if (useAlternateScreen) { terminal.clearScreen(); terminal.print(frame); return; } terminal.moveCursorHome(); terminal.print(frame); if (terminal.supportsAnsi()) { terminal.print("\u001b[J"); } } } ================================================ FILE: ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/runtime/DefaultAppendOnlyTuiRuntime.java ================================================ package io.github.lnyocly.ai4j.tui.runtime; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiAssistantPhase; import io.github.lnyocly.ai4j.tui.TuiAssistantToolView; import io.github.lnyocly.ai4j.tui.TuiAssistantViewModel; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import io.github.lnyocly.ai4j.tui.TuiKeyStroke; import io.github.lnyocly.ai4j.tui.TuiPaletteItem; import io.github.lnyocly.ai4j.tui.TuiRuntime; import io.github.lnyocly.ai4j.tui.TuiScreenModel; import org.jline.utils.WCWidth; import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; public class DefaultAppendOnlyTuiRuntime implements TuiRuntime { private static final String ESC = "\u001b["; private static final String RESET = "\u001b[0m"; private static final String DIM = "\u001b[2m"; private static final String CYAN = "\u001b[36m"; private static final String GREEN = "\u001b[32m"; private static final String YELLOW = "\u001b[33m"; private static final String RED = "\u001b[31m"; private static final String INVERSE = "\u001b[7m"; private static final String BULLET = "\u2022 "; private static final String TREE = " \u2514 "; private static final String INDENT = " "; private static final String ELLIPSIS = "\u2026"; private static final String INITIAL_HINT = "Ask AI4J to inspect this repository"; private static final int MAX_TOOL_PREVIEW_LINES = 4; private static final int MAX_PALETTE_LINES = 8; private static final String[] SPINNER_FRAMES = new String[]{ "\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f" }; private final TerminalIO terminal; private final Set printedEventKeys = new LinkedHashSet(); private boolean entered; private boolean headerPrinted; private int footerLineCount; private String footerSignature; private String currentSessionId; private String lastAssistantOutput; private String liveReasoningPrinted = ""; private String liveTextPrinted = ""; private LiveBlock activeLiveBlock = LiveBlock.NONE; public DefaultAppendOnlyTuiRuntime(TerminalIO terminal) { this.terminal = terminal; } @Override public boolean supportsRawInput() { return terminal != null && terminal.supportsRawInput(); } @Override public synchronized void enter() { entered = true; footerLineCount = 0; footerSignature = null; if (terminal != null) { terminal.showCursor(); } } @Override public synchronized void exit() { clearFooter(); if (terminal != null) { terminal.showCursor(); } entered = false; } @Override public TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException { return terminal == null ? null : terminal.readKeyStroke(timeoutMs); } @Override public synchronized void render(TuiScreenModel screenModel) { if (terminal == null || screenModel == null) { return; } if (!headerPrinted) { printSessionHeader(screenModel); } else { printSessionBoundaryIfNeeded(screenModel); } appendEventTranscript(screenModel); appendLiveAssistantTranscript(screenModel); appendAssistantOutput(screenModel); renderFooter(screenModel); } private void printSessionHeader(TuiScreenModel screenModel) { List lines = new ArrayList(); String model = safeTrim(screenModel.getRenderContext() == null ? null : screenModel.getRenderContext().getModel()); String workspace = safeTrim(screenModel.getRenderContext() == null ? null : screenModel.getRenderContext().getWorkspace()); String sessionId = shortenSessionId(screenModel.getDescriptor() == null ? null : screenModel.getDescriptor().getSessionId()); StringBuilder header = new StringBuilder(); header.append(colorize(CYAN, "AI4J")) .append(" ") .append(firstNonBlank(model, "model")) .append(" ") .append(lastPathSegment(firstNonBlank(workspace, "."))); if (!isBlank(sessionId)) { header.append(" ").append(colorize(DIM, sessionId)); } lines.add(header.toString()); lines.add(colorize(DIM, BULLET + "Type / for commands, Enter to send")); lines.add(""); printTranscriptLines(lines); headerPrinted = true; currentSessionId = screenModel.getDescriptor() == null ? null : safeTrim(screenModel.getDescriptor().getSessionId()); } private void printSessionBoundaryIfNeeded(TuiScreenModel screenModel) { String nextSessionId = screenModel.getDescriptor() == null ? null : safeTrim(screenModel.getDescriptor().getSessionId()); if (isBlank(nextSessionId) || equals(currentSessionId, nextSessionId)) { return; } List lines = new ArrayList(); lines.add(colorize(DIM, BULLET + "Note: Switched session " + shortenSessionId(nextSessionId))); printTranscriptLines(lines); currentSessionId = nextSessionId; } private void appendEventTranscript(TuiScreenModel screenModel) { List events = screenModel.getCachedEvents(); if (events == null || events.isEmpty()) { return; } for (SessionEvent event : events) { String key = eventKey(event); if (isBlank(key) || printedEventKeys.contains(key)) { continue; } if (event.getType() == SessionEventType.USER_MESSAGE) { resetLiveDedupState(); } if (consumeLiveAssistantEvent(event)) { printedEventKeys.add(key); continue; } List lines = formatEvent(event); printedEventKeys.add(key); if (lines.isEmpty()) { continue; } printTranscriptLines(lines); } } private boolean consumeLiveAssistantEvent(SessionEvent event) { if (event == null || event.getType() != SessionEventType.ASSISTANT_MESSAGE) { return false; } Map payload = event.getPayload(); String output = firstNonBlank(payloadString(payload, "output"), event.getSummary()); if (isBlank(output)) { return false; } boolean reasoning = "reasoning".equalsIgnoreCase(payloadString(payload, "kind")); String printed = reasoning ? liveReasoningPrinted : liveTextPrinted; if (isBlank(printed) || !output.startsWith(printed)) { return false; } String suffix = output.substring(printed.length()); if (!isBlank(suffix)) { appendLiveSuffix(reasoning ? LiveBlock.REASONING : LiveBlock.TEXT, printed, suffix); } if (reasoning) { liveReasoningPrinted = ""; } else { liveTextPrinted = ""; } return true; } private void appendLiveAssistantTranscript(TuiScreenModel screenModel) { TuiAssistantViewModel assistant = screenModel == null ? null : screenModel.getAssistantViewModel(); String reasoning = normalizeLiveBuffer(assistant == null ? null : assistant.getReasoningText()); String text = normalizeLiveBuffer(assistant == null ? null : assistant.getText()); if (!isBlank(reasoning)) { if (!reasoning.startsWith(liveReasoningPrinted)) { liveReasoningPrinted = ""; } appendLiveSuffix(LiveBlock.REASONING, liveReasoningPrinted, reasoning.substring(liveReasoningPrinted.length())); liveReasoningPrinted = reasoning; } if (!isBlank(text)) { if (!text.startsWith(liveTextPrinted)) { liveTextPrinted = ""; } appendLiveSuffix(LiveBlock.TEXT, liveTextPrinted, text.substring(liveTextPrinted.length())); liveTextPrinted = text; } if (isBlank(reasoning) && isBlank(text)) { closeActiveLiveBlock(); } } private String normalizeLiveBuffer(String value) { return value == null ? "" : value.replace("\r", ""); } private void appendLiveSuffix(LiveBlock mode, String alreadyPrinted, String suffix) { if (mode == null || mode == LiveBlock.NONE || suffix == null || suffix.isEmpty()) { return; } clearFooter(); StringBuilder chunk = new StringBuilder(); if (activeLiveBlock != mode) { if (activeLiveBlock != LiveBlock.NONE) { chunk.append("\r\n\r\n"); } activeLiveBlock = mode; } boolean useFirstPrefix = isBlank(alreadyPrinted); boolean needsPrefix = useFirstPrefix || alreadyPrinted.endsWith("\n"); String normalized = suffix.replace("\r", ""); for (int i = 0; i < normalized.length(); i++) { char ch = normalized.charAt(i); if (ch == '\n') { chunk.append("\r\n"); needsPrefix = true; continue; } if (needsPrefix) { chunk.append(useFirstPrefix ? mode.firstPrefix() : mode.continuationPrefix()); useFirstPrefix = false; needsPrefix = false; } chunk.append(ch); } String rendered = chunk.toString(); if (rendered.isEmpty()) { return; } terminal.print(mode == LiveBlock.REASONING ? colorize(DIM, rendered) : rendered); } private void closeActiveLiveBlock() { if (activeLiveBlock == LiveBlock.NONE) { return; } clearFooter(); terminal.print("\r\n\r\n"); activeLiveBlock = LiveBlock.NONE; } private void resetLiveDedupState() { liveReasoningPrinted = ""; liveTextPrinted = ""; } private void appendAssistantOutput(TuiScreenModel screenModel) { String output = screenModel.getAssistantOutput(); if (isBlank(output) || output.equals(lastAssistantOutput)) { return; } lastAssistantOutput = output; if (printedEventKeys.isEmpty() && output.startsWith(INITIAL_HINT)) { return; } List lines = output.startsWith(INITIAL_HINT) ? formatInitialHintLines(output) : bulletBlock(splitLines(output), null); if (!lines.isEmpty()) { resetLiveDedupState(); printTranscriptLines(lines); } } private void renderFooter(TuiScreenModel screenModel) { if (!entered) { clearFooter(); return; } Footer footer = buildFooter(screenModel); if (footer == null || footer.lines.isEmpty()) { clearFooter(); return; } String signature = buildFooterSignature(footer); if (signature.equals(footerSignature)) { return; } moveToFooterTop(); clearFooterArea(); printFooterLines(footer.lines); placeCursor(footer); footerLineCount = footer.lines.size(); footerSignature = signature; } private Footer buildFooter(TuiScreenModel screenModel) { int width = resolveWidth(screenModel); List lines = new ArrayList(); TuiAssistantViewModel assistant = screenModel.getAssistantViewModel(); FooterLine statusLine = buildStatusLine(assistant, width); if (statusLine != null && !isBlank(statusLine.text)) { lines.add(statusLine); } List previewLines = buildPreviewLines(screenModel, assistant, width); lines.addAll(previewLines); TuiInteractionState interaction = screenModel.getInteractionState(); String input = interaction == null ? "" : firstNonBlank(interaction.getInputBuffer(), ""); InputViewport viewport = cropInputForViewport(input, width - 2); int inputLineIndex = lines.size(); lines.add(new FooterLine("> " + viewport.visibleText, null)); if (interaction != null && interaction.isPaletteOpen()) { lines.addAll(buildPaletteLines(interaction, width)); } int cursorColumn = 2 + viewport.cursorColumns; return new Footer(lines, inputLineIndex, cursorColumn); } private FooterLine buildStatusLine(TuiAssistantViewModel assistant, int width) { if (assistant == null || assistant.getPhase() == null || assistant.getPhase() == TuiAssistantPhase.IDLE) { return new FooterLine(crop(BULLET + "Ready", width), DIM); } if (assistant.getPhase() == TuiAssistantPhase.ERROR) { return new FooterLine(crop(BULLET + "Error", width), RED); } if (assistant.getPhase() == TuiAssistantPhase.COMPLETE) { return new FooterLine(crop(BULLET + "Done", width), GREEN); } TuiAssistantToolView activeTool = assistant.getPhase() == TuiAssistantPhase.WAITING_TOOL_RESULT ? firstPendingTool(assistant) : null; if (activeTool != null) { String working = animatedStatusPrefix("Working", assistant); return new FooterLine(crop(working + ": " + toolPrimaryLabel(activeTool, true), width), YELLOW); } String detail = safeTrim(assistant.getPhaseDetail()); String prefix = statusPrefix(assistant); String normalizedDetail = normalizeStatusDetail(assistant.getPhase(), detail); String text = isBlank(normalizedDetail) ? prefix : prefix + ": " + normalizedDetail; return new FooterLine(crop(text, width), DIM); } private String statusPrefix(TuiAssistantViewModel assistant) { TuiAssistantPhase phase = assistant == null ? null : assistant.getPhase(); if (phase == TuiAssistantPhase.THINKING) { return animatedStatusPrefix("Thinking", assistant); } if (phase == TuiAssistantPhase.GENERATING) { return animatedStatusPrefix("Responding", assistant); } if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT) { return animatedStatusPrefix("Working", assistant); } String phaseName = phase == null ? "" : phase.name().toLowerCase(Locale.ROOT).replace('_', ' '); return BULLET + capitalize(phaseName); } private String animatedStatusPrefix(String label, TuiAssistantViewModel assistant) { return BULLET + label + " " + spinnerFrame(assistant == null ? 0 : assistant.getAnimationTick()); } private String spinnerFrame(int animationTick) { int frameCount = SPINNER_FRAMES.length; if (frameCount == 0) { return ""; } int index = animationTick % frameCount; if (index < 0) { index += frameCount; } return SPINNER_FRAMES[index]; } private String normalizeStatusDetail(TuiAssistantPhase phase, String detail) { if (isBlank(detail)) { return ""; } if (phase == TuiAssistantPhase.THINKING) { if ("Waiting for model output...".equalsIgnoreCase(detail) || "Streaming reasoning...".equalsIgnoreCase(detail) || "Preparing next step...".equalsIgnoreCase(detail) || "Tool finished, continuing...".equalsIgnoreCase(detail)) { return ""; } if (detail.regionMatches(true, 0, "Thinking about: ", 0, "Thinking about: ".length())) { return safeTrim(detail.substring("Thinking about: ".length())); } } if (phase == TuiAssistantPhase.GENERATING && "Streaming model output...".equalsIgnoreCase(detail)) { return ""; } if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT && "Waiting for tool result...".equalsIgnoreCase(detail)) { return ""; } return detail; } private List buildPreviewLines(TuiScreenModel screenModel, TuiAssistantViewModel assistant, int width) { List lines = new ArrayList(); if (printedEventKeys.isEmpty() && shouldShowInitialHint(screenModel, assistant)) { List hintLines = formatInitialHintLines(firstNonBlank(screenModel.getAssistantOutput(), "")); for (String hintLine : hintLines) { if (!isBlank(hintLine)) { lines.add(new FooterLine(crop(hintLine, width), DIM)); } } } return lines; } private boolean shouldShowInitialHint(TuiScreenModel screenModel, TuiAssistantViewModel assistant) { if (screenModel == null) { return false; } if (assistant != null && assistant.getPhase() != null && assistant.getPhase() != TuiAssistantPhase.IDLE) { return false; } String output = screenModel.getAssistantOutput(); return !isBlank(output) && output.startsWith(INITIAL_HINT); } private List buildPaletteLines(TuiInteractionState interaction, int width) { List lines = new ArrayList(); lines.add(new FooterLine(crop(paletteHeader(interaction), width), DIM)); List items = interaction.getPaletteItems(); if (items == null || items.isEmpty()) { lines.add(new FooterLine(crop(BULLET + "No commands", width), DIM)); return lines; } int selected = Math.max(0, Math.min(interaction.getPaletteSelectedIndex(), items.size() - 1)); int maxLines = Math.min(Math.max(2, MAX_PALETTE_LINES - 1), Math.max(2, resolvePaletteCapacity() - 1)); int start = Math.max(0, selected - (maxLines / 2)); int end = Math.min(items.size(), start + maxLines); if (end - start < maxLines) { start = Math.max(0, end - maxLines); } if (start > 0) { lines.add(new FooterLine(crop(BULLET + ELLIPSIS, width), DIM)); } for (int i = start; i < end; i++) { TuiPaletteItem item = items.get(i); String label = firstNonBlank(item.getLabel(), item.getCommand()); String detail = isBlank(item.getDetail()) ? "" : " " + item.getDetail(); String line = crop(BULLET + firstNonBlank(label, "") + detail, width); lines.add(new FooterLine(line, i == selected ? INVERSE : null)); } if (end < items.size()) { lines.add(new FooterLine(crop(BULLET + ELLIPSIS, width), DIM)); } return lines; } private String paletteHeader(TuiInteractionState interaction) { if (interaction == null) { return BULLET + "Commands"; } String query = safeTrim(interaction.getPaletteQuery()); if (interaction.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH && !isBlank(query)) { return BULLET + "Commands: " + (query.startsWith("/") ? query : "/" + query); } return BULLET + "Commands"; } private void printTranscriptLines(List lines) { if (lines == null || lines.isEmpty()) { return; } closeActiveLiveBlock(); clearFooter(); for (String line : lines) { terminal.println(line == null ? "" : line); } terminal.println(""); } private void clearFooter() { if (footerLineCount <= 0) { footerSignature = null; return; } moveToFooterTop(); clearFooterArea(); footerLineCount = 0; footerSignature = null; } private void moveToFooterTop() { if (footerLineCount <= 0 || !terminal.supportsAnsi()) { return; } terminal.print("\r"); if (footerLineCount > 1) { terminal.print(ESC + (footerLineCount - 1) + "A"); } } private void clearFooterArea() { if (!terminal.supportsAnsi() || footerLineCount <= 0) { return; } for (int i = 0; i < footerLineCount; i++) { terminal.print("\r"); terminal.print(ESC + "2K"); if (i + 1 < footerLineCount) { terminal.print("\r\n"); } } terminal.print("\r"); if (footerLineCount > 1) { terminal.print(ESC + (footerLineCount - 1) + "A"); } } private void printFooterLines(List lines) { for (int i = 0; i < lines.size(); i++) { if (i > 0) { terminal.print("\r\n"); } terminal.print("\r"); terminal.print(ESC + "2K"); FooterLine line = lines.get(i); terminal.print(line == null ? "" : line.render(this)); } } private void placeCursor(Footer footer) { if (!terminal.supportsAnsi() || footer.lines.isEmpty()) { return; } terminal.print("\r"); int moveUp = footer.lines.size() - 1 - footer.inputLineIndex; if (moveUp > 0) { terminal.print(ESC + moveUp + "A"); } if (footer.cursorColumn > 0) { terminal.print(ESC + footer.cursorColumn + "C"); } } private int resolveWidth(TuiScreenModel screenModel) { int width = screenModel.getRenderContext() == null ? 0 : screenModel.getRenderContext().getTerminalColumns(); if (width <= 0 && terminal != null) { width = terminal.getTerminalColumns(); } return Math.max(40, width <= 0 ? 120 : width); } private int resolvePaletteCapacity() { int rows = terminal == null ? 0 : terminal.getTerminalRows(); if (rows <= 0) { return MAX_PALETTE_LINES; } return Math.max(3, Math.min(MAX_PALETTE_LINES, rows / 3)); } private List formatEvent(SessionEvent event) { if (event == null || event.getType() == null) { return new ArrayList(); } SessionEventType type = event.getType(); Map payload = event.getPayload(); if (type == SessionEventType.USER_MESSAGE) { return bulletBlock(splitLines(firstNonBlank(payloadString(payload, "input"), event.getSummary())), null); } if (type == SessionEventType.ASSISTANT_MESSAGE) { String kind = payloadString(payload, "kind"); List content = splitLines(firstNonBlank(payloadString(payload, "output"), event.getSummary())); if ("reasoning".equalsIgnoreCase(kind)) { return formatReasoning(content); } return bulletBlock(content, null); } if (type == SessionEventType.TOOL_CALL) { return new ArrayList(); } if (type == SessionEventType.TOOL_RESULT) { return formatToolResult(payload); } if (type == SessionEventType.ERROR) { return bulletBlock(splitLines(firstNonBlank(payloadString(payload, "error"), event.getSummary())), "Error: "); } if (type == SessionEventType.COMPACT) { return bulletBlock(splitLines(firstNonBlank(payloadString(payload, "summary"), event.getSummary(), "context compacted")), "Note: "); } if (type == SessionEventType.AUTO_CONTINUE || type == SessionEventType.AUTO_STOP || type == SessionEventType.BLOCKED) { return bulletBlock(splitLines(firstNonBlank(event.getSummary(), type.name().toLowerCase(Locale.ROOT).replace('_', ' '))), "Note: "); } if (type == SessionEventType.SESSION_RESUMED || type == SessionEventType.SESSION_FORKED) { return bulletBlock(splitLines(firstNonBlank(event.getSummary(), type.name().toLowerCase(Locale.ROOT).replace('_', ' '))), "Note: "); } if (type == SessionEventType.TASK_CREATED || type == SessionEventType.TASK_UPDATED) { return formatTaskEvent(payload, event.getSummary()); } if (type == SessionEventType.TEAM_MESSAGE) { return formatTeamMessageEvent(payload, event.getSummary()); } if (type == SessionEventType.PROCESS_STARTED || type == SessionEventType.PROCESS_UPDATED || type == SessionEventType.PROCESS_STOPPED) { return formatProcessEvent(type, payload, event.getSummary()); } return new ArrayList(); } private List formatTaskEvent(Map payload, String summary) { List lines = new ArrayList(); lines.add(BULLET + "Note: " + firstNonBlank(summary, payloadString(payload, "title"), "delegate task")); String detail = firstNonBlank(payloadString(payload, "detail"), payloadString(payload, "error"), payloadString(payload, "output")); if (!isBlank(detail)) { lines.add(TREE + detail); } String childSessionId = payloadString(payload, "childSessionId"); if (!isBlank(childSessionId)) { lines.add(INDENT + "child session: " + childSessionId); } String status = payloadString(payload, "status"); if (!isBlank(status)) { lines.add(INDENT + "status: " + status); } return lines; } private List formatTeamMessageEvent(Map payload, String summary) { List lines = new ArrayList(); lines.add(BULLET + "Note: " + firstNonBlank(summary, payloadString(payload, "title"), "team message")); String taskId = payloadString(payload, "taskId"); if (!isBlank(taskId)) { lines.add(TREE + "task: " + taskId); } String detail = firstNonBlank(payloadString(payload, "content"), payloadString(payload, "detail")); if (!isBlank(detail)) { List detailLines = splitLines(detail); for (int i = 0; i < detailLines.size(); i++) { lines.add((i == 0 ? INDENT : INDENT) + detailLines.get(i)); } } return lines; } private List formatReasoning(List content) { List lines = new ArrayList(); if (content == null || content.isEmpty()) { return lines; } String continuation = repeat(' ', (BULLET + "Thinking: ").length()); for (int i = 0; i < content.size(); i++) { String prefix = i == 0 ? BULLET + "Thinking: " : continuation; lines.add(colorize(DIM, prefix + content.get(i))); } return lines; } private List formatToolResult(Map payload) { List lines = new ArrayList(); if (payload == null) { return lines; } String toolName = firstNonBlank(payloadString(payload, "tool"), "tool"); String title = normalizeToolLabel(firstNonBlank(payloadString(payload, "title"), toolName)); String detail = safeTrim(payloadString(payload, "detail")); String output = payloadString(payload, "output"); boolean failed = looksLikeToolFailure(output, detail); if ("apply_patch".equals(toolName)) { lines.add(failed ? colorize(RED, BULLET + "Tool failed apply_patch") : colorize(YELLOW, BULLET + "Applied patch")); } else if ("write_file".equals(toolName)) { String label = crop(normalizeToolLabel(title), 108); lines.add(failed ? colorize(RED, BULLET + "Tool failed write " + crop(label, 96)) : colorize(YELLOW, BULLET + "Wrote " + label)); } else if (failed) { lines.add(colorize(RED, BULLET + "Tool failed " + crop(title, 96))); } else { lines.add(colorize(YELLOW, BULLET + "Ran " + crop(title, 108))); } if (!isBlank(detail)) { lines.add(TREE + detail); } List previewLines = payloadLines(payload, "previewLines"); int max = Math.min(MAX_TOOL_PREVIEW_LINES, previewLines.size()); for (int i = 0; i < max; i++) { lines.add((i == 0 && isBlank(detail) ? TREE : INDENT) + previewLines.get(i)); } if (previewLines.size() > max) { lines.add(colorize(DIM, INDENT + ELLIPSIS + " +" + (previewLines.size() - max) + " lines")); } return lines; } private List formatProcessEvent(SessionEventType type, Map payload, String summary) { List lines = new ArrayList(); String processId = firstNonBlank(payloadString(payload, "processId"), "unknown-process"); String status = safeTrim(payloadString(payload, "status")); String command = safeTrim(payloadString(payload, "command")); String workingDirectory = safeTrim(payloadString(payload, "workingDirectory")); if (type == SessionEventType.PROCESS_STARTED) { lines.add(BULLET + "Process started: " + processId); } else if (type == SessionEventType.PROCESS_STOPPED) { lines.add(BULLET + "Process stopped: " + processId); } else { String label = BULLET + "Process: " + processId; if (!isBlank(status)) { label = label + " (" + status + ")"; } lines.add(label); } if (!isBlank(command)) { lines.add(TREE + command); } else if (!isBlank(summary)) { lines.add(TREE + summary); } if (!isBlank(workingDirectory)) { lines.add(INDENT + "cwd " + workingDirectory); } return lines; } private InputViewport cropInputForViewport(String input, int availableColumns) { String safeInput = input == null ? "" : input; int width = Math.max(8, availableColumns); if (displayWidth(safeInput) <= width) { return new InputViewport(safeInput, displayWidth(safeInput)); } int visibleWidth = 0; StringBuilder builder = new StringBuilder(); for (int i = safeInput.length() - 1; i >= 0; i--) { char ch = safeInput.charAt(i); int charWidth = charWidth(ch); if (visibleWidth + charWidth > width - 3 && builder.length() > 0) { break; } builder.insert(0, ch); visibleWidth += charWidth; } String visible = "..." + builder.toString(); return new InputViewport(visible, Math.min(width, displayWidth(visible))); } private List bulletBlock(List content, String label) { List lines = new ArrayList(); if (content == null || content.isEmpty()) { return lines; } String firstPrefix = BULLET + firstNonBlank(label, ""); String continuation = repeat(' ', displayWidth(firstPrefix)); for (int i = 0; i < content.size(); i++) { String line = content.get(i) == null ? "" : content.get(i); lines.add((i == 0 ? firstPrefix : continuation) + line); } return lines; } private List formatInitialHintLines(String output) { List lines = new ArrayList(); List rawLines = splitLines(output); for (String rawLine : rawLines) { if (!isBlank(rawLine)) { lines.add(BULLET + rawLine); } } return lines; } private List appendLabelBlock(String label, List content) { List lines = new ArrayList(); if (!isBlank(label)) { lines.add(label); } if (content != null) { lines.addAll(content); } return lines; } private List prefixBlock(String prefix, List content) { List lines = new ArrayList(); if (content == null || content.isEmpty()) { return lines; } String continuation = repeat(' ', prefix.length()); for (int i = 0; i < content.size(); i++) { lines.add((i == 0 ? prefix : continuation) + content.get(i)); } return lines; } private String buildFooterSignature(Footer footer) { StringBuilder builder = new StringBuilder(); builder.append(footer.inputLineIndex).append('|').append(footer.cursorColumn).append('|'); for (FooterLine line : footer.lines) { if (line == null) { builder.append('\n'); continue; } builder.append(firstNonBlank(line.style, "")) .append('|') .append(firstNonBlank(line.text, "")) .append('\n'); } return builder.toString(); } private String eventKey(SessionEvent event) { if (!isBlank(event.getEventId())) { return event.getEventId(); } return firstNonBlank(event.getTurnId(), "session") + "|" + event.getTimestamp() + "|" + (event.getStep() == null ? "" : event.getStep().toString()) + "|" + event.getType().name() + "|" + firstNonBlank(event.getSummary(), ""); } private List splitLines(String text) { List lines = new ArrayList(); if (text == null) { return lines; } String[] raw = text.replace("\r", "").split("\n", -1); for (String line : raw) { lines.add(line == null ? "" : line); } return lines; } private List payloadLines(Map payload, String key) { List lines = new ArrayList(); if (payload == null || isBlank(key) || !payload.containsKey(key)) { return lines; } Object value = payload.get(key); if (value instanceof List) { List list = (List) value; for (Object item : list) { if (item != null) { lines.add(String.valueOf(item)); } } return lines; } if (value != null) { lines.add(String.valueOf(value)); } return lines; } private String payloadString(Map payload, String key) { if (payload == null || isBlank(key) || !payload.containsKey(key)) { return null; } Object value = payload.get(key); return value == null ? null : String.valueOf(value); } private TuiAssistantToolView firstPendingTool(TuiAssistantViewModel assistant) { if (assistant == null || assistant.getTools() == null || assistant.getTools().isEmpty()) { return null; } for (TuiAssistantToolView tool : assistant.getTools()) { if (tool != null && "pending".equalsIgnoreCase(safeTrim(tool.getStatus()))) { return tool; } } return assistant.getTools().get(0); } private String toolPrimaryLabel(TuiAssistantToolView tool, boolean pending) { if (tool == null) { return pending ? "Thinking" : "Done"; } String label = normalizeToolLabel(firstNonBlank(tool.getTitle(), tool.getToolName(), "tool")); if ("write_file".equals(tool.getToolName())) { return pending ? "Writing " + label : "Wrote " + label; } return pending ? "Running " + label : "Ran " + label; } private String normalizeToolLabel(String title) { String value = firstNonBlank(title, "tool").trim(); String[] prefixes = new String[]{"$ ", "read ", "write ", "bash logs ", "bash status ", "bash write ", "bash stop "}; for (String prefix : prefixes) { if (value.startsWith(prefix)) { return value.substring(prefix.length()).trim(); } } return value; } private boolean looksLikeToolFailure(String output, String detail) { String combined = (firstNonBlank(output, "") + " " + firstNonBlank(detail, "")).toLowerCase(Locale.ROOT); return combined.contains("error") || combined.contains("failed") || combined.contains("exception") || combined.contains("unsupported patch line") || combined.contains("cannot invoke"); } private String shortenSessionId(String sessionId) { String value = safeTrim(sessionId); if (isBlank(value)) { return null; } if (value.length() <= 12) { return value; } return value.substring(0, 12); } private String crop(String value, int width) { String safe = value == null ? "" : value; if (width <= 0 || displayWidth(safe) <= width) { return safe; } StringBuilder builder = new StringBuilder(); int used = 0; for (int i = 0; i < safe.length(); i++) { char ch = safe.charAt(i); int charWidth = charWidth(ch); if (used + charWidth > Math.max(0, width - 3)) { break; } builder.append(ch); used += charWidth; } return builder.append("...").toString(); } private int displayWidth(String text) { if (text == null || text.isEmpty()) { return 0; } int width = 0; for (int i = 0; i < text.length(); i++) { width += charWidth(text.charAt(i)); } return width; } private int charWidth(char ch) { int width = WCWidth.wcwidth(ch); return width <= 0 ? 1 : width; } private String trimBlankEdges(String value) { if (value == null) { return null; } int start = 0; int end = value.length(); while (start < end && Character.isWhitespace(value.charAt(start))) { start++; } while (end > start && Character.isWhitespace(value.charAt(end - 1))) { end--; } return value.substring(start, end); } private String safeTrim(String value) { return value == null ? null : value.trim(); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return values.length == 0 ? null : values[values.length - 1]; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String repeat(char ch, int count) { StringBuilder builder = new StringBuilder(Math.max(0, count)); for (int i = 0; i < count; i++) { builder.append(ch); } return builder.toString(); } private String lastPathSegment(String value) { if (isBlank(value)) { return "."; } String normalized = value.replace('\\', '/'); int index = normalized.lastIndexOf('/'); return index < 0 ? normalized : normalized.substring(index + 1); } private String capitalize(String value) { if (isBlank(value)) { return ""; } return Character.toUpperCase(value.charAt(0)) + value.substring(1); } private boolean equals(String left, String right) { return left == null ? right == null : left.equals(right); } private String colorize(String color, String text) { if (!terminal.supportsAnsi() || isBlank(color)) { return firstNonBlank(text, ""); } return color + firstNonBlank(text, "") + RESET; } private static final class Footer { private final List lines; private final int inputLineIndex; private final int cursorColumn; private Footer(List lines, int inputLineIndex, int cursorColumn) { this.lines = lines; this.inputLineIndex = inputLineIndex; this.cursorColumn = cursorColumn; } } private static final class FooterLine { private final String text; private final String style; private FooterLine(String text, String style) { this.text = text == null ? "" : text; this.style = style; } private String render(DefaultAppendOnlyTuiRuntime runtime) { if (runtime == null || isBlank(style)) { return text; } return runtime.colorize(style, text); } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } private static final class InputViewport { private final String visibleText; private final int cursorColumns; private InputViewport(String visibleText, int cursorColumns) { this.visibleText = visibleText == null ? "" : visibleText; this.cursorColumns = Math.max(0, cursorColumns); } } private enum LiveBlock { NONE, REASONING, TEXT; private String firstPrefix() { return this == REASONING ? BULLET + "Thinking: " : BULLET; } private String continuationPrefix() { int count = this == REASONING ? (BULLET + "Thinking: ").length() : BULLET.length(); StringBuilder builder = new StringBuilder(Math.max(0, count)); for (int i = 0; i < count; i++) { builder.append(' '); } return builder.toString(); } } } ================================================ FILE: ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/amber.json ================================================ { "name": "amber", "brand": "#ffcc66", "accent": "#ff8a3d", "success": "#c7f9a8", "warning": "#ffd166", "danger": "#ff6b6b", "text": "#f8f5ec", "muted": "#c8b89d", "panelBorder": "#7c5c3b", "panelTitle": "#ffcc66", "badgeForeground": "#241a10" } ================================================ FILE: ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/default.json ================================================ { "name": "default", "brand": "#7cc6fe", "accent": "#f5b14c", "success": "#8fd694", "warning": "#f4d35e", "danger": "#ef6f6c", "text": "#f3f4f6", "muted": "#9ca3af", "panelBorder": "#4b5563", "panelTitle": "#f5b14c", "badgeForeground": "#111827" } ================================================ FILE: ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/github-dark.json ================================================ { "name": "github-dark", "brand": "#58a6ff", "accent": "#d2a8ff", "success": "#3fb950", "warning": "#d29922", "danger": "#f85149", "text": "#c9d1d9", "muted": "#8b949e", "panelBorder": "#30363d", "panelTitle": "#58a6ff", "badgeForeground": "#0d1117", "codeBackground": "#161b22", "codeBorder": "#30363d", "codeText": "#c9d1d9", "codeKeyword": "#ff7b72", "codeString": "#a5d6ff", "codeComment": "#8b949e", "codeNumber": "#79c0ff" } ================================================ FILE: ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/github-light.json ================================================ { "name": "github-light", "brand": "#0969da", "accent": "#8250df", "success": "#1a7f37", "warning": "#9a6700", "danger": "#cf222e", "text": "#24292f", "muted": "#57606a", "panelBorder": "#d0d7de", "panelTitle": "#0969da", "badgeForeground": "#ffffff", "codeBackground": "#f6f8fa", "codeBorder": "#d0d7de", "codeText": "#24292f", "codeKeyword": "#cf222e", "codeString": "#0a3069", "codeComment": "#6e7781", "codeNumber": "#0550ae" } ================================================ FILE: ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/matrix.json ================================================ { "name": "matrix", "brand": "#66ff99", "accent": "#3ddc97", "success": "#98fb98", "warning": "#e6ff5c", "danger": "#ff5f6d", "text": "#ddffe7", "muted": "#7aa387", "panelBorder": "#24593d", "panelTitle": "#66ff99", "badgeForeground": "#06110a" } ================================================ FILE: ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/ocean.json ================================================ { "name": "ocean", "brand": "#66d9ef", "accent": "#7aa2f7", "success": "#9ece6a", "warning": "#e0af68", "danger": "#f7768e", "text": "#e6edf3", "muted": "#8b9bb4", "panelBorder": "#355070", "panelTitle": "#66d9ef", "badgeForeground": "#0b1320" } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/Ai4jCliTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.coding.CodingAgents; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory.PreparedCodingAgent; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Paths; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Properties; public class Ai4jCliTest { @Test public void test_top_level_help() { ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); int exitCode = new Ai4jCli().run( new String[]{"help"}, new ByteArrayInputStream(new byte[0]), out, err, Collections.emptyMap(), new Properties() ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("ai4j-cli")); Assert.assertTrue(output.contains("code")); Assert.assertTrue(output.contains("acp")); } @Test public void test_unknown_command_returns_argument_error() { ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); int exitCode = new Ai4jCli().run( new String[]{"unknown"}, new ByteArrayInputStream(new byte[0]), out, err, Collections.emptyMap(), new Properties() ); String error = new String(err.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(2, exitCode); Assert.assertTrue(error.contains("Unknown command: unknown")); } @Test public void test_top_level_tui_command_routes_to_tui_mode() { ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); Ai4jCli cli = new Ai4jCli(new StubCodingCliAgentFactory(), Paths.get(".").toAbsolutePath().normalize()); int exitCode = cli.run( new String[]{"tui", "--model", "fake-model", "--prompt", "hello"}, new ByteArrayInputStream(new byte[0]), out, err, Collections.emptyMap(), new Properties() ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("AI4J")); Assert.assertTrue(output.contains("fake-model")); Assert.assertTrue(output.contains("Echo: hello")); Assert.assertFalse(output.contains("EVENTS")); } private static final class StubCodingCliAgentFactory implements CodingCliAgentFactory { @Override public PreparedCodingAgent prepare(CodeCommandOptions options) { CodingAgent agent = CodingAgents.builder() .modelClient(new StubModelClient()) .model(options.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build()) .build(); return new PreparedCodingAgent(agent, CliProtocol.CHAT); } } private static final class StubModelClient implements AgentModelClient { @Override public AgentModelResult create(AgentPrompt prompt) { return AgentModelResult.builder().outputText("Echo: " + findLastUserText(prompt)).build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } List parts = (List) content; for (Object part : parts) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if ("input_text".equals(partMap.get("type"))) { Object text = partMap.get("text"); return text == null ? "" : String.valueOf(text); } } } return ""; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/AssistantTranscriptRendererTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.render.AssistantTranscriptRenderer; import io.github.lnyocly.ai4j.cli.render.CliThemeStyler; import io.github.lnyocly.ai4j.tui.TuiTheme; import org.junit.Test; import java.util.Arrays; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class AssistantTranscriptRendererTest { @Test public void renderTurnsMarkdownCodeBlocksIntoTranscriptLines() { AssistantTranscriptRenderer renderer = new AssistantTranscriptRenderer(); List lines = renderer.plainLines("`hello.py` 文件已经存在了,内容如下:\n\n```python\n# Python Hello World\n\nprint(\"Hello, World!\")\n```\n\n你可以运行它:\n\n```bash\npython hello.py\n```"); assertEquals(Arrays.asList( "`hello.py` 文件已经存在了,内容如下:", "", " # Python Hello World", " ", " print(\"Hello, World!\")", "", "你可以运行它:", "", " python hello.py" ), lines); } @Test public void styleBlockHighlightsCodeWithoutLeakingFences() { AssistantTranscriptRenderer renderer = new AssistantTranscriptRenderer(); CliThemeStyler styler = new CliThemeStyler(new TuiTheme(), true); String styled = renderer.styleBlock("```python\nprint(\"Hello\")\n```", styler); assertTrue(styled.replaceAll("\\u001B\\[[;\\d]*m", "").contains("print(\"Hello\")")); assertFalse(styled.contains("```")); assertTrue(styled.contains("\u001b[")); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliDisplayWidthTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.render.CliDisplayWidth; import org.jline.utils.AttributedString; import org.junit.Assert; import org.junit.Test; public class CliDisplayWidthTest { @Test public void clipUsesDisplayWidthForChineseText() { String value = CliDisplayWidth.clip("然后说:你好世界", 9); Assert.assertEquals("然后说...", value); Assert.assertTrue(CliDisplayWidth.displayWidth(value) <= 9); } @Test public void wrapAnsiBreaksBeforeWideTextPushesLastSymbolPastEdge() { CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi( "\u001b[90mab你好\"\u001b[0m", 6, 0 ); Assert.assertEquals("ab你好\n\"", AttributedString.fromAnsi(wrapped.text()).toString()); Assert.assertEquals(1, wrapped.endColumn()); } @Test public void wrapAnsiHonorsExistingColumnOffset() { CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi( "\u001b[90m你好\"\u001b[0m", 6, 2 ); Assert.assertEquals("你好\n\"", AttributedString.fromAnsi(wrapped.text()).toString()); Assert.assertEquals(1, wrapped.endColumn()); } @Test public void wrapAnsiDoesNotLeaveChinesePeriodAloneAtLineStart() { CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi( "\u001b[90mab你好。\u001b[0m", 6, 0 ); Assert.assertEquals("ab你\n好。", AttributedString.fromAnsi(wrapped.text()).toString()); Assert.assertEquals(4, wrapped.endColumn()); } @Test public void wrapAnsiDoesNotLeaveChineseColonAloneAtLineStart() { CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi( "\u001b[90m例如:\u001b[0m", 4, 0 ); Assert.assertEquals("例\n如:", AttributedString.fromAnsi(wrapped.text()).toString()); Assert.assertEquals(4, wrapped.endColumn()); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliMcpConfigManagerTest.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import org.junit.Assert; import org.junit.Test; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; public class CliMcpConfigManagerTest { @Test public void saveLoadAndResolveMcpConfigs() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-mcp-home"); Path workspace = Files.createTempDirectory("ai4j-cli-mcp-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliMcpConfigManager manager = new CliMcpConfigManager(workspace); Map servers = new LinkedHashMap(); servers.put("fetch", CliMcpServerDefinition.builder() .type("sse") .url(" https://mcp.api-inference.modelscope.net/1e1a663049b340/sse ") .build()); servers.put("time", CliMcpServerDefinition.builder() .command(" uvx ") .args(Arrays.asList(" mcp-server-time ", "")) .build()); servers.put("bing-cn-mcp-server", CliMcpServerDefinition.builder() .type("http") .url("https://mcp.api-inference.modelscope.net/0904773a8c2045/mcp") .build()); manager.saveGlobalConfig(CliMcpConfig.builder() .mcpServers(servers) .build()); manager.saveWorkspaceConfig(CliWorkspaceConfig.builder() .enabledMcpServers(Arrays.asList(" fetch ", "time", "fetch", "missing", "")) .skillDirectories(Arrays.asList(" .ai4j/skills ", "C:/skills/team ", ".ai4j/skills")) .agentDirectories(Arrays.asList(" .ai4j/agents ", "C:/agents/team ", ".ai4j/agents")) .build()); CliMcpConfig loadedGlobal = manager.loadGlobalConfig(); CliWorkspaceConfig loadedWorkspace = manager.loadWorkspaceConfig(); CliResolvedMcpConfig resolved = manager.resolve(Collections.singleton("fetch")); Assert.assertEquals("sse", loadedGlobal.getMcpServers().get("fetch").getType()); Assert.assertEquals("stdio", loadedGlobal.getMcpServers().get("time").getType()); Assert.assertEquals("uvx", loadedGlobal.getMcpServers().get("time").getCommand()); Assert.assertEquals(Arrays.asList("mcp-server-time"), loadedGlobal.getMcpServers().get("time").getArgs()); Assert.assertEquals("streamable_http", loadedGlobal.getMcpServers().get("bing-cn-mcp-server").getType()); Assert.assertEquals(Arrays.asList("fetch", "time", "missing"), loadedWorkspace.getEnabledMcpServers()); Assert.assertEquals(Arrays.asList(".ai4j/skills", "C:/skills/team"), loadedWorkspace.getSkillDirectories()); Assert.assertEquals(Arrays.asList(".ai4j/agents", "C:/agents/team"), loadedWorkspace.getAgentDirectories()); CliResolvedMcpServer fetch = resolved.getServers().get("fetch"); CliResolvedMcpServer time = resolved.getServers().get("time"); CliResolvedMcpServer bing = resolved.getServers().get("bing-cn-mcp-server"); Assert.assertNotNull(fetch); Assert.assertTrue(fetch.isWorkspaceEnabled()); Assert.assertTrue(fetch.isSessionPaused()); Assert.assertFalse(fetch.isActive()); Assert.assertTrue(fetch.isValid()); Assert.assertNotNull(time); Assert.assertTrue(time.isWorkspaceEnabled()); Assert.assertFalse(time.isSessionPaused()); Assert.assertTrue(time.isActive()); Assert.assertTrue(time.isValid()); Assert.assertNotNull(bing); Assert.assertFalse(bing.isWorkspaceEnabled()); Assert.assertFalse(bing.isActive()); Assert.assertEquals(Arrays.asList("fetch", "time"), resolved.getEnabledServerNames()); Assert.assertEquals(Collections.singletonList("fetch"), resolved.getPausedServerNames()); Assert.assertEquals(Collections.singletonList("missing"), resolved.getUnknownEnabledServerNames()); String persisted = new String(Files.readAllBytes(manager.globalMcpPath()), StandardCharsets.UTF_8); Assert.assertTrue(persisted.contains("\"mcpServers\"")); Assert.assertFalse(persisted.contains("\"version\"")); Assert.assertFalse(persisted.contains("\"enabled\"")); } finally { restoreUserHome(previousUserHome); } } @Test public void resolveMarksInvalidDefinitionsWithoutBlockingOtherServers() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-mcp-invalid-home"); Path workspace = Files.createTempDirectory("ai4j-cli-mcp-invalid-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliMcpConfigManager manager = new CliMcpConfigManager(workspace); Map servers = new LinkedHashMap(); servers.put("broken-stdio", CliMcpServerDefinition.builder() .type("stdio") .build()); servers.put("broken-sse", CliMcpServerDefinition.builder() .type("sse") .build()); servers.put("ambiguous", CliMcpServerDefinition.builder() .url("http://localhost:8080/mcp") .build()); servers.put("working", CliMcpServerDefinition.builder() .command("uvx") .args(Collections.singletonList("mcp-server-time")) .build()); manager.saveGlobalConfig(CliMcpConfig.builder() .mcpServers(servers) .build()); manager.saveWorkspaceConfig(CliWorkspaceConfig.builder() .enabledMcpServers(Arrays.asList("broken-stdio", "broken-sse", "ambiguous", "working")) .build()); CliResolvedMcpConfig resolved = manager.resolve(Collections.emptyList()); Assert.assertEquals("stdio transport requires command", resolved.getServers().get("broken-stdio").getValidationError()); Assert.assertEquals("sse transport requires url", resolved.getServers().get("broken-sse").getValidationError()); Assert.assertEquals("missing MCP transport type", resolved.getServers().get("ambiguous").getValidationError()); Assert.assertFalse(resolved.getServers().get("broken-stdio").isActive()); Assert.assertFalse(resolved.getServers().get("broken-sse").isActive()); Assert.assertFalse(resolved.getServers().get("ambiguous").isActive()); Assert.assertTrue(resolved.getServers().get("working").isActive()); Assert.assertEquals("stdio", resolved.getServers().get("working").getTransportType()); } finally { restoreUserHome(previousUserHome); } } private void restoreUserHome(String value) { if (value == null) { System.clearProperty("user.home"); return; } System.setProperty("user.home", value); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliMcpRuntimeManagerTest.java ================================================ package io.github.lnyocly.ai4j.cli.mcp; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class CliMcpRuntimeManagerTest { @Test public void connectedServerRegistersToolsAndRoutesCalls() throws Exception { FakeClientSession timeSession = new FakeClientSession(Collections.singletonList(tool("time_now", "Read current time"))); CliMcpRuntimeManager runtimeManager = new CliMcpRuntimeManager( resolvedConfig( Collections.singletonMap("time", activeServer("time", "stdio", "uvx")), Collections.singletonList("time"), Collections.emptyList(), Collections.emptyList() ), new FakeClientFactory(Collections.singletonMap("time", timeSession)) ); runtimeManager.start(); Assert.assertNotNull(runtimeManager.getToolRegistry()); Assert.assertNotNull(runtimeManager.getToolExecutor()); Assert.assertEquals(1, runtimeManager.getToolRegistry().getTools().size()); Assert.assertEquals(1, runtimeManager.getStatuses().size()); Assert.assertEquals(CliMcpRuntimeManager.STATE_CONNECTED, runtimeManager.getStatuses().get(0).getState()); Assert.assertEquals(1, runtimeManager.getStatuses().get(0).getToolCount()); String result = runtimeManager.getToolExecutor().execute(AgentToolCall.builder() .name("time_now") .arguments("{\"timezone\":\"Asia/Shanghai\"}") .build()); Assert.assertEquals("ok:time_now", result); Assert.assertTrue(timeSession.connected); Assert.assertEquals("time_now", timeSession.lastToolName); Assert.assertEquals("Asia/Shanghai", String.valueOf(timeSession.lastArguments.get("timezone"))); runtimeManager.close(); Assert.assertTrue(timeSession.closed); } @Test public void failedAndConflictingServersDoNotBlockHealthyServer() throws Exception { FakeClientSession fetchSession = new FakeClientSession(Collections.singletonList(tool("search_web", "Search the web"))); FakeClientSession duplicateSession = new FakeClientSession(Collections.singletonList(tool("search_web", "Duplicate tool"))); FakeClientSession pausedSession = new FakeClientSession(Collections.singletonList(tool("paused_tool", "Paused tool"))); Map servers = new LinkedHashMap(); servers.put("fetch", activeServer("fetch", "sse", null)); servers.put("broken", activeServer("broken", "sse", null)); servers.put("duplicate", activeServer("duplicate", "streamable_http", null)); servers.put("paused", pausedServer("paused", "stdio", "uvx")); servers.put("disabled", disabledServer("disabled", "stdio", "uvx")); Map sessions = new LinkedHashMap(); sessions.put("fetch", fetchSession); sessions.put("duplicate", duplicateSession); sessions.put("paused", pausedSession); CliMcpRuntimeManager runtimeManager = new CliMcpRuntimeManager( resolvedConfig( servers, Arrays.asList("fetch", "broken", "duplicate", "paused"), Collections.singletonList("paused"), Collections.singletonList("missing") ), new FakeClientFactory(sessions, Collections.singletonMap("broken", new IllegalStateException("connect failed"))) ); runtimeManager.start(); Map statuses = indexStatuses(runtimeManager.getStatuses()); Assert.assertEquals(CliMcpRuntimeManager.STATE_CONNECTED, statuses.get("fetch").getState()); Assert.assertEquals(CliMcpRuntimeManager.STATE_ERROR, statuses.get("broken").getState()); Assert.assertTrue(statuses.get("broken").getErrorSummary().contains("connect failed")); Assert.assertEquals(CliMcpRuntimeManager.STATE_ERROR, statuses.get("duplicate").getState()); Assert.assertTrue(statuses.get("duplicate").getErrorSummary().contains("search_web")); Assert.assertEquals(CliMcpRuntimeManager.STATE_PAUSED, statuses.get("paused").getState()); Assert.assertEquals(CliMcpRuntimeManager.STATE_DISABLED, statuses.get("disabled").getState()); Assert.assertEquals(CliMcpRuntimeManager.STATE_MISSING, statuses.get("missing").getState()); Assert.assertNotNull(runtimeManager.getToolRegistry()); Assert.assertEquals(1, runtimeManager.getToolRegistry().getTools().size()); Assert.assertEquals("ok:search_web", runtimeManager.getToolExecutor().execute(AgentToolCall.builder() .name("search_web") .arguments("{}") .build())); Assert.assertEquals(Arrays.asList( "MCP unavailable: broken (connect failed)", "MCP unavailable: duplicate (MCP tool name conflict: search_web already provided by fetch)", "MCP unavailable: missing (workspace references undefined MCP server)" ), runtimeManager.buildStartupWarnings()); runtimeManager.close(); Assert.assertTrue(fetchSession.closed); Assert.assertTrue(duplicateSession.closed); Assert.assertFalse(pausedSession.connected); } private Map indexStatuses(List statuses) { Map index = new LinkedHashMap(); for (CliMcpStatusSnapshot status : statuses) { if (status != null) { index.put(status.getServerName(), status); } } return index; } private CliResolvedMcpConfig resolvedConfig(Map servers, List enabled, List paused, List missing) { return new CliResolvedMcpConfig(servers, enabled, paused, missing); } private CliResolvedMcpServer activeServer(String name, String type, String command) { CliMcpServerDefinition definition = CliMcpServerDefinition.builder() .type(type) .command(command) .url(command == null ? "https://example.com/" + name : null) .build(); return new CliResolvedMcpServer(name, type, true, false, true, null, definition); } private CliResolvedMcpServer pausedServer(String name, String type, String command) { CliMcpServerDefinition definition = CliMcpServerDefinition.builder() .type(type) .command(command) .build(); return new CliResolvedMcpServer(name, type, true, true, false, null, definition); } private CliResolvedMcpServer disabledServer(String name, String type, String command) { CliMcpServerDefinition definition = CliMcpServerDefinition.builder() .type(type) .command(command) .build(); return new CliResolvedMcpServer(name, type, false, false, false, null, definition); } private McpToolDefinition tool(String name, String description) { Map schema = new LinkedHashMap(); Map properties = new LinkedHashMap(); Map timezone = new LinkedHashMap(); timezone.put("type", "string"); timezone.put("description", "Timezone"); properties.put("timezone", timezone); schema.put("type", "object"); schema.put("properties", properties); schema.put("required", Collections.singletonList("timezone")); return McpToolDefinition.builder() .name(name) .description(description) .inputSchema(schema) .build(); } private static final class FakeClientFactory implements CliMcpRuntimeManager.ClientFactory { private final Map sessions; private final Map failures; private FakeClientFactory(Map sessions) { this(sessions, Collections.emptyMap()); } private FakeClientFactory(Map sessions, Map failures) { this.sessions = sessions == null ? Collections.emptyMap() : sessions; this.failures = failures == null ? Collections.emptyMap() : failures; } @Override public CliMcpRuntimeManager.ClientSession create(CliResolvedMcpServer server) { if (server != null && failures.containsKey(server.getName())) { throw failures.get(server.getName()); } FakeClientSession session = sessions.get(server == null ? null : server.getName()); if (session == null) { throw new IllegalStateException("missing fake session for " + (server == null ? null : server.getName())); } return session; } } private static final class FakeClientSession implements CliMcpRuntimeManager.ClientSession { private final List tools; private boolean connected; private boolean closed; private String lastToolName; private Map lastArguments; private FakeClientSession(List tools) { this.tools = tools == null ? Collections.emptyList() : new ArrayList(tools); } @Override public void connect() { connected = true; } @Override public List listTools() { return new ArrayList(tools); } @Override @SuppressWarnings("unchecked") public String callTool(String toolName, Object arguments) { this.lastToolName = toolName; this.lastArguments = arguments instanceof Map ? new LinkedHashMap((Map) arguments) : Collections.emptyMap(); return "ok:" + toolName; } @Override public void close() { closed = true; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliProviderConfigManagerTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.cli.provider.CliProviderProfile; import io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig; import io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig; import io.github.lnyocly.ai4j.service.PlatformType; import org.junit.Assert; import org.junit.Test; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; import java.util.Properties; public class CliProviderConfigManagerTest { @Test public void saveAndLoadProviderAndWorkspaceConfigs() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-home"); Path workspace = Files.createTempDirectory("ai4j-cli-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); CliProvidersConfig providersConfig = CliProvidersConfig.builder() .defaultProfile("zhipu-main") .build(); providersConfig.getProfiles().put("zhipu-main", CliProviderProfile.builder() .provider("zhipu") .protocol("chat") .model("glm-4.7") .baseUrl("https://open.bigmodel.cn/api/coding/paas/v4") .apiKey("secret-zhipu") .build()); manager.saveProvidersConfig(providersConfig); manager.saveWorkspaceConfig(CliWorkspaceConfig.builder() .activeProfile("zhipu-main") .modelOverride("glm-4.7-plus") .experimentalSubagentsEnabled(Boolean.FALSE) .experimentalAgentTeamsEnabled(Boolean.TRUE) .enabledMcpServers(Arrays.asList(" fetch ", "time", "fetch")) .skillDirectories(Arrays.asList(" .ai4j/skills ", "C:/skills/team ", ".ai4j/skills")) .agentDirectories(Arrays.asList(" .ai4j/agents ", "C:/agents/team ", ".ai4j/agents")) .build()); CliProvidersConfig loadedProviders = manager.loadProvidersConfig(); CliWorkspaceConfig loadedWorkspace = manager.loadWorkspaceConfig(); CliResolvedProviderConfig resolved = manager.resolve( null, null, null, null, null, Collections.emptyMap(), new Properties() ); Assert.assertEquals("zhipu-main", loadedProviders.getDefaultProfile()); Assert.assertEquals(1, loadedProviders.getProfiles().size()); Assert.assertEquals("zhipu-main", loadedWorkspace.getActiveProfile()); Assert.assertEquals("glm-4.7-plus", loadedWorkspace.getModelOverride()); Assert.assertEquals(Boolean.FALSE, loadedWorkspace.getExperimentalSubagentsEnabled()); Assert.assertEquals(Boolean.TRUE, loadedWorkspace.getExperimentalAgentTeamsEnabled()); Assert.assertEquals(Arrays.asList("fetch", "time"), loadedWorkspace.getEnabledMcpServers()); Assert.assertEquals(Arrays.asList(".ai4j/skills", "C:/skills/team"), loadedWorkspace.getSkillDirectories()); Assert.assertEquals(Arrays.asList(".ai4j/agents", "C:/agents/team"), loadedWorkspace.getAgentDirectories()); Assert.assertEquals(PlatformType.ZHIPU, resolved.getProvider()); Assert.assertEquals(CliProtocol.CHAT, resolved.getProtocol()); Assert.assertEquals("glm-4.7-plus", resolved.getModel()); Assert.assertEquals("secret-zhipu", resolved.getApiKey()); } finally { restoreUserHome(previousUserHome); } } @Test public void loadProvidersConfigMigratesLegacyAutoProtocol() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-home-auto"); Path workspace = Files.createTempDirectory("ai4j-cli-workspace-auto"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); CliProvidersConfig providersConfig = CliProvidersConfig.builder() .defaultProfile("openai-main") .build(); providersConfig.getProfiles().put("openai-main", CliProviderProfile.builder() .provider("openai") .protocol("auto") .model("gpt-5-mini") .build()); manager.saveProvidersConfig(providersConfig); CliProvidersConfig loadedProviders = manager.loadProvidersConfig(); CliProviderProfile loadedProfile = loadedProviders.getProfiles().get("openai-main"); CliResolvedProviderConfig resolved = manager.resolve( null, null, null, null, null, Collections.emptyMap(), new Properties() ); Assert.assertNotNull(loadedProfile); Assert.assertEquals("responses", loadedProfile.getProtocol()); Assert.assertEquals(CliProtocol.RESPONSES, resolved.getProtocol()); } finally { restoreUserHome(previousUserHome); } } private void restoreUserHome(String value) { if (value == null) { System.clearProperty("user.home"); return; } System.setProperty("user.home", value); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliThemeStylerTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.render.CliThemeStyler; import io.github.lnyocly.ai4j.tui.TuiTheme; import org.junit.Test; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class CliThemeStylerTest { @Test public void styleTranscriptLineAddsAnsiForBulletBlocksWhenEnabled() { CliThemeStyler styler = new CliThemeStyler(theme(), true); String value = styler.styleTranscriptLine("• Error"); assertTrue(value.contains("\u001b[")); assertTrue(value.contains("Error")); } @Test public void buildPrimaryStatusLineStylesSpinnerAndDetail() { CliThemeStyler styler = new CliThemeStyler(theme(), true); String value = styler.buildPrimaryStatusLine("Thinking", true, "⠋", "Analyzing workspace"); assertTrue(value.contains("\u001b[")); assertTrue(value.contains("Thinking")); assertTrue(value.contains("Analyzing workspace")); } @Test public void ansiDisabledLeavesTranscriptPlain() { CliThemeStyler styler = new CliThemeStyler(theme(), false); String value = styler.styleTranscriptLine("• Approved"); assertFalse(value.contains("\u001b[")); assertTrue("• Approved".equals(value)); } @Test public void styleThinkingTranscriptLineUsesAnsiWhenEnabled() { CliThemeStyler styler = new CliThemeStyler(theme(), true); String value = styler.styleTranscriptLine("Thinking: inspect request"); assertTrue(value.contains("\u001b[")); assertTrue(value.contains("Thinking: inspect request")); } @Test public void buildCompactStatusLineIncludesStatusAndContext() { CliThemeStyler styler = new CliThemeStyler(theme(), true); String value = styler.buildCompactStatusLine( "Thinking", true, "⠋", "Analyzing workspace", "glm-4.7", "ai4j-sdk", "Enter a prompt or /command" ); assertTrue(value.contains("Thinking")); assertTrue(value.contains("glm-4.7")); assertTrue(value.contains("ai4j-sdk")); assertFalse(value.contains("Enter a prompt or /command")); } @Test public void styleCodeBlockLineUsesAnsiWhenEnabled() { CliThemeStyler styler = new CliThemeStyler(theme(), true); CliThemeStyler.TranscriptStyleState state = new CliThemeStyler.TranscriptStyleState(); state.enterCodeBlock("java"); String value = styler.styleTranscriptLine(" System.out.println(\"hi\");", state); assertTrue(value.contains("\u001b[")); assertTrue(value.contains("System")); assertTrue(value.contains("println")); assertTrue(value.contains("\"hi\"")); } @Test public void styleJavaCodeBlockUsesTranscriptStateForSyntaxHighlighting() { CliThemeStyler styler = new CliThemeStyler(theme(), true); CliThemeStyler.TranscriptStyleState state = new CliThemeStyler.TranscriptStyleState(); state.enterCodeBlock("java"); String body = styler.styleTranscriptLine(" public class Demo { String value = \"hi\"; // note", state); String close = styler.styleTranscriptLine("After"); assertTrue(body.contains("\u001b[")); assertTrue(body.contains("public")); assertTrue(body.contains("\"hi\"")); assertTrue(body.contains("// note")); assertTrue(close.contains("\u001b[")); } @Test public void styleJsonCodeBlockHighlightsKeysAndLiterals() { CliThemeStyler styler = new CliThemeStyler(theme(), true); CliThemeStyler.TranscriptStyleState state = new CliThemeStyler.TranscriptStyleState(); state.enterCodeBlock("json"); String value = styler.styleTranscriptLine(" {\"enabled\": true, \"count\": 2}", state); assertTrue(value.contains("\u001b[")); assertTrue(value.contains("\"enabled\"")); assertTrue(value.contains("true")); assertTrue(value.contains("2")); } @Test public void ansiDisabledStripsInlineMarkdownMarkers() { CliThemeStyler styler = new CliThemeStyler(theme(), false); String value = styler.styleTranscriptLine("Use `rg` and **fast path**"); assertTrue("Use rg and fast path".equals(value)); } @Test public void styleHeadingAndQuoteUseAnsiWhenEnabled() { CliThemeStyler styler = new CliThemeStyler(theme(), true); String heading = styler.styleTranscriptLine("## Files"); String quote = styler.styleTranscriptLine("> note"); assertTrue(heading.contains("\u001b[")); assertTrue(heading.contains("## Files")); assertTrue(quote.contains("\u001b[")); assertTrue(quote.contains("> note")); } private TuiTheme theme() { TuiTheme theme = new TuiTheme(); theme.setBrand("#7cc6fe"); theme.setAccent("#f5b14c"); theme.setSuccess("#8fd694"); theme.setWarning("#f4d35e"); theme.setDanger("#ef6f6c"); theme.setText("#f3f4f6"); theme.setMuted("#9ca3af"); return theme; } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandOptionsParserTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.cli.provider.CliProviderProfile; import io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig; import io.github.lnyocly.ai4j.service.PlatformType; import org.junit.Assert; import org.junit.Test; import java.nio.file.Paths; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Properties; public class CodeCommandOptionsParserTest { private final CodeCommandOptionsParser parser = new CodeCommandOptionsParser(); @Test public void test_parse_resolves_env_and_defaults() { Map env = new HashMap(); env.put("AI4J_WORKSPACE", "workspace-from-env"); env.put("ZHIPU_API_KEY", "zhipu-key-from-env"); CodeCommandOptions options = parser.parse( Arrays.asList("--provider", "zhipu", "--model", "GLM-4.5-Flash"), env, new Properties(), Paths.get(".") ); Assert.assertFalse(options.isHelp()); Assert.assertEquals(CliUiMode.CLI, options.getUiMode()); Assert.assertEquals(PlatformType.ZHIPU, options.getProvider()); Assert.assertEquals(CliProtocol.CHAT, options.getProtocol()); Assert.assertEquals("GLM-4.5-Flash", options.getModel()); Assert.assertEquals("zhipu-key-from-env", options.getApiKey()); Assert.assertEquals("workspace-from-env", options.getWorkspace()); Assert.assertEquals(0, options.getMaxSteps()); Assert.assertEquals(Boolean.FALSE, options.getParallelToolCalls()); } @Test public void test_help_does_not_require_model() { CodeCommandOptions options = parser.parse( Collections.singletonList("--help"), Collections.emptyMap(), new Properties(), Paths.get(".") ); Assert.assertTrue(options.isHelp()); Assert.assertEquals(CliUiMode.CLI, options.getUiMode()); Assert.assertNull(options.getModel()); } @Test public void test_parse_ui_mode_from_env() { Map env = new HashMap(); env.put("AI4J_UI", "tui"); CodeCommandOptions options = parser.parse( Arrays.asList("--model", "demo-model"), env, new Properties(), Paths.get(".") ); Assert.assertEquals(CliUiMode.TUI, options.getUiMode()); } @Test public void test_parse_session_options() { CodeCommandOptions options = parser.parse( Arrays.asList( "--model", "demo-model", "--workspace", "workspace-root", "--session-id", "session-alpha", "--fork", "session-beta", "--theme", "amber", "--approval", "safe", "--no-session", "false", "--session-dir", "custom-sessions", "--auto-save-session", "false", "--auto-compact", "true", "--compact-context-window-tokens", "64000", "--compact-reserve-tokens", "8000", "--compact-keep-recent-tokens", "12000", "--compact-summary-max-output-tokens", "512" ), Collections.emptyMap(), new Properties(), Paths.get(".") ); Assert.assertEquals("session-alpha", options.getSessionId()); Assert.assertEquals("session-beta", options.getForkSessionId()); Assert.assertEquals("amber", options.getTheme()); Assert.assertEquals(ApprovalMode.SAFE, options.getApprovalMode()); Assert.assertFalse(options.isNoSession()); Assert.assertEquals("custom-sessions", options.getSessionStoreDir()); Assert.assertFalse(options.isAutoSaveSession()); Assert.assertTrue(options.isAutoCompact()); Assert.assertEquals(64000, options.getCompactContextWindowTokens()); Assert.assertEquals(8000, options.getCompactReserveTokens()); Assert.assertEquals(12000, options.getCompactKeepRecentTokens()); Assert.assertEquals(512, options.getCompactSummaryMaxOutputTokens()); } @Test public void test_parse_stream_option() { CodeCommandOptions options = parser.parse( Arrays.asList("--model", "demo-model", "--stream", "true"), Collections.emptyMap(), new Properties(), Paths.get(".") ); Assert.assertTrue(options.isStream()); } @Test public void test_invalid_provider_should_fail_fast() { try { parser.parse( Arrays.asList("--provider", "unknown", "--model", "demo-model"), Collections.emptyMap(), new Properties(), Paths.get(".") ); Assert.fail("Expected invalid provider to fail fast"); } catch (IllegalArgumentException ex) { Assert.assertEquals("Unsupported provider: unknown", ex.getMessage()); } } @Test public void test_no_session_cannot_resume_or_fork_on_startup() { try { parser.parse( Arrays.asList("--model", "demo-model", "--no-session", "--resume", "session-alpha"), Collections.emptyMap(), new Properties(), Paths.get(".") ); Assert.fail("Expected conflicting no-session and resume"); } catch (IllegalArgumentException ex) { Assert.assertEquals("--no-session cannot be combined with --resume", ex.getMessage()); } try { parser.parse( Arrays.asList("--model", "demo-model", "--no-session", "--fork", "session-alpha"), Collections.emptyMap(), new Properties(), Paths.get(".") ); Assert.fail("Expected conflicting no-session and fork"); } catch (IllegalArgumentException ex) { Assert.assertEquals("--no-session cannot be combined with --fork", ex.getMessage()); } } @Test public void test_parse_prefers_workspace_and_global_profile_config_before_env_fallbacks() throws Exception { java.nio.file.Path home = java.nio.file.Files.createTempDirectory("ai4j-cli-parser-home"); java.nio.file.Path workspace = java.nio.file.Files.createTempDirectory("ai4j-cli-parser-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); CliProvidersConfig providersConfig = CliProvidersConfig.builder() .defaultProfile("openai-default") .build(); providersConfig.getProfiles().put("openai-default", CliProviderProfile.builder() .provider("openai") .protocol("responses") .model("gpt-5-mini") .apiKey("openai-default-key") .build()); providersConfig.getProfiles().put("zhipu-workspace", CliProviderProfile.builder() .provider("zhipu") .protocol("chat") .model("glm-4.7") .baseUrl("https://open.bigmodel.cn/api/coding/paas/v4") .apiKey("zhipu-workspace-key") .build()); manager.saveProvidersConfig(providersConfig); manager.saveWorkspaceConfig(CliWorkspaceConfig.builder() .activeProfile("zhipu-workspace") .modelOverride("glm-4.7-plus") .build()); Map env = new HashMap(); env.put("AI4J_PROVIDER", "deepseek"); env.put("AI4J_MODEL", "deepseek-chat"); env.put("AI4J_API_KEY", "generic-env-key"); env.put("DEEPSEEK_API_KEY", "deepseek-env-key"); CodeCommandOptions options = parser.parse( Arrays.asList("--workspace", workspace.toString()), env, new Properties(), workspace ); Assert.assertEquals(PlatformType.ZHIPU, options.getProvider()); Assert.assertEquals(CliProtocol.CHAT, options.getProtocol()); Assert.assertEquals("glm-4.7-plus", options.getModel()); Assert.assertEquals("zhipu-workspace-key", options.getApiKey()); Assert.assertEquals("https://open.bigmodel.cn/api/coding/paas/v4", options.getBaseUrl()); } finally { if (previousUserHome == null) { System.clearProperty("user.home"); } else { System.setProperty("user.home", previousUserHome); } } } @Test public void test_parse_cli_runtime_values_override_profile_config() throws Exception { java.nio.file.Path home = java.nio.file.Files.createTempDirectory("ai4j-cli-parser-home-override"); java.nio.file.Path workspace = java.nio.file.Files.createTempDirectory("ai4j-cli-parser-workspace-override"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); CliProvidersConfig providersConfig = CliProvidersConfig.builder() .defaultProfile("zhipu-default") .build(); providersConfig.getProfiles().put("zhipu-default", CliProviderProfile.builder() .provider("zhipu") .protocol("chat") .model("glm-4.7") .apiKey("zhipu-config-key") .build()); manager.saveProvidersConfig(providersConfig); CodeCommandOptions options = parser.parse( Arrays.asList( "--workspace", workspace.toString(), "--provider", "openai", "--protocol", "responses", "--model", "gpt-5", "--api-key", "openai-cli-key", "--base-url", "https://api.openai.com/v1" ), Collections.emptyMap(), new Properties(), workspace ); Assert.assertEquals(PlatformType.OPENAI, options.getProvider()); Assert.assertEquals(CliProtocol.RESPONSES, options.getProtocol()); Assert.assertEquals("gpt-5", options.getModel()); Assert.assertEquals("openai-cli-key", options.getApiKey()); Assert.assertEquals("https://api.openai.com/v1", options.getBaseUrl()); } finally { if (previousUserHome == null) { System.clearProperty("user.home"); } else { System.setProperty("user.home", previousUserHome); } } } @Test public void test_parse_rejects_explicit_auto_protocol_flag() { try { parser.parse( Arrays.asList("--provider", "openai", "--protocol", "auto", "--model", "gpt-5-mini"), Collections.emptyMap(), new Properties(), Paths.get(".") ); Assert.fail("Expected auto protocol flag to be rejected"); } catch (IllegalArgumentException ex) { Assert.assertEquals("Unsupported protocol: auto. Expected: chat, responses", ex.getMessage()); } } @Test public void test_parse_provider_override_does_not_reuse_mismatched_profile_credentials() throws Exception { java.nio.file.Path home = java.nio.file.Files.createTempDirectory("ai4j-cli-parser-home-provider-only"); java.nio.file.Path workspace = java.nio.file.Files.createTempDirectory("ai4j-cli-parser-workspace-provider-only"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); CliProvidersConfig providersConfig = CliProvidersConfig.builder() .defaultProfile("zhipu-default") .build(); providersConfig.getProfiles().put("zhipu-default", CliProviderProfile.builder() .provider("zhipu") .protocol("chat") .model("glm-4.7") .apiKey("zhipu-config-key") .baseUrl("https://open.bigmodel.cn/api/coding/paas/v4") .build()); manager.saveProvidersConfig(providersConfig); Map env = new HashMap(); env.put("OPENAI_API_KEY", "openai-env-key"); CodeCommandOptions options = parser.parse( Arrays.asList("--workspace", workspace.toString(), "--provider", "openai", "--model", "gpt-5-mini"), env, new Properties(), workspace ); Assert.assertEquals(PlatformType.OPENAI, options.getProvider()); Assert.assertEquals("gpt-5-mini", options.getModel()); Assert.assertEquals("openai-env-key", options.getApiKey()); Assert.assertNull(options.getBaseUrl()); } finally { if (previousUserHome == null) { System.clearProperty("user.home"); } else { System.setProperty("user.home", previousUserHome); } } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.cli.command.CodeCommand; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.cli.provider.CliProviderProfile; import io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig; import io.github.lnyocly.ai4j.cli.runtime.CliToolApprovalDecorator; import io.github.lnyocly.ai4j.cli.runtime.CodingCliSessionRunner; import io.github.lnyocly.ai4j.cli.runtime.CodingCliTuiSupport; import io.github.lnyocly.ai4j.cli.shell.JlineShellContext; import io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberSnapshot; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.CodingAgents; import io.github.lnyocly.ai4j.coding.CodingSessionState; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory.PreparedCodingAgent; import io.github.lnyocly.ai4j.cli.factory.CodingCliTuiFactory; import io.github.lnyocly.ai4j.cli.mcp.CliMcpConfig; import io.github.lnyocly.ai4j.cli.mcp.CliMcpConfigManager; import io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager; import io.github.lnyocly.ai4j.cli.mcp.CliMcpServerDefinition; import io.github.lnyocly.ai4j.cli.session.CodingSessionManager; import io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager; import io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore; import io.github.lnyocly.ai4j.cli.session.StoredCodingSession; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore; import io.github.lnyocly.ai4j.coding.process.BashProcessStatus; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.listener.SseListener; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion; import io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.tui.StreamsTerminalIO; import io.github.lnyocly.ai4j.tui.TerminalIO; import io.github.lnyocly.ai4j.tui.TuiInteractionState; import io.github.lnyocly.ai4j.tui.TuiKeyStroke; import io.github.lnyocly.ai4j.tui.TuiKeyType; import io.github.lnyocly.ai4j.tui.AppendOnlyTuiRuntime; import io.github.lnyocly.ai4j.tui.TuiConfig; import io.github.lnyocly.ai4j.tui.TuiConfigManager; import io.github.lnyocly.ai4j.tui.TuiRenderer; import io.github.lnyocly.ai4j.tui.TuiRuntime; import io.github.lnyocly.ai4j.tui.TuiScreenModel; import io.github.lnyocly.ai4j.tui.TuiSessionView; import io.github.lnyocly.ai4j.tui.TuiTheme; import org.jline.reader.LineReader; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; import org.junit.Assert; import org.junit.Test; import okhttp3.Protocol; import okhttp3.Request; import okhttp3.sse.EventSource; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collections; import java.util.Deque; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; public class CodeCommandTest { @Test public void test_interactive_mode_runs_until_exit() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-test"); Files.write(workspace.resolve("sample.txt"), Collections.singletonList("hello-cli"), StandardCharsets.UTF_8); ByteArrayInputStream input = new ByteArrayInputStream( "Please inspect sample.txt\n/exit\n".getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("[tool] read_file")); Assert.assertTrue(output.contains("Read result: hello-cli")); Assert.assertTrue(output.contains("Session closed.")); } @Test public void test_interactive_compact_command() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-compact"); ByteArrayInputStream input = new ByteArrayInputStream( ("say hello\n" + "/save\n" + "/session\n" + "/theme\n" + "/theme amber\n" + "/sessions\n" + "/events 20\n" + "/checkpoint\n" + "/processes\n" + "/resume session-alpha\n" + "/compact\n" + "/compacts 10\n" + "/status\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--session-id", "session-alpha"), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("saved session: session-alpha")); Assert.assertTrue(output.contains("session")); Assert.assertTrue(output.contains("themes:")); Assert.assertTrue(output.contains("theme switched to: amber")); Assert.assertTrue(output.contains("sessions:")); Assert.assertTrue(output.contains("events:")); Assert.assertTrue(output.contains("checkpoint")); Assert.assertTrue(output.contains("processes:")); Assert.assertTrue(output.contains("resumed session: session-alpha")); Assert.assertTrue(output.contains("compact: mode=manual")); Assert.assertTrue(output.contains("compacts:")); Assert.assertTrue(output.contains("items=")); Assert.assertTrue(output.contains("checkpointGoal=Continue the CLI session.")); Assert.assertTrue(output.contains("compact=manual")); Assert.assertTrue(output.contains("Session closed.")); } @Test public void test_resume_session_across_runs() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-resume"); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int firstExit = command.run( Arrays.asList( "--model", "fake-model", "--workspace", workspace.toString(), "--session-id", "resume-me", "--prompt", "remember alpha" ), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), new ByteArrayOutputStream()) ); Assert.assertEquals(0, firstExit); ByteArrayOutputStream resumedOut = new ByteArrayOutputStream(); ByteArrayOutputStream resumedErr = new ByteArrayOutputStream(); int resumedExit = command.run( Arrays.asList( "--model", "fake-model", "--workspace", workspace.toString(), "--resume", "resume-me", "--prompt", "what do you remember" ), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), resumedOut, resumedErr) ); String output = new String(resumedOut.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, resumedExit); Assert.assertTrue(output.contains("session=resume-me")); Assert.assertTrue(output.contains("history: remember alpha")); } @Test public void test_interactive_history_tree_and_fork() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-fork"); ByteArrayInputStream input = new ByteArrayInputStream( ("remember alpha\n" + "/save\n" + "/fork session-beta\n" + "what do you remember\n" + "/history\n" + "/tree\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--session-id", "session-alpha"), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("forked session: session-beta <- session-alpha")); Assert.assertTrue(output.contains("history: remember alpha | what do you remember")); Assert.assertTrue(output.contains("history:")); Assert.assertTrue(output.contains("tree:")); Assert.assertTrue(output.contains("session-alpha")); Assert.assertTrue(output.contains("session-beta")); } @Test public void test_no_session_mode_avoids_file_session_store() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-no-session"); Path sessionDir = workspace.resolve(".ai4j").resolve("sessions"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--no-session", "--prompt", "say hello"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Echo: say hello")); Assert.assertFalse(Files.exists(sessionDir)); } @Test public void test_custom_command_templates_are_listed_and_executed() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-custom-cmd"); Path commandsDir = Files.createDirectories(workspace.resolve(".ai4j").resolve("commands")); Files.write(commandsDir.resolve("review.md"), Collections.singletonList( "# Review workspace\nReview the workspace at $WORKSPACE.\nFocus: $ARGUMENTS\nSession: $SESSION_ID" ), StandardCharsets.UTF_8); ByteArrayInputStream input = new ByteArrayInputStream( ("/commands\n" + "/cmd review auth flow\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("commands:")); Assert.assertTrue(output.contains("review")); Assert.assertTrue(output.contains("Echo: Review the workspace at " + workspace.toString())); Assert.assertTrue(output.contains("Focus: auth flow")); } @Test public void test_skills_command_lists_discovered_workspace_skills() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-skills"); Path skillsDir = Files.createDirectories(workspace.resolve(".ai4j").resolve("skills").resolve("repo-review")); Files.write(skillsDir.resolve("SKILL.md"), Arrays.asList( "---", "name: repo-review", "description: Review repository changes safely.", "---", "", "# Repo Review", "Review repository changes safely." ), StandardCharsets.UTF_8); ByteArrayInputStream input = new ByteArrayInputStream( ("/skills\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("skills:")); Assert.assertTrue(output.contains("count=1")); Assert.assertTrue(output.contains("repo-review")); Assert.assertTrue(output.contains("source=workspace")); Assert.assertTrue(output.contains("Review repository changes safely.")); } @Test public void test_skills_command_with_name_prints_skill_detail_and_content() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-skill-detail"); Path skillsDir = Files.createDirectories(workspace.resolve(".ai4j").resolve("skills").resolve("repo-review")); Files.write(skillsDir.resolve("SKILL.md"), Arrays.asList( "---", "name: repo-review", "description: Review repository changes safely.", "---", "", "# Repo Review", "Review repository changes safely.", "", "Use rg before grep." ), StandardCharsets.UTF_8); ByteArrayInputStream input = new ByteArrayInputStream( ("/skills repo-review\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("skill:")); Assert.assertTrue(output.contains("name=repo-review")); Assert.assertTrue(output.contains("path=")); Assert.assertTrue(output.contains("description=Review repository changes safely.")); Assert.assertTrue(output.contains("roots=")); Assert.assertFalse(output.contains("content:")); Assert.assertFalse(output.contains("# Repo Review")); Assert.assertFalse(output.contains("Use rg before grep.")); } @Test public void test_safe_approval_mode_prompts_before_bash_exec() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-approval"); Files.write(workspace.resolve("sample.txt"), Collections.singletonList("hello-cli"), StandardCharsets.UTF_8); ByteArrayInputStream input = new ByteArrayInputStream( ("run bash sample\n" + "y\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--approval", "safe"), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Approval required for bash")); Assert.assertTrue(output.contains("type sample.txt")); } @Test public void test_one_shot_prompt_mode() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-oneshot"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "say hello"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Echo: say hello")); Assert.assertFalse(output.contains("assistant>")); } @Test public void test_one_shot_prompt_mode_does_not_duplicate_streamed_final_output() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-oneshot-stream"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "chunked hello"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertEquals(1, countOccurrences(output, "Hello world from stream.")); } @Test public void test_tui_mode_renders_tui_shell() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "say hello"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("AI4J")); Assert.assertTrue(output.contains("fake-model")); Assert.assertFalse(output.contains("STATUS")); Assert.assertFalse(output.contains("HISTORY")); Assert.assertFalse(output.contains("TREE")); Assert.assertTrue(output.contains("Echo: say hello")); } @Test public void test_tui_mode_requests_approval_inline_for_safe_mode() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-approval"); Files.write(workspace.resolve("sample.txt"), Collections.singletonList("hello-cli"), StandardCharsets.UTF_8); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--approval", "safe", "--prompt", "run bash sample"), new StreamsTerminalIO(new ByteArrayInputStream("y\n".getBytes(StandardCharsets.UTF_8)), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Approval required for bash")); Assert.assertTrue(output.contains("type sample.txt")); Assert.assertTrue(output.contains("Approve? [y/N]")); Assert.assertTrue(output.contains("Approved")); } @Test public void test_tui_mode_rejection_keeps_rejected_block_without_failed_tool_card() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-approval-rejected"); Files.write(workspace.resolve("sample.txt"), Collections.singletonList("hello-cli"), StandardCharsets.UTF_8); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--approval", "safe", "--prompt", "run bash sample"), new StreamsTerminalIO(new ByteArrayInputStream("n\n".getBytes(StandardCharsets.UTF_8)), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Rejected")); Assert.assertFalse(output.contains("Tool failed")); Assert.assertFalse(output.contains("Command failed")); } @Test public void test_tui_mode_renders_tool_cards_for_bash_turns() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-bash"); Files.write(workspace.resolve("sample.txt"), Collections.singletonList("hello-cli"), StandardCharsets.UTF_8); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "run bash sample"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Ran type sample.txt")); Assert.assertFalse(output.contains("exit=0")); Assert.assertTrue(output.contains("hello-cli")); Assert.assertFalse(output.contains("stdout> hello-cli")); } @Test public void test_tui_mode_keeps_session_alive_when_apply_patch_fails() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-invalid-patch"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "run invalid patch"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Tool failed apply_patch")); Assert.assertTrue(output.contains("Unsupported patch line")); Assert.assertFalse(output.contains("Argument error:")); } @Test public void test_tui_mode_accepts_recoverable_apply_patch_headers() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-recoverable-patch"); Files.write(workspace.resolve("sample.txt"), Collections.singletonList("value=1"), StandardCharsets.UTF_8); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "run recoverable patch"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertFalse(output.contains("Tool failed apply_patch")); Assert.assertEquals("value=2", new String(Files.readAllBytes(workspace.resolve("sample.txt")), StandardCharsets.UTF_8).trim()); } @Test public void test_tui_mode_accepts_recoverable_unified_diff_apply_patch_headers() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-unified-patch"); Files.write(workspace.resolve("sample.txt"), Collections.singletonList("value=1"), StandardCharsets.UTF_8); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "run unified diff patch"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertFalse(output.contains("Tool failed apply_patch")); Assert.assertEquals("value=2", new String(Files.readAllBytes(workspace.resolve("sample.txt")), StandardCharsets.UTF_8).trim()); } @Test public void test_tui_mode_surfaces_invalid_bash_calls_without_hiding_tool_feedback() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-invalid-bash"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "run invalid bash"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Need to inspect the shell call.")); Assert.assertTrue(output.contains("Command failed bash exec")); Assert.assertTrue(output.contains("bash exec requires a non-empty command")); Assert.assertTrue(output.contains("Tool error: bash exec requires a non-empty command")); Assert.assertFalse(output.contains("(empty command)")); } @Test public void test_tui_interactive_main_buffer_prints_reasoning_in_transcript_before_tool_feedback() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-interactive-reasoning"); ByteArrayInputStream input = new ByteArrayInputStream( ("run invalid bash\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Thinking: Need to inspect the shell call.")); Assert.assertTrue(output.contains("Command failed bash exec")); Assert.assertTrue(output.indexOf("Thinking: Need to inspect the shell call.") < output.indexOf("Command failed bash exec")); } @Test public void test_tui_mode_prints_main_buffer_status_without_fullscreen_redraw() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-spinner"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "slow hello"), new RecordingInteractiveTerminal(out, err) ); String rawOutput = new String(out.toByteArray(), StandardCharsets.UTF_8); String output = stripAnsi(rawOutput); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Slow hello done.")); Assert.assertFalse(rawOutput.matches("(?s).*\\r(?!\\n).*")); Assert.assertFalse(rawOutput.contains("\u001b[2K")); } @Test public void test_tui_mode_streams_chunked_main_buffer_output_without_duplicate_final_flush() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-stream"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "chunked hello"), new RecordingInteractiveTerminal(out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Hello world from stream.")); Assert.assertEquals(1, countOccurrences(output, "Hello world from stream.")); Assert.assertFalse(output.contains("Hello world from stream.Hello world from stream.")); } @Test public void test_tui_mode_renders_fenced_code_blocks_in_streaming_transcript() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-code-block"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "show code block"), new RecordingInteractiveTerminal(out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertFalse(output.contains("[java]")); Assert.assertFalse(output.contains("[code]")); Assert.assertFalse(output.contains("\u2063")); Assert.assertFalse(output.contains("\u200b")); Assert.assertTrue(output.contains("System.out.println(\"hi\");")); Assert.assertFalse(output.contains(" ╭")); Assert.assertFalse(output.contains(" ╰")); } @Test public void test_tui_mode_buffers_split_code_fence_chunks_without_leaking_backticks() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-split-fence"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "show split fence"), new RecordingInteractiveTerminal(out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Here is python:")); Assert.assertTrue(output.contains("print(\"Hello, World!\")")); Assert.assertTrue(output.contains("Done.")); Assert.assertFalse(output.contains("```")); Assert.assertFalse(output.contains(System.lineSeparator() + "``")); } @Test public void test_tui_mode_does_not_duplicate_final_output_when_provider_rewrites_markdown() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-rewrite-final"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "rewrite final markdown"), new RecordingInteractiveTerminal(out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertEquals(1, countOccurrences(output, "已经为你创建了")); Assert.assertTrue(output.contains("print(\"Hello, World!\")")); } @Test public void test_tui_mode_replays_missing_final_code_block_segment_when_streamed_text_is_incomplete() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-final-code-block"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "final adds code block"), new RecordingInteractiveTerminal(out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("你可以通过以下命令运行它:")); Assert.assertTrue(output.contains("python hello_world.py")); Assert.assertTrue(output.contains("这个程序会输出:Hello, World!")); } @Test public void test_stream_command_turns_streaming_off_for_current_session() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-stream-command"); ByteArrayInputStream input = new ByteArrayInputStream( ("/stream off\n" + "show code block\n" + "/stream\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("status=off")); Assert.assertTrue(output.contains("request=stream=false")); Assert.assertTrue(output.contains("renders as completed blocks")); Assert.assertFalse(output.contains("[java]")); Assert.assertFalse(output.contains("[code]")); Assert.assertFalse(output.contains("\u2063")); Assert.assertFalse(output.contains("\u200b")); Assert.assertTrue(output.contains("System.out.println(\"hi\");")); } @Test public void test_stream_command_is_on_by_default_for_new_session() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-stream-default-on"); ByteArrayInputStream input = new ByteArrayInputStream( ("/stream\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("status=on")); Assert.assertTrue(output.contains("request=stream=true")); Assert.assertTrue(output.contains("stream incrementally")); } @Test public void test_stream_command_turns_request_streaming_on_for_current_session() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-stream-command-on"); ByteArrayInputStream input = new ByteArrayInputStream( ("/stream on\n" + "record request mode\n" + "/stream\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("status=on")); Assert.assertTrue(output.contains("request=stream=true")); Assert.assertTrue(output.contains("mode=createStream")); } @Test public void test_cli_stream_option_false_uses_non_streaming_model_request() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-stream-off-request"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--stream", "false", "--prompt", "record request mode"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("mode=create")); Assert.assertFalse(output.contains("mode=createStream")); } @Test public void test_cli_stream_option_uses_streaming_model_request() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-stream-option"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--stream", "true", "--prompt", "record request mode"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("mode=createStream")); } @Test public void test_stream_off_does_not_duplicate_completed_assistant_output() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-stream-off-duplicate"); ByteArrayInputStream input = new ByteArrayInputStream( ("/stream off\n" + "chunked hello\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertEquals(1, countOccurrences(output, "Hello world from stream.")); } @Test public void test_provider_and_model_commands_switch_runtime_and_persist_configs() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-provider-home"); Path workspace = Files.createTempDirectory("ai4j-cli-provider-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); CliProvidersConfig providersConfig = CliProvidersConfig.builder() .defaultProfile("openai-main") .build(); providersConfig.getProfiles().put("openai-main", CliProviderProfile.builder() .provider("openai") .protocol("chat") .model("fake-model") .apiKey("openai-main-key") .build()); providersConfig.getProfiles().put("zhipu-main", CliProviderProfile.builder() .provider("zhipu") .protocol("chat") .model("glm-4.7") .baseUrl("https://open.bigmodel.cn/api/coding/paas/v4") .apiKey("zhipu-main-key") .build()); manager.saveProvidersConfig(providersConfig); ByteArrayInputStream input = new ByteArrayInputStream( ("/provider save openai-local\n" + "/provider default openai-local\n" + "/provider use zhipu-main\n" + "/status\n" + "/model glm-4.7-plus\n" + "/status\n" + "/provider default clear\n" + "/model reset\n" + "/status\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList( "--ui", "tui", "--workspace", workspace.toString(), "--provider", "openai", "--protocol", "chat", "--model", "fake-model", "--api-key", "openai-cli-key" ), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("provider saved: openai-local")); Assert.assertTrue(output.contains("provider default: openai-local")); Assert.assertTrue(output.contains("provider=zhipu, protocol=chat, model=glm-4.7")); Assert.assertTrue(output.contains("modelOverride=glm-4.7-plus")); Assert.assertTrue(output.contains("provider=zhipu, protocol=chat, model=glm-4.7-plus")); Assert.assertTrue(output.contains("provider default cleared")); Assert.assertTrue(output.contains("modelOverride=(none)")); CliWorkspaceConfig workspaceConfig = manager.loadWorkspaceConfig(); Assert.assertEquals("zhipu-main", workspaceConfig.getActiveProfile()); Assert.assertNull(workspaceConfig.getModelOverride()); CliProvidersConfig savedProviders = manager.loadProvidersConfig(); Assert.assertNull(savedProviders.getDefaultProfile()); CliProviderProfile savedProfile = manager.getProfile("openai-local"); Assert.assertNotNull(savedProfile); Assert.assertEquals("openai", savedProfile.getProvider()); Assert.assertEquals("fake-model", savedProfile.getModel()); Assert.assertEquals("openai-cli-key", savedProfile.getApiKey()); } finally { restoreUserHome(previousUserHome); } } @Test public void test_provider_add_and_edit_persist_explicit_protocols() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-provider-add-home"); Path workspace = Files.createTempDirectory("ai4j-cli-provider-add-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); ByteArrayInputStream input = new ByteArrayInputStream( ("/provider add zhipu-added --provider zhipu --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --api-key added-key\n" + "/provider edit zhipu-added --model glm-4.7-plus\n" + "/provider use zhipu-added\n" + "/status\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList( "--ui", "tui", "--workspace", workspace.toString(), "--provider", "openai", "--protocol", "chat", "--model", "fake-model", "--api-key", "openai-cli-key" ), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("provider added: zhipu-added")); Assert.assertTrue(output.contains("provider updated: zhipu-added")); Assert.assertTrue(output.contains("provider=zhipu, protocol=chat, model=glm-4.7-plus")); CliProviderProfile savedProfile = manager.getProfile("zhipu-added"); Assert.assertNotNull(savedProfile); Assert.assertEquals("zhipu", savedProfile.getProvider()); Assert.assertEquals("chat", savedProfile.getProtocol()); Assert.assertEquals("glm-4.7-plus", savedProfile.getModel()); Assert.assertEquals("https://open.bigmodel.cn/api/coding/paas/v4", savedProfile.getBaseUrl()); Assert.assertEquals("added-key", savedProfile.getApiKey()); } finally { restoreUserHome(previousUserHome); } } @Test public void test_experimental_command_persists_workspace_flags() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-experimental-workspace"); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); ByteArrayInputStream input = new ByteArrayInputStream( ("/experimental\n" + "/experimental subagent off\n" + "/experimental agent-teams off\n" + "/experimental\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList( "--ui", "tui", "--workspace", workspace.toString(), "--provider", "openai", "--protocol", "chat", "--model", "fake-model", "--api-key", "openai-cli-key" ), new StreamsTerminalIO(input, out, err) ); Assert.assertEquals(0, exitCode); CliWorkspaceConfig workspaceConfig = manager.loadWorkspaceConfig(); Assert.assertEquals(Boolean.FALSE, workspaceConfig.getExperimentalSubagentsEnabled()); Assert.assertEquals(Boolean.FALSE, workspaceConfig.getExperimentalAgentTeamsEnabled()); } @Test public void test_team_management_commands_render_persisted_team_state() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-team-management"); seedPersistedTeamState(workspace, "experimental-delivery-team"); ByteArrayInputStream input = new ByteArrayInputStream( ("/team list\n" + "/team status experimental-delivery-team\n" + "/team messages experimental-delivery-team 5\n" + "/team resume experimental-delivery-team\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("teams:")); Assert.assertTrue(output.contains("experimental-delivery-team")); Assert.assertTrue(output.contains("team status:")); Assert.assertTrue(output.contains("objective=Deliver a travel planner demo.")); Assert.assertTrue(output.contains("team messages:")); Assert.assertTrue(output.contains("Define the backend contract first.")); Assert.assertTrue(output.contains("team resumed: experimental-delivery-team")); Assert.assertTrue(output.contains("team board:")); Assert.assertTrue(output.contains("lane Backend")); } @Test public void test_mcp_commands_persist_configs_and_render_statuses() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-mcp-home"); Path workspace = Files.createTempDirectory("ai4j-cli-mcp-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliMcpConfigManager manager = new CliMcpConfigManager(workspace); CliMcpConfig globalConfig = CliMcpConfig.builder().build(); globalConfig.getMcpServers().put("fetch", CliMcpServerDefinition.builder() .type("sse") .url("https://mcp.api-inference.modelscope.net/1e1a663049b340/sse") .build()); manager.saveGlobalConfig(globalConfig); ByteArrayInputStream input = new ByteArrayInputStream( ("/mcp\n" + "/mcp enable fetch\n" + "/mcp pause fetch\n" + "/mcp resume fetch\n" + "/mcp add --transport http bing-cn-mcp-server https://mcp.api-inference.modelscope.net/0904773a8c2045/mcp\n" + "/mcp enable bing-cn-mcp-server\n" + "/mcp remove bing-cn-mcp-server\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output, output.contains("fetch | type=sse | state=disabled | workspace=disabled | paused=no | tools=0")); Assert.assertTrue(output, output.contains("fetch | type=sse | state=configured | workspace=enabled | paused=no | tools=0")); Assert.assertTrue(output, output.contains("fetch | type=sse | state=paused | workspace=enabled | paused=yes | tools=0")); Assert.assertTrue(output, output.contains("mcp added: bing-cn-mcp-server")); Assert.assertTrue(output, output.contains("mcp removed: bing-cn-mcp-server")); CliWorkspaceConfig workspaceConfig = manager.loadWorkspaceConfig(); Assert.assertEquals(Collections.singletonList("fetch"), workspaceConfig.getEnabledMcpServers()); CliMcpConfig savedGlobal = manager.loadGlobalConfig(); Assert.assertTrue(savedGlobal.getMcpServers().containsKey("fetch")); Assert.assertFalse(savedGlobal.getMcpServers().containsKey("bing-cn-mcp-server")); } finally { restoreUserHome(previousUserHome); } } @Test public void test_startup_warns_for_unavailable_mcp_servers_without_aborting_session() throws Exception { Path home = Files.createTempDirectory("ai4j-cli-mcp-warning-home"); Path workspace = Files.createTempDirectory("ai4j-cli-mcp-warning-workspace"); String previousUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", home.toString()); CliMcpConfigManager manager = new CliMcpConfigManager(workspace); CliMcpConfig globalConfig = CliMcpConfig.builder().build(); globalConfig.getMcpServers().put("broken", CliMcpServerDefinition.builder() .type("sse") .build()); manager.saveGlobalConfig(globalConfig); CliWorkspaceConfig workspaceConfig = new CliWorkspaceConfig(); workspaceConfig.setEnabledMcpServers(Arrays.asList("broken", "missing")); manager.saveWorkspaceConfig(workspaceConfig); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new ConfiguredRuntimeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "say hello"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Warning: MCP unavailable: broken (sse transport requires url)")); Assert.assertTrue(output.contains("Warning: MCP unavailable: missing (workspace references undefined MCP server)")); Assert.assertTrue(output.contains("Echo: say hello")); } finally { restoreUserHome(previousUserHome); } } @Test public void test_stream_off_collapses_blank_reasoning_lines_in_main_buffer() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-stream-off-reasoning"); ByteArrayInputStream input = new ByteArrayInputStream( ("/stream off\n" + "sparse reasoning\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); String continuationBlankLine = System.lineSeparator() + " " + System.lineSeparator(); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Thinking: First line.")); Assert.assertTrue(output.contains("Second line.")); Assert.assertFalse(output.contains(continuationBlankLine)); } @Test public void test_tui_mode_uses_text_commands_in_non_alternate_screen_mode() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-main-buffer-commands"); ByteArrayInputStream input = new ByteArrayInputStream( ("/commands\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Commands")); Assert.assertTrue(output.contains("(none)")); Assert.assertFalse(output.contains("Exit session")); Assert.assertFalse(output.contains("commands:")); } @Test public void test_append_only_tui_keeps_error_state_when_stream_finishes_with_blank_output() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-stream-error"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), new AppendOnlyPromptTuiFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "stream auth fail"), new RecordingInteractiveTerminal(out, err) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Error: Invalid API key")); Assert.assertFalse(output.contains("Done")); } @Test public void test_append_only_tui_slash_enter_applies_selection_without_immediate_dispatch() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-slash-enter"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), new AppendOnlyPromptTuiFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new KeyedRecordingInteractiveTerminal(out, err, Arrays.asList( TuiKeyStroke.character("/"), TuiKeyStroke.character("s"), TuiKeyStroke.of(TuiKeyType.ENTER), TuiKeyStroke.of(TuiKeyType.ESCAPE), TuiKeyStroke.character("/"), TuiKeyStroke.character("e"), TuiKeyStroke.character("x"), TuiKeyStroke.character("i"), TuiKeyStroke.character("t"), TuiKeyStroke.of(TuiKeyType.ENTER) )) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())); Assert.assertEquals(0, exitCode); Assert.assertFalse(output.contains("status:")); } @Test public void test_append_only_tui_typing_slash_keeps_a_single_input_render() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-single-slash"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), new AppendOnlyPromptTuiFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new KeyedRecordingInteractiveTerminal(out, err, Collections.singletonList( TuiKeyStroke.character("/") )) ); String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())).replace("\r", ""); Assert.assertEquals(0, exitCode); Assert.assertEquals(1, countOccurrences(output, "> /")); Assert.assertEquals(1, countOccurrences(output, "• Commands")); } @Test public void test_replay_command_groups_recent_turns() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-replay"); ByteArrayInputStream input = new ByteArrayInputStream( ("first message\n" + "second message\n" + "/replay 20\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("replay:")); Assert.assertTrue(output.contains("first message")); Assert.assertFalse(output.contains("you> first message")); Assert.assertTrue(output.contains("Echo: second message")); Assert.assertFalse(output.contains("assistant> Echo: second message")); } @Test public void test_replay_command_hides_reasoning_prefix() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-replay-reasoning"); ByteArrayInputStream input = new ByteArrayInputStream( ("reason first\n" + "/replay 20\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("replay:")); Assert.assertTrue(output.contains("Need to inspect the request first.")); Assert.assertTrue(output.contains("Done reasoning.")); Assert.assertFalse(output.contains("assistant> Done reasoning.")); Assert.assertFalse(output.contains("reasoning> ")); } @Test public void test_process_status_and_follow_commands_with_restored_snapshot() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-process-status"); seedStoredSession(workspace, "process-seeded", "proc_demo", "[stdout] ready\n[stdout] waiting\n"); ByteArrayInputStream input = new ByteArrayInputStream( ("/resume process-seeded\n" + "/process status proc_demo\n" + "/process follow proc_demo 200\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("resumed session: process-seeded")); Assert.assertTrue(output.contains("process status:")); Assert.assertTrue(output.contains("id=proc_demo")); Assert.assertTrue(output.contains("mode=metadata-only")); Assert.assertTrue(output.contains("process follow:")); Assert.assertTrue(output.contains("ready")); Assert.assertTrue(output.contains("waiting")); } @Test public void test_tui_mode_uses_replay_command_in_non_alternate_screen_mode() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-tui-replay"); ByteArrayInputStream input = new ByteArrayInputStream( ("first\n" + "second\n" + "/replay 20\n" + "/exit\n").getBytes(StandardCharsets.UTF_8) ); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), new StreamsTerminalIO(input, out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Replay")); Assert.assertTrue(output.contains("first")); Assert.assertFalse(output.contains("history\n")); Assert.assertTrue(output.contains("Echo: second")); } @Test public void test_default_non_alternate_screen_terminal_uses_main_buffer_mode() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-main-buffer-default"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); RecordingInteractiveTerminal terminal = new RecordingInteractiveTerminal(out, err); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), terminal ); String output = out.toString(StandardCharsets.UTF_8.name()); Assert.assertEquals(0, exitCode); Assert.assertEquals(1, terminal.getReadLineCalls()); Assert.assertTrue(output.contains("AI4J fake-model")); Assert.assertFalse(output.contains("Interactive TUI input is unavailable")); } @Test public void test_main_buffer_mode_defers_next_prompt_until_output_starts() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-main-buffer-async"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); ScriptedInteractiveTerminal terminal = new ScriptedInteractiveTerminal( out, err, Arrays.asList("slow hello", "/exit") ); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), Collections.emptyMap(), new Properties(), workspace ); ExecutorService executor = Executors.newSingleThreadExecutor(); try { Future future = executor.submit(new Callable() { @Override public Integer call() throws Exception { return command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), terminal ); } }); long deadline = System.currentTimeMillis() + 300L; while (System.currentTimeMillis() < deadline && terminal.getReadLineCalls() < 2) { Thread.sleep(20L); } Assert.assertEquals("expected prompt activation to stay deferred until output actually begins", 1, terminal.getReadLineCalls()); Assert.assertEquals(0, future.get(3L, TimeUnit.SECONDS).intValue()); } finally { executor.shutdownNow(); } } @Test public void test_tui_factory_allows_custom_renderer_and_runtime() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-custom-tui"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); RecordingTuiFactory tuiFactory = new RecordingTuiFactory(); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), tuiFactory, Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString(), "--prompt", "say hello"), new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err) ); String output = new String(out.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("CUSTOM-TUI")); Assert.assertTrue(output.contains("fake-model")); Assert.assertTrue(tuiFactory.rendered); } @Test public void test_tui_mode_keeps_raw_palette_when_alternate_screen_is_disabled() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-raw-tui"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); RecordingInteractiveTerminal terminal = new RecordingInteractiveTerminal(out, err); RawInteractiveTuiFactory tuiFactory = new RawInteractiveTuiFactory(Arrays.asList( TuiKeyStroke.of(TuiKeyType.CTRL_P), TuiKeyStroke.character("e"), TuiKeyStroke.character("x"), TuiKeyStroke.character("i"), TuiKeyStroke.character("t"), TuiKeyStroke.of(TuiKeyType.ENTER) )); CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), tuiFactory, Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), terminal ); String output = out.toString(StandardCharsets.UTF_8.name()); Assert.assertEquals(0, exitCode); Assert.assertEquals(0, terminal.getReadLineCalls()); Assert.assertTrue(output.contains("RAW-TUI")); } @Test public void test_raw_tui_escape_interrupts_active_turn() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-raw-tui-interrupt"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); final Deque keys = new ArrayDeque(Arrays.asList( TuiKeyStroke.character("s"), TuiKeyStroke.character("l"), TuiKeyStroke.character("o"), TuiKeyStroke.character("w"), TuiKeyStroke.character(" "), TuiKeyStroke.character("h"), TuiKeyStroke.character("e"), TuiKeyStroke.character("l"), TuiKeyStroke.character("l"), TuiKeyStroke.character("o"), TuiKeyStroke.of(TuiKeyType.ENTER), TuiKeyStroke.of(TuiKeyType.ESCAPE) )); RecordingInteractiveTerminal terminal = new RecordingInteractiveTerminal(out, err) { @Override public boolean isInputClosed() { return keys.isEmpty(); } }; CodingCliTuiFactory tuiFactory = new CodingCliTuiFactory() { @Override public CodingCliTuiSupport create(CodeCommandOptions options, TerminalIO terminal, TuiConfigManager configManager) { TuiConfig config = new TuiConfig(); config.setUseAlternateScreen(false); TuiTheme theme = new TuiTheme(); TuiRenderer renderer = new TuiRenderer() { @Override public int getMaxEvents() { return 10; } @Override public String getThemeName() { return "raw-interrupt"; } @Override public void updateTheme(TuiConfig config, TuiTheme theme) { } @Override public String render(TuiScreenModel screenModel) { return "RAW phase=" + (screenModel == null || screenModel.getAssistantViewModel() == null ? "" : screenModel.getAssistantViewModel().getPhase()) + " detail=" + (screenModel == null || screenModel.getAssistantViewModel() == null ? "" : String.valueOf(screenModel.getAssistantViewModel().getPhaseDetail())); } }; TuiRuntime runtime = new TuiRuntime() { @Override public boolean supportsRawInput() { return true; } @Override public void enter() { } @Override public void exit() { } @Override public TuiKeyStroke readKeyStroke(long timeoutMs) { return keys.isEmpty() ? null : keys.removeFirst(); } @Override public void render(TuiScreenModel screenModel) { terminal.println(renderer.render(screenModel)); } }; return new CodingCliTuiSupport(config, theme, renderer, runtime); } }; CodeCommand command = new CodeCommand( new FakeCodingCliAgentFactory(), tuiFactory, Collections.emptyMap(), new Properties(), workspace ); int exitCode = command.run( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), terminal ); String output = out.toString(StandardCharsets.UTF_8.name()); Assert.assertEquals(0, exitCode); Assert.assertTrue(output.contains("Conversation interrupted by user.")); Assert.assertFalse(output.contains("Slow hello done.")); } @Test public void test_main_buffer_jline_user_interrupt_suppresses_final_output() throws Exception { String previous = System.getProperty("ai4j.tui.main-buffer"); System.setProperty("ai4j.tui.main-buffer", "true"); Path workspace = Files.createTempDirectory("ai4j-cli-main-buffer-interrupt"); ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); ScriptedLineReaderHandler handler = new ScriptedLineReaderHandler(Arrays.asList("slow hello", "/exit")); LineReader lineReader = (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); JlineShellContext shellContext = newJlineShellContext(terminal, lineReader); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(shellContext, null); Properties properties = new Properties(); CodeCommandOptions options = new CodeCommandOptionsParser().parse( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), Collections.emptyMap(), properties, workspace ); CodingSessionManager sessionManager = new DefaultCodingSessionManager( new InMemoryCodingSessionStore(workspace.resolve(".ai4j").resolve("sessions")), new InMemorySessionEventStore() ); TuiInteractionState interactionState = new TuiInteractionState(); FakeCodingCliAgentFactory agentFactory = new FakeCodingCliAgentFactory(); CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare(options, terminalIO, interactionState); CodingCliSessionRunner runner = new CodingCliSessionRunner( prepared.getAgent(), prepared.getProtocol(), options, terminalIO, sessionManager, interactionState, new RecordingTuiFactory(), null, agentFactory, Collections.emptyMap(), properties ); ExecutorService executor = Executors.newSingleThreadExecutor(); try { Future future = executor.submit(new Callable() { @Override public Integer call() throws Exception { return runner.run(); } }); String turnId = null; for (int attempt = 0; attempt < 40; attempt++) { Thread.sleep(50L); turnId = (String) readPrivateField(runner, "activeMainBufferTurnId"); if (turnId != null) { break; } } Assert.assertNotNull(turnId); invokePrivateMethod(runner, "interruptActiveMainBufferTurn", new Class[]{String.class}, turnId); int exitCode = future.get(5, TimeUnit.SECONDS); String rendered = output.toString(StandardCharsets.UTF_8.name()); Assert.assertEquals(0, exitCode); Assert.assertTrue(rendered.contains("Conversation interrupted by user.")); Assert.assertFalse(rendered.contains("Slow hello done.")); Assert.assertEquals(2, handler.getReadLineCalls()); } finally { executor.shutdownNow(); terminalIO.close(); shellContext.close(); restoreProperty("ai4j.tui.main-buffer", previous); } } @Test public void test_main_buffer_jline_user_interrupt_cancels_active_chat_stream() throws Exception { String previous = System.getProperty("ai4j.tui.main-buffer"); System.setProperty("ai4j.tui.main-buffer", "true"); Path workspace = Files.createTempDirectory("ai4j-cli-main-buffer-chat-cancel"); ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); ScriptedLineReaderHandler handler = new ScriptedLineReaderHandler(Arrays.asList("slow chat stream", "/exit")); LineReader lineReader = (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); JlineShellContext shellContext = newJlineShellContext(terminal, lineReader); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(shellContext, null); Properties properties = new Properties(); CodeCommandOptions options = new CodeCommandOptionsParser().parse( Arrays.asList("--ui", "tui", "--model", "fake-model", "--workspace", workspace.toString()), Collections.emptyMap(), properties, workspace ); CodingSessionManager sessionManager = new DefaultCodingSessionManager( new InMemoryCodingSessionStore(workspace.resolve(".ai4j").resolve("sessions")), new InMemorySessionEventStore() ); TuiInteractionState interactionState = new TuiInteractionState(); CountDownLatch cancelled = new CountDownLatch(1); CodingCliAgentFactory agentFactory = new CustomModelCodingCliAgentFactory( new ChatModelClient(new BlockingChatService(cancelled)) ); CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare(options, terminalIO, interactionState); CodingCliSessionRunner runner = new CodingCliSessionRunner( prepared.getAgent(), prepared.getProtocol(), options, terminalIO, sessionManager, interactionState, new RecordingTuiFactory(), null, agentFactory, Collections.emptyMap(), properties ); ExecutorService executor = Executors.newSingleThreadExecutor(); try { Future future = executor.submit(new Callable() { @Override public Integer call() throws Exception { return runner.run(); } }); String turnId = null; for (int attempt = 0; attempt < 40; attempt++) { Thread.sleep(50L); turnId = (String) readPrivateField(runner, "activeMainBufferTurnId"); if (turnId != null) { break; } } Assert.assertNotNull(turnId); invokePrivateMethod(runner, "interruptActiveMainBufferTurn", new Class[]{String.class}, turnId); int exitCode = future.get(5, TimeUnit.SECONDS); String rendered = output.toString(StandardCharsets.UTF_8.name()); Assert.assertEquals(0, exitCode); Assert.assertTrue(cancelled.await(1, TimeUnit.SECONDS)); Assert.assertTrue(rendered.contains("Conversation interrupted by user.")); Assert.assertFalse(rendered.contains("slow chat stream completed")); Assert.assertEquals(2, handler.getReadLineCalls()); } finally { executor.shutdownNow(); terminalIO.close(); shellContext.close(); restoreProperty("ai4j.tui.main-buffer", previous); } } private void seedStoredSession(Path workspace, String sessionId, String processId, String previewLogs) throws Exception { Path sessionsDir = Files.createDirectories(workspace.resolve(".ai4j").resolve("sessions")); StoredCodingSession storedSession = StoredCodingSession.builder() .sessionId(sessionId) .rootSessionId(sessionId) .provider("zhipu") .protocol("chat") .model("fake-model") .workspace(workspace.toString()) .summary("seeded session") .memoryItemCount(1) .processCount(1) .activeProcessCount(0) .restoredProcessCount(1) .createdAtEpochMs(System.currentTimeMillis()) .updatedAtEpochMs(System.currentTimeMillis()) .state(CodingSessionState.builder() .sessionId(sessionId) .workspaceRoot(workspace.toString()) .processCount(1) .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder() .processId(processId) .command("npm run dev") .workingDirectory(workspace.toString()) .status(BashProcessStatus.STOPPED) .startedAt(System.currentTimeMillis()) .endedAt(System.currentTimeMillis()) .lastLogOffset(previewLogs.length()) .lastLogPreview(previewLogs) .restored(false) .controlAvailable(true) .build())) .build()) .build(); Files.write( sessionsDir.resolve(sessionId + ".json"), JSON.toJSONString(storedSession).getBytes(StandardCharsets.UTF_8) ); } private static final class FakeCodingCliAgentFactory implements CodingCliAgentFactory { @Override public PreparedCodingAgent prepare(CodeCommandOptions options) { return prepare(options, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) { return prepare(options, terminal, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(new FakeModelClient()) .model(options.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(options.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState)) .build()) .build(), options.getProtocol() == null ? CliProtocol.CHAT : options.getProtocol() ); } } private static final class CustomModelCodingCliAgentFactory implements CodingCliAgentFactory { private final AgentModelClient modelClient; private CustomModelCodingCliAgentFactory(AgentModelClient modelClient) { this.modelClient = modelClient; } @Override public PreparedCodingAgent prepare(CodeCommandOptions options) { return prepare(options, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) { return prepare(options, terminal, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(modelClient) .model(options.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(options.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState)) .build()) .build(), options.getProtocol() == null ? CliProtocol.CHAT : options.getProtocol() ); } } private static final class ConfiguredRuntimeCodingCliAgentFactory implements CodingCliAgentFactory { @Override public PreparedCodingAgent prepare(CodeCommandOptions options) throws Exception { return prepare(options, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) throws Exception { return prepare(options, terminal, null); } @Override public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal, TuiInteractionState interactionState) throws Exception { CliMcpRuntimeManager runtimeManager = CliMcpRuntimeManager.initialize( java.nio.file.Paths.get(options.getWorkspace()), Collections.emptySet() ); return new PreparedCodingAgent( CodingAgents.builder() .modelClient(new FakeModelClient()) .model(options.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(options.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState)) .build()) .build(), options.getProtocol() == null ? CliProtocol.CHAT : options.getProtocol(), runtimeManager ); } } private static final class BlockingChatService implements IChatService { private final CountDownLatch cancelled; private BlockingChatService(CountDownLatch cancelled) { this.cancelled = cancelled; } @Override public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) { throw new UnsupportedOperationException("not used"); } @Override public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { eventSourceListener.onOpen(new EventSource() { @Override public Request request() { return new Request.Builder().url("http://localhost/test").build(); } @Override public void cancel() { cancelled.countDown(); } }, new okhttp3.Response.Builder() .request(new Request.Builder().url("http://localhost/test").build()) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .build()); if (!eventSourceListener.getCountDownLatch().await(5, TimeUnit.SECONDS)) { throw new AssertionError("stream was not released"); } } @Override public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception { chatCompletionStream(null, null, chatCompletion, eventSourceListener); } } private static final class RecordingTuiFactory implements CodingCliTuiFactory { private boolean rendered; @Override public CodingCliTuiSupport create(CodeCommandOptions options, TerminalIO terminal, TuiConfigManager configManager) { TuiConfig config = new TuiConfig(); config.setUseAlternateScreen(false); TuiTheme theme = new TuiTheme(); TuiRenderer renderer = new TuiRenderer() { @Override public int getMaxEvents() { return 10; } @Override public String getThemeName() { return "recording"; } @Override public void updateTheme(TuiConfig config, TuiTheme theme) { } @Override public String render(TuiScreenModel screenModel) { rendered = true; return "CUSTOM-TUI model=" + (screenModel == null || screenModel.getRenderContext() == null ? "" : screenModel.getRenderContext().getModel()); } }; TuiRuntime runtime = new TuiRuntime() { @Override public boolean supportsRawInput() { return false; } @Override public void enter() { } @Override public void exit() { } @Override public io.github.lnyocly.ai4j.tui.TuiKeyStroke readKeyStroke(long timeoutMs) { return null; } @Override public void render(TuiScreenModel screenModel) { terminal.println(renderer.render(screenModel)); } }; return new CodingCliTuiSupport(config, theme, renderer, runtime); } } private static final class AppendOnlyPromptTuiFactory implements CodingCliTuiFactory { @Override public CodingCliTuiSupport create(CodeCommandOptions options, TerminalIO terminal, TuiConfigManager configManager) { TuiConfig config = new TuiConfig(); config.setUseAlternateScreen(false); TuiTheme theme = new TuiTheme(); TuiRenderer renderer = new TuiSessionView(config, theme, terminal != null && terminal.supportsAnsi()); TuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); return new CodingCliTuiSupport(config, theme, renderer, runtime); } } private static final class RawInteractiveTuiFactory implements CodingCliTuiFactory { private final Deque keys; private RawInteractiveTuiFactory(List keys) { this.keys = new ArrayDeque(keys == null ? Collections.emptyList() : keys); } @Override public CodingCliTuiSupport create(CodeCommandOptions options, TerminalIO terminal, TuiConfigManager configManager) { TuiConfig config = new TuiConfig(); config.setUseAlternateScreen(false); TuiTheme theme = new TuiTheme(); TuiRenderer renderer = new TuiRenderer() { @Override public int getMaxEvents() { return 10; } @Override public String getThemeName() { return "raw-interactive"; } @Override public void updateTheme(TuiConfig config, TuiTheme theme) { } @Override public String render(TuiScreenModel screenModel) { return "RAW-TUI"; } }; TuiRuntime runtime = new TuiRuntime() { @Override public boolean supportsRawInput() { return true; } @Override public void enter() { } @Override public void exit() { } @Override public TuiKeyStroke readKeyStroke(long timeoutMs) { return keys.isEmpty() ? null : keys.removeFirst(); } @Override public void render(TuiScreenModel screenModel) { terminal.println(renderer.render(screenModel)); } }; return new CodingCliTuiSupport(config, theme, renderer, runtime); } } private static class RecordingInteractiveTerminal implements TerminalIO { private final ByteArrayOutputStream out; private final ByteArrayOutputStream err; private int readLineCalls; private RecordingInteractiveTerminal(ByteArrayOutputStream out, ByteArrayOutputStream err) { this.out = out; this.err = err; } @Override public String readLine(String prompt) throws IOException { readLineCalls++; return "/exit"; } @Override public void print(String message) { write(out, message); } @Override public void println(String message) { write(out, (message == null ? "" : message) + System.lineSeparator()); } @Override public void errorln(String message) { write(err, (message == null ? "" : message) + System.lineSeparator()); } @Override public boolean supportsAnsi() { return true; } @Override public boolean supportsRawInput() { return true; } protected int getReadLineCalls() { return readLineCalls; } private void write(ByteArrayOutputStream stream, String text) { byte[] bytes = (text == null ? "" : text).getBytes(StandardCharsets.UTF_8); stream.write(bytes, 0, bytes.length); } } private static final class KeyedRecordingInteractiveTerminal extends RecordingInteractiveTerminal { private final Deque keys; private KeyedRecordingInteractiveTerminal(ByteArrayOutputStream out, ByteArrayOutputStream err, List keys) { super(out, err); this.keys = new ArrayDeque(keys == null ? Collections.emptyList() : keys); } @Override public TuiKeyStroke readKeyStroke(long timeoutMs) { return keys.isEmpty() ? null : keys.removeFirst(); } @Override public boolean isInputClosed() { return keys.isEmpty(); } } private static final class ScriptedInteractiveTerminal extends RecordingInteractiveTerminal { private final Deque lines; private ScriptedInteractiveTerminal(ByteArrayOutputStream out, ByteArrayOutputStream err, List lines) { super(out, err); this.lines = new ArrayDeque(lines == null ? Collections.emptyList() : lines); } @Override public synchronized String readLine(String prompt) throws IOException { super.readLine(prompt); return lines.isEmpty() ? null : lines.removeFirst(); } } private JlineShellContext newJlineShellContext(Terminal terminal, LineReader lineReader) throws Exception { Constructor constructor = JlineShellContext.class .getDeclaredConstructor(Terminal.class, LineReader.class, org.jline.utils.Status.class); constructor.setAccessible(true); return constructor.newInstance(terminal, lineReader, null); } private void restoreProperty(String key, String value) { if (value == null) { System.clearProperty(key); } else { System.setProperty(key, value); } } private Object readPrivateField(Object target, String fieldName) throws Exception { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); return field.get(target); } private Object invokePrivateMethod(Object target, String methodName, Class[] parameterTypes, Object... args) throws Exception { Method method = target.getClass().getDeclaredMethod(methodName, parameterTypes); method.setAccessible(true); return method.invoke(target, args); } private static final class ScriptedLineReaderHandler implements InvocationHandler { private final Deque scriptedLines; private int readLineCalls; private ScriptedLineReaderHandler(List scriptedLines) { this.scriptedLines = new ArrayDeque(scriptedLines == null ? Collections.emptyList() : scriptedLines); } @Override public Object invoke(Object proxy, Method method, Object[] args) { String name = method.getName(); if ("readLine".equals(name)) { readLineCalls++; return scriptedLines.isEmpty() ? null : scriptedLines.removeFirst(); } if ("isReading".equals(name)) { return Boolean.FALSE; } if ("callWidget".equals(name)) { return Boolean.TRUE; } if ("hashCode".equals(name)) { return System.identityHashCode(proxy); } if ("equals".equals(name)) { return proxy == (args == null || args.length == 0 ? null : args[0]); } if ("toString".equals(name)) { return "ScriptedLineReader"; } return defaultValue(method.getReturnType()); } private int getReadLineCalls() { return readLineCalls; } private Object defaultValue(Class returnType) { if (returnType == null || Void.TYPE.equals(returnType)) { return null; } if (Boolean.TYPE.equals(returnType)) { return Boolean.FALSE; } if (Character.TYPE.equals(returnType)) { return Character.valueOf('\0'); } if (Byte.TYPE.equals(returnType)) { return Byte.valueOf((byte) 0); } if (Short.TYPE.equals(returnType)) { return Short.valueOf((short) 0); } if (Integer.TYPE.equals(returnType)) { return Integer.valueOf(0); } if (Long.TYPE.equals(returnType)) { return Long.valueOf(0L); } if (Float.TYPE.equals(returnType)) { return Float.valueOf(0F); } if (Double.TYPE.equals(returnType)) { return Double.valueOf(0D); } return null; } } private static final class FakeModelClient implements AgentModelClient { @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { return AgentModelResult.builder() .outputText("## Goal\nContinue the CLI session.\n" + "## Constraints & Preferences\n- Preserve workspace details.\n" + "## Progress\n### Done\n- [x] Session was compacted.\n" + "### In Progress\n- [ ] Continue from the latest context.\n" + "### Blocked\n- (none)\n" + "## Key Decisions\n- **Compaction**: Older messages were summarized.\n" + "## Next Steps\n1. Continue the requested coding work.\n" + "## Critical Context\n- Keep using the saved session state.") .build(); } String toolOutput = findLastToolOutput(prompt); if (toolOutput != null) { if (toolOutput.startsWith("TOOL_ERROR:")) { JSONObject error = parseObject(toolOutput.substring("TOOL_ERROR:".length()).trim()); String message = error == null ? toolOutput : firstNonBlank(error.getString("error"), toolOutput); return AgentModelResult.builder().outputText("Tool error: " + message).build(); } JSONObject json = parseObject(toolOutput); String content = json == null ? "" : firstNonBlank(json.getString("content"), json.getString("stdout")); return AgentModelResult.builder().outputText("Read result: " + content).build(); } String userInput = findLastUserText(prompt); if (userInput != null && userInput.toLowerCase().contains("what do you remember")) { return AgentModelResult.builder().outputText("history: " + findAllUserText(prompt)).build(); } if (userInput != null && userInput.toLowerCase().contains("reason first")) { return AgentModelResult.builder() .reasoningText("Need to inspect the request first.") .outputText("Done reasoning.") .build(); } if (userInput != null && userInput.toLowerCase().contains("sparse reasoning")) { return AgentModelResult.builder() .reasoningText("First line.\n\n\n\nSecond line.") .outputText("Done sparse reasoning.") .build(); } if (userInput != null && userInput.toLowerCase().contains("slow hello")) { return AgentModelResult.builder().outputText("Slow hello done.").build(); } if (userInput != null && userInput.toLowerCase().contains("chunked hello")) { return AgentModelResult.builder().outputText("Hello world from stream.").build(); } if (userInput != null && userInput.toLowerCase().contains("show code block")) { return AgentModelResult.builder() .outputText("Here is a code sample:\n```java\nSystem.out.println(\"hi\");\n```\nDone.") .build(); } if (userInput != null && userInput.toLowerCase().contains("show split fence")) { return AgentModelResult.builder() .outputText("Here is python:\n```python\nprint(\"Hello, World!\")\n```\nDone.") .build(); } if (userInput != null && userInput.toLowerCase().contains("rewrite final markdown")) { return AgentModelResult.builder() .outputText("已经为你创建了 `hello.py` 文件并成功运行!\n\n```python\nprint(\"Hello, World!\")\n```\n\n运行结果:\nHello, World!") .build(); } if (userInput != null && userInput.toLowerCase().contains("final adds code block")) { return AgentModelResult.builder() .outputText("已经为你创建了一个Python的Hello World程序!文件名为 hello_world.py。\n\n你可以通过以下命令运行它:\n\n```bash\npython hello_world.py\n```\n\n这个程序会输出:`Hello, World!`") .build(); } if (userInput != null && userInput.toLowerCase().contains("record request mode")) { return AgentModelResult.builder().outputText("mode=create").build(); } if (userInput != null && userInput.toLowerCase().contains("run bash")) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("call-bash") .name("bash") .arguments("{\"action\":\"exec\",\"command\":\"type sample.txt\"}") .build())) .build(); } if (userInput != null && userInput.toLowerCase().contains("run invalid patch")) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("call-invalid-patch") .name("apply_patch") .arguments("{\"patch\":\"*** Begin Patch\\n*** Unknown: calculator.py\\n*** End Patch\"}") .build())) .build(); } if (userInput != null && userInput.toLowerCase().contains("run recoverable patch")) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("call-recoverable-patch") .name("apply_patch") .arguments("{\"patch\":\"*** Begin Patch\\n*** Update File:/sample.txt\\n@@\\n-value=1\\n+value=2\\n*** End Patch\"}") .build())) .build(); } if (userInput != null && userInput.toLowerCase().contains("run unified diff patch")) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("call-unified-diff-patch") .name("apply_patch") .arguments("{\"patch\":\"*** Begin Patch\\n*** Update File:/sample.txt\\n--- a/sample.txt\\n+++ b/sample.txt\\n@@\\n-value=1\\n+value=2\\n*** End Patch\"}") .build())) .build(); } if (userInput != null && userInput.toLowerCase().contains("run invalid bash")) { return AgentModelResult.builder() .reasoningText("Need to inspect the shell call.") .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("call-invalid-bash") .name("bash") .arguments("{\"action\":\"exec\"}") .build())) .build(); } if (userInput != null && userInput.toLowerCase().contains("sample")) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("call-read-file") .name("read_file") .arguments("{\"path\":\"sample.txt\"}") .build())) .build(); } return AgentModelResult.builder().outputText("Echo: " + userInput).build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { String userInput = findLastUserText(prompt); if (userInput != null && userInput.toLowerCase().contains("slow hello")) { AgentModelResult result = AgentModelResult.builder().outputText("Slow hello done.").build(); if (listener != null) { try { Thread.sleep(450L); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); return result; } if (Thread.currentThread().isInterrupted()) { return result; } listener.onDeltaText("Slow hello done."); listener.onComplete(result); } return result; } if (userInput != null && userInput.toLowerCase().contains("chunked hello")) { AgentModelResult result = AgentModelResult.builder().outputText("Hello world from stream.").build(); if (listener != null) { listener.onDeltaText("Hello "); listener.onDeltaText("world "); listener.onDeltaText("from stream."); listener.onComplete(result); } return result; } if (userInput != null && userInput.toLowerCase().contains("record request mode")) { AgentModelResult result = AgentModelResult.builder().outputText("mode=createStream").build(); if (listener != null) { listener.onDeltaText("mode=createStream"); listener.onComplete(result); } return result; } if (userInput != null && userInput.toLowerCase().contains("show code block")) { String codeBlock = "Here is a code sample:\n```java\nSystem.out.println(\"hi\");\n```\nDone."; AgentModelResult result = AgentModelResult.builder().outputText(codeBlock).build(); if (listener != null) { listener.onDeltaText("Here is a code sample:\n"); listener.onDeltaText("```"); listener.onDeltaText("java\nSystem.out.println(\"hi\");\n"); listener.onDeltaText("```\nDone."); listener.onComplete(result); } return result; } if (userInput != null && userInput.toLowerCase().contains("show split fence")) { String codeBlock = "Here is python:\n```python\nprint(\"Hello, World!\")\n```\nDone."; AgentModelResult result = AgentModelResult.builder().outputText(codeBlock).build(); if (listener != null) { listener.onDeltaText("Here is python:\n``"); listener.onDeltaText("`python\nprint(\"Hello, World!\")\n"); listener.onDeltaText("```\nDone."); listener.onComplete(result); } return result; } if (userInput != null && userInput.toLowerCase().contains("rewrite final markdown")) { String rewritten = "已经为你创建了 `hello.py` 文件并成功运行!\n\n```python\nprint(\"Hello, World!\")\n```\n\n运行结果:\nHello, World!"; AgentModelResult result = AgentModelResult.builder().outputText(rewritten).build(); if (listener != null) { listener.onDeltaText("已经为你创建了 hello.py 文件并成功运行!\n\n"); listener.onDeltaText("```python\nprint(\"Hello, World!\")\n```\n"); listener.onDeltaText("\n运行结果:\nHello, World!"); listener.onComplete(result); } return result; } if (userInput != null && userInput.toLowerCase().contains("final adds code block")) { String finalText = "已经为你创建了一个Python的Hello World程序!文件名为 hello_world.py。\n\n你可以通过以下命令运行它:\n\n```bash\npython hello_world.py\n```\n\n这个程序会输出:`Hello, World!`"; AgentModelResult result = AgentModelResult.builder().outputText(finalText).build(); if (listener != null) { listener.onDeltaText("已经为你创建了一个Python的Hello World程序!文件名为 hello_world.py。\n\n"); listener.onDeltaText("你可以通过以下命令运行它:\n\n"); listener.onDeltaText("\n\n这个程序会输出:Hello, World!"); listener.onComplete(result); } return result; } if (userInput != null && userInput.toLowerCase().contains("stream auth fail")) { AgentModelResult result = AgentModelResult.builder().outputText("").build(); if (listener != null) { listener.onError(new RuntimeException("Invalid API key")); listener.onComplete(result); } return result; } AgentModelResult result = create(prompt); if (listener != null) { if (result.getReasoningText() != null && !result.getReasoningText().isEmpty()) { listener.onReasoningDelta(result.getReasoningText()); } if (result.getOutputText() != null && !result.getOutputText().isEmpty()) { listener.onDeltaText(result.getOutputText()); } if (result.getToolCalls() != null) { for (AgentToolCall call : result.getToolCalls()) { listener.onToolCall(call); } } listener.onComplete(result); } return result; } private String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } List parts = (List) content; for (Object part : parts) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if ("input_text".equals(partMap.get("type"))) { Object text = partMap.get("text"); return text == null ? "" : String.valueOf(text); } } } return ""; } private String findAllUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } StringBuilder builder = new StringBuilder(); List items = prompt.getItems(); for (Object item : items) { if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } List parts = (List) content; for (Object part : parts) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if (!"input_text".equals(partMap.get("type"))) { continue; } Object text = partMap.get("text"); if (text == null) { continue; } String value = String.valueOf(text); if (builder.length() > 0) { builder.append(" | "); } builder.append(value); } } return builder.toString(); } private String findLastToolOutput(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return null; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if ("function_call_output".equals(map.get("type"))) { Object output = map.get("output"); return output == null ? null : String.valueOf(output); } } return null; } private JSONObject parseObject(String raw) { if (raw == null || raw.trim().isEmpty()) { return null; } try { return JSON.parseObject(raw); } catch (Exception ignored) { return null; } } private String firstNonBlank(String... values) { if (values == null) { return ""; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value; } } return ""; } } private static void seedPersistedTeamState(Path workspace, String teamId) throws Exception { Path teamRoot = workspace.resolve(".ai4j").resolve("teams"); long now = System.currentTimeMillis(); AgentTeamTask backendTask = AgentTeamTask.builder() .id("backend") .memberId("backend") .task("Define travel destination API") .build(); AgentTeamTaskState backendState = AgentTeamTaskState.builder() .taskId("backend") .task(backendTask) .status(AgentTeamTaskStatus.IN_PROGRESS) .claimedBy("backend") .phase("running") .detail("Drafting OpenAPI paths and payloads.") .percent(Integer.valueOf(40)) .heartbeatCount(2) .updatedAtEpochMs(now) .build(); AgentTeamTask qaTask = AgentTeamTask.builder() .id("qa") .memberId("qa") .task("Prepare verification checklist") .build(); AgentTeamTaskState qaState = AgentTeamTaskState.builder() .taskId("qa") .task(qaTask) .status(AgentTeamTaskStatus.PENDING) .phase("planned") .percent(Integer.valueOf(0)) .updatedAtEpochMs(now - 1000L) .build(); AgentTeamMessage message = AgentTeamMessage.builder() .id("msg-1") .fromMemberId("architect") .toMemberId("backend") .type("contract.note") .taskId("backend") .content("Define the backend contract first.") .createdAt(now) .build(); AgentTeamState state = AgentTeamState.builder() .teamId(teamId) .objective("Deliver a travel planner demo.") .members(Arrays.asList( AgentTeamMemberSnapshot.builder().id("architect").name("Architect").description("System design").build(), AgentTeamMemberSnapshot.builder().id("backend").name("Backend").description("API implementation").build(), AgentTeamMemberSnapshot.builder().id("qa").name("QA").description("Verification").build() )) .taskStates(Arrays.asList(backendState, qaState)) .messages(Arrays.asList(message)) .lastOutput("Architecture and backend work are in progress.") .lastRounds(3) .lastRunStartedAt(now - 5000L) .lastRunCompletedAt(0L) .updatedAt(now) .runActive(true) .build(); new FileAgentTeamStateStore(teamRoot.resolve("state")).save(state); new FileAgentTeamMessageBus(teamRoot.resolve("mailbox").resolve(teamId + ".jsonl")) .restore(Arrays.asList(message)); } private static int countSpinnerFrames(String output) { int matches = 0; String[] frames = new String[]{"- thinking...", "\\ thinking...", "| thinking...", "/ thinking..."}; for (String frame : frames) { if (output.contains(frame)) { matches++; } } return matches; } private static String stripAnsi(String value) { return value == null ? "" : value.replaceAll("\\u001B\\[[0-?]*[ -/]*[@-~]", ""); } private static int countOccurrences(String text, String needle) { if (text == null || needle == null || needle.isEmpty()) { return 0; } int count = 0; int index = 0; while (true) { index = text.indexOf(needle, index); if (index < 0) { return count; } count++; index += needle.length(); } } private static void restoreUserHome(String value) { if (value == null) { System.clearProperty("user.home"); } else { System.setProperty("user.home", value); } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodexStyleBlockFormatterTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.cli.render.CodexStyleBlockFormatter; import io.github.lnyocly.ai4j.tui.TuiAssistantToolView; import org.junit.Test; import java.util.Arrays; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class CodexStyleBlockFormatterTest { private final CodexStyleBlockFormatter formatter = new CodexStyleBlockFormatter(80, 4); @Test public void formatAssistantTrimsBlankEdgesButKeepsInnerSpacing() { List lines = formatter.formatAssistant("\n\nhello\n\nworld\n\n"); assertEquals(Arrays.asList("hello", "", "world"), lines); } @Test public void formatAssistantRendersFencedCodeBlocks() { List lines = formatter.formatAssistant("Before\n```java\nSystem.out.println(\"hi\");\n```\nAfter"); assertEquals(Arrays.asList( "Before", " System.out.println(\"hi\");", "After" ), lines); } @Test public void formatRunningStatusRemovesBlockBulletPrefix() { TuiAssistantToolView toolView = TuiAssistantToolView.builder() .toolName("bash") .status("pending") .title("$ echo hello") .build(); assertEquals("Running echo hello", formatter.formatRunningStatus(toolView)); } @Test public void formatToolBuildsCodexLikeTranscriptBlock() { TuiAssistantToolView toolView = TuiAssistantToolView.builder() .toolName("bash") .status("done") .title("$ echo hello") .previewLines(Arrays.asList("stdout> hello", "stdout> world")) .build(); List lines = formatter.formatTool(toolView); assertEquals("• Ran echo hello", lines.get(0)); assertEquals(" └ hello", lines.get(1)); assertEquals(" world", lines.get(2)); } @Test public void formatToolKeepsQualifiedMcpToolLabel() { TuiAssistantToolView toolView = TuiAssistantToolView.builder() .toolName("fetch") .status("done") .title("fetch.fetch(url=\"https://zjuers.com/\")") .previewLines(Arrays.asList("out> Contents of https://zjuers.com/")) .build(); List lines = formatter.formatTool(toolView); assertEquals("• Ran fetch.fetch(url=\"https://zjuers.com/\")", lines.get(0)); assertEquals(" └ Contents of https://zjuers.com/", lines.get(1)); } @Test public void formatCompactBuildsCompactBlock() { CodingSessionCompactResult result = CodingSessionCompactResult.builder() .automatic(true) .beforeItemCount(12) .afterItemCount(7) .estimatedTokensBefore(3200) .estimatedTokensAfter(1900) .summary("Dropped stale history and kept the latest checkpoint.") .build(); List lines = formatter.formatCompact(result); assertEquals("• Auto-compacted session context", lines.get(0)); assertTrue(lines.get(1).contains("tokens 3200->1900")); assertTrue(lines.get(1).contains("items 12->7")); assertTrue(lines.get(2).contains("Dropped stale history")); } @Test public void formatOutputParsesStructuredSingleLineStatus() { List lines = formatter.formatOutput("process stopped: proc-7 status=stopped"); assertEquals(Arrays.asList( "• Process stopped", " └ proc-7 status=stopped" ), lines); } @Test public void formatInfoBlockKeepsInnerSeparators() { List lines = formatter.formatInfoBlock("Replay", Arrays.asList("first", "", "second")); assertEquals(Arrays.asList( "• Replay", " └ first", "", " second" ), lines); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/DefaultCodingCliAgentFactoryTest.java ================================================ package io.github.lnyocly.ai4j.cli.factory; import io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.CliUiMode; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.service.PlatformType; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; import java.util.List; public class DefaultCodingCliAgentFactoryTest { private final DefaultCodingCliAgentFactory factory = new DefaultCodingCliAgentFactory(); @Test public void test_default_protocol_prefers_chat_for_openai_compatible_base_url() { CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.OPENAI, null, "deepseek-chat", null, "https://api.deepseek.com", ".", null, null, null, null, 12, null, null, null, Boolean.FALSE, false, false ); Assert.assertEquals(CliProtocol.CHAT, factory.resolveProtocol(options)); } @Test public void test_default_protocol_prefers_responses_for_official_openai() { CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.OPENAI, null, "gpt-5-mini", null, "https://api.openai.com", ".", null, null, null, null, 12, null, null, null, Boolean.FALSE, false, false ); Assert.assertEquals(CliProtocol.RESPONSES, factory.resolveProtocol(options)); } @Test public void test_normalize_zhipu_coding_plan_base_url() { Assert.assertEquals( "https://open.bigmodel.cn/api/coding/paas/", factory.normalizeZhipuBaseUrl("https://open.bigmodel.cn/api/coding/paas/v4") ); Assert.assertEquals( "https://open.bigmodel.cn/api/coding/paas/", factory.normalizeZhipuBaseUrl("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions") ); } @Test(expected = IllegalArgumentException.class) public void test_explicit_responses_rejected_for_zhipu() { CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.ZHIPU, CliProtocol.RESPONSES, "GLM-4.5-Flash", null, null, ".", null, null, null, null, 12, null, null, null, Boolean.FALSE, false, false ); factory.resolveProtocol(options); } @Test public void test_build_workspace_context_includes_workspace_skill_directories() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-workspace-skills"); CliProviderConfigManager manager = new CliProviderConfigManager(workspace); manager.saveWorkspaceConfig(CliWorkspaceConfig.builder() .skillDirectories(Arrays.asList(" .ai4j/skills ", "C:/skills/team ", ".ai4j/skills")) .build()); CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.OPENAI, CliProtocol.RESPONSES, "gpt-5-mini", null, null, workspace.toString(), "workspace description", null, null, null, 12, null, null, null, Boolean.FALSE, false, false ); WorkspaceContext workspaceContext = factory.buildWorkspaceContext(options); Assert.assertEquals(workspace.toAbsolutePath().normalize().toString(), workspaceContext.getRoot().toString()); Assert.assertEquals("workspace description", workspaceContext.getDescription()); Assert.assertEquals(Arrays.asList(".ai4j/skills", "C:/skills/team"), workspaceContext.getSkillDirectories()); } @Test public void test_load_definition_registry_merges_built_ins_with_workspace_agents() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-workspace-agents"); Path agentsDirectory = workspace.resolve(".ai4j").resolve("agents"); Files.createDirectories(agentsDirectory); Files.write(agentsDirectory.resolve("reviewer.md"), ( "---\n" + "name: reviewer\n" + "description: Review diffs.\n" + "tools: read-only\n" + "---\n" + "Review code changes for correctness and missing tests.\n" ).getBytes(StandardCharsets.UTF_8)); CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.OPENAI, CliProtocol.RESPONSES, "gpt-5-mini", null, null, workspace.toString(), null, null, null, null, 12, null, null, null, Boolean.FALSE, false, false ); CodingAgentDefinitionRegistry registry = factory.loadDefinitionRegistry(options); Assert.assertNotNull(registry.getDefinition("general-purpose")); Assert.assertNotNull(registry.getDefinition("delegate_general_purpose")); Assert.assertNotNull(registry.getDefinition("reviewer")); Assert.assertNotNull(registry.getDefinition("delegate_reviewer")); } @Test public void test_prepare_includes_experimental_subagent_and_team_tools_by_default() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-experimental-default"); TestFactory testFactory = new TestFactory(); CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.OPENAI, CliProtocol.RESPONSES, "gpt-5-mini", null, null, workspace.toString(), null, null, null, null, 12, null, null, null, Boolean.FALSE, false, false ); CodingCliAgentFactory.PreparedCodingAgent prepared = testFactory.prepare(options); List toolNames = toolNames(prepared); Assert.assertTrue(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_SUBAGENT_TOOL_NAME)); Assert.assertTrue(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_TEAM_TOOL_NAME)); } @Test public void test_prepare_respects_workspace_experimental_toggles() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-experimental-disabled"); new CliProviderConfigManager(workspace).saveWorkspaceConfig(CliWorkspaceConfig.builder() .experimentalSubagentsEnabled(Boolean.FALSE) .experimentalAgentTeamsEnabled(Boolean.FALSE) .build()); TestFactory testFactory = new TestFactory(); CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.OPENAI, CliProtocol.RESPONSES, "gpt-5-mini", null, null, workspace.toString(), null, null, null, null, 12, null, null, null, Boolean.FALSE, false, false ); CodingCliAgentFactory.PreparedCodingAgent prepared = testFactory.prepare(options); List toolNames = toolNames(prepared); Assert.assertFalse(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_SUBAGENT_TOOL_NAME)); Assert.assertFalse(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_TEAM_TOOL_NAME)); } private List toolNames(CodingCliAgentFactory.PreparedCodingAgent prepared) { if (prepared == null || prepared.getAgent() == null) { return Collections.emptyList(); } List names = new ArrayList(); List tools = prepared.getAgent().newSession().getDelegate().getContext().getToolRegistry().getTools(); if (tools == null) { return names; } for (Object tool : tools) { if (!(tool instanceof Tool)) { continue; } Tool typedTool = (Tool) tool; if (typedTool.getFunction() != null && typedTool.getFunction().getName() != null) { names.add(typedTool.getFunction().getName()); } } return names; } private static final class TestFactory extends DefaultCodingCliAgentFactory { @Override protected AgentModelClient createModelClient(CodeCommandOptions options, CliProtocol protocol) { return new AgentModelClient() { @Override public AgentModelResult create(AgentPrompt prompt) { return emptyResult(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { return emptyResult(); } }; } private AgentModelResult emptyResult() { return AgentModelResult.builder() .outputText("") .toolCalls(new ArrayList()) .memoryItems(new ArrayList()) .build(); } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/DefaultCodingSessionManagerTest.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.CliUiMode; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.coding.CodingAgentResult; import io.github.lnyocly.ai4j.coding.CodingAgents; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.service.PlatformType; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class DefaultCodingSessionManagerTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldCreateSaveResumeAndListSessionEvents() throws Exception { Path workspace = temporaryFolder.newFolder("workspace").toPath(); Path sessionDir = temporaryFolder.newFolder("sessions").toPath(); CodingAgent agent = CodingAgents.builder() .modelClient(new EchoModelClient()) .model("fake-model") .workspaceContext(WorkspaceContext.builder().rootPath(workspace.toString()).build()) .build(); DefaultCodingSessionManager manager = new DefaultCodingSessionManager( new FileCodingSessionStore(sessionDir), new FileSessionEventStore(sessionDir.resolve("events")) ); CodeCommandOptions options = new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.ZHIPU, CliProtocol.CHAT, "fake-model", null, null, workspace.toString(), "workspace", null, null, null, 8, null, null, null, null, false, "session-alpha", null, sessionDir.toString(), null, true, false, 128000, 16384, 20000, 400, false ); ManagedCodingSession managed = manager.create(agent, CliProtocol.CHAT, options); CodingAgentResult firstResult = managed.getSession().run("remember alpha"); manager.appendEvent(managed.getSessionId(), SessionEvent.builder() .sessionId(managed.getSessionId()) .type(SessionEventType.USER_MESSAGE) .summary("remember alpha") .build()); manager.save(managed); List descriptors = manager.list(); List events = manager.listEvents(managed.getSessionId(), 10, null); assertEquals("Echo: remember alpha", firstResult.getOutputText()); assertEquals(1, descriptors.size()); assertEquals("session-alpha", descriptors.get(0).getSessionId()); assertEquals("session-alpha", descriptors.get(0).getRootSessionId()); assertEquals(null, descriptors.get(0).getParentSessionId()); assertFalse(events.isEmpty()); assertEquals(SessionEventType.SESSION_CREATED, events.get(0).getType()); assertEquals(SessionEventType.SESSION_SAVED, events.get(events.size() - 1).getType()); ManagedCodingSession resumed = manager.resume(agent, CliProtocol.CHAT, options, managed.getSessionId()); CodingAgentResult resumedResult = resumed.getSession().run("what do you remember"); List resumedEvents = manager.listEvents(resumed.getSessionId(), 20, null); assertTrue(resumedResult.getOutputText().contains("remember alpha")); assertEquals(SessionEventType.SESSION_RESUMED, resumedEvents.get(resumedEvents.size() - 1).getType()); ManagedCodingSession forked = manager.fork(agent, CliProtocol.CHAT, options, managed.getSessionId(), "session-beta"); manager.save(forked); CodingAgentResult forkedResult = forked.getSession().run("what do you remember"); List afterFork = manager.list(); StoredCodingSession storedFork = manager.load("session-beta"); assertTrue(forkedResult.getOutputText().contains("remember alpha")); assertNotNull(storedFork); assertEquals("session-alpha", storedFork.getRootSessionId()); assertEquals("session-alpha", storedFork.getParentSessionId()); assertEquals("session-beta", forked.getSessionId()); assertEquals("session-alpha", forked.getRootSessionId()); assertEquals("session-alpha", forked.getParentSessionId()); assertEquals(2, afterFork.size()); } private static final class EchoModelClient implements AgentModelClient { @Override public AgentModelResult create(AgentPrompt prompt) { String userInput = findLastUserText(prompt); if (userInput != null && userInput.toLowerCase().contains("what do you remember")) { return AgentModelResult.builder().outputText("history: " + findAllUserText(prompt)).build(); } return AgentModelResult.builder().outputText("Echo: " + userInput).build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } List parts = (List) content; for (Object part : parts) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if ("input_text".equals(partMap.get("type"))) { Object text = partMap.get("text"); return text == null ? "" : String.valueOf(text); } } } return ""; } private String findAllUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } StringBuilder builder = new StringBuilder(); for (Object item : prompt.getItems()) { if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } for (Object part : (List) content) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if (!"input_text".equals(partMap.get("type"))) { continue; } Object text = partMap.get("text"); if (text == null) { continue; } if (builder.length() > 0) { builder.append(" | "); } builder.append(String.valueOf(text)); } } return builder.toString(); } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/FileCodingSessionStoreTest.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.coding.CodingSessionState; import io.github.lnyocly.ai4j.coding.process.BashProcessStatus; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class FileCodingSessionStoreTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldSaveLoadAndListSessions() throws Exception { Path sessionDir = temporaryFolder.newFolder("session-store").toPath(); FileCodingSessionStore store = new FileCodingSessionStore(sessionDir); StoredCodingSession saved = store.save(StoredCodingSession.builder() .sessionId("session-alpha") .rootSessionId("session-alpha") .provider("zhipu") .protocol("chat") .model("GLM-4.5-Flash") .workspace("workspace-a") .summary("alpha summary") .memoryItemCount(2) .processCount(1) .activeProcessCount(0) .restoredProcessCount(1) .state(CodingSessionState.builder() .sessionId("session-alpha") .workspaceRoot("workspace-a") .memorySnapshot(MemorySnapshot.from( Arrays.asList( AgentInputItem.userMessage("hello"), AgentInputItem.message("assistant", "world") ), null )) .processCount(1) .checkpoint(CodingSessionCheckpoint.builder() .goal("Continue session-alpha") .doneItems(Collections.singletonList("Saved workspace state")) .build()) .latestCompactResult(CodingSessionCompactResult.builder() .sessionId("session-alpha") .beforeItemCount(12) .afterItemCount(3) .summary("checkpoint summary") .automatic(false) .strategy("checkpoint-delta") .deltaItemCount(5) .checkpointReused(true) .checkpoint(CodingSessionCheckpoint.builder() .goal("Continue session-alpha") .nextSteps(Collections.singletonList("Resume the next coding step")) .build()) .build()) .autoCompactFailureCount(2) .autoCompactCircuitBreakerOpen(true) .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder() .processId("proc_demo") .command("echo ready") .workingDirectory("workspace-a") .status(BashProcessStatus.STOPPED) .startedAt(System.currentTimeMillis()) .endedAt(System.currentTimeMillis()) .lastLogOffset(10L) .lastLogPreview("[stdout] ready") .restored(true) .controlAvailable(false) .build())) .build()) .build()); StoredCodingSession loaded = store.load("session-alpha"); List sessions = store.list(); assertNotNull(saved.getStorePath()); assertNotNull(loaded); assertEquals("session-alpha", loaded.getSessionId()); assertEquals("session-alpha", loaded.getRootSessionId()); assertEquals("alpha summary", loaded.getSummary()); assertEquals(1, loaded.getProcessCount()); assertEquals(1, loaded.getRestoredProcessCount()); assertNotNull(loaded.getState().getCheckpoint()); assertNotNull(loaded.getState().getLatestCompactResult()); assertEquals("checkpoint-delta", loaded.getState().getLatestCompactResult().getStrategy()); assertEquals(2, loaded.getState().getAutoCompactFailureCount()); assertTrue(loaded.getState().isAutoCompactCircuitBreakerOpen()); assertEquals(1, loaded.getState().getProcessSnapshots().size()); assertEquals(1, sessions.size()); assertEquals("session-alpha", sessions.get(0).getSessionId()); assertTrue(sessions.get(0).getUpdatedAtEpochMs() >= sessions.get(0).getCreatedAtEpochMs()); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/FileSessionEventStoreTest.java ================================================ package io.github.lnyocly.ai4j.cli.session; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class FileSessionEventStoreTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldAppendListAndDeleteSessionEvents() throws Exception { Path eventDir = temporaryFolder.newFolder("session-events").toPath(); FileSessionEventStore store = new FileSessionEventStore(eventDir); store.append(SessionEvent.builder() .sessionId("session-alpha") .type(SessionEventType.SESSION_CREATED) .timestamp(100L) .summary("created") .build()); store.append(SessionEvent.builder() .sessionId("session-alpha") .type(SessionEventType.USER_MESSAGE) .timestamp(200L) .summary("user") .build()); store.append(SessionEvent.builder() .sessionId("session-alpha") .type(SessionEventType.ASSISTANT_MESSAGE) .timestamp(300L) .summary("assistant") .build()); List recent = store.list("session-alpha", 2, null); List offset = store.list("session-alpha", 1, 1L); assertEquals(2, recent.size()); assertEquals(SessionEventType.USER_MESSAGE, recent.get(0).getType()); assertEquals(SessionEventType.ASSISTANT_MESSAGE, recent.get(1).getType()); assertEquals(1, offset.size()); assertEquals(SessionEventType.USER_MESSAGE, offset.get(0).getType()); store.delete("session-alpha"); assertTrue(store.list("session-alpha", 10, null).isEmpty()); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/JlineShellTerminalIOTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry; import io.github.lnyocly.ai4j.cli.shell.JlineShellContext; import io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; import org.jline.reader.Buffer; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; import org.jline.utils.AttributedString; import org.jline.utils.Status; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; public class JlineShellTerminalIOTest { @Test public void test_clear_assistant_block_resets_tracking_and_writes_erase_sequences() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); Status status = Status.getStatus(terminal, false); if (status != null) { status.setBorder(false); } JlineShellContext context = newContext(terminal, lineReader, status); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.beginAssistantBlockTracking(); terminalIO.printTranscriptLine("already rendered", true); Assert.assertTrue(terminalIO.assistantBlockRows() > 0); int beforeClear = output.size(); terminalIO.clearAssistantBlock(); String clearOutput = new String(output.toByteArray(), beforeClear, output.size() - beforeClear, StandardCharsets.UTF_8); Assert.assertEquals(0, terminalIO.assistantBlockRows()); Assert.assertTrue(clearOutput.contains("\u001b[2K")); Assert.assertTrue(clearOutput.contains("\u001b[1A")); } finally { terminalIO.close(); context.close(); } } @Test public void test_forget_assistant_block_resets_tracking_without_rewriting_terminal_history() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); Status status = Status.getStatus(terminal, false); if (status != null) { status.setBorder(false); } JlineShellContext context = newContext(terminal, lineReader, status); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.beginAssistantBlockTracking(); terminalIO.printTranscriptLine("already rendered", true); Assert.assertTrue(terminalIO.assistantBlockRows() > 0); int beforeForget = output.size(); terminalIO.forgetAssistantBlock(); String forgetOutput = new String(output.toByteArray(), beforeForget, output.size() - beforeForget, StandardCharsets.UTF_8); Assert.assertEquals(0, terminalIO.assistantBlockRows()); Assert.assertEquals("", forgetOutput); } finally { terminalIO.close(); context.close(); } } @Test public void test_blank_newline_uses_print_above_while_reading() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); RecordingLineReaderHandler handler = new RecordingLineReaderHandler(); LineReader lineReader = (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.println(""); Assert.assertEquals(1, handler.printAboveCalls().size()); Assert.assertEquals(" ", handler.printAboveCalls().get(0)); Assert.assertTrue(handler.widgetCalls().isEmpty()); Assert.assertEquals("", new String(output.toByteArray(), StandardCharsets.UTF_8)); } finally { terminalIO.close(); context.close(); } } @Test public void test_multiline_transcript_block_uses_print_above_while_reading() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); RecordingLineReaderHandler handler = new RecordingLineReaderHandler(); LineReader lineReader = (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.printTranscriptBlock(Arrays.asList("alpha", "", "beta")); Assert.assertEquals(1, handler.printAboveCalls().size()); Assert.assertEquals("alpha\n \nbeta", handler.printAboveCalls().get(0)); Assert.assertTrue(handler.widgetCalls().isEmpty()); Assert.assertEquals("", new String(output.toByteArray(), StandardCharsets.UTF_8)); } finally { terminalIO.close(); context.close(); } } @Test public void test_assistant_markdown_block_writes_directly_when_not_reading() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); RecordingLineReaderHandler handler = new RecordingLineReaderHandler(false); LineReader lineReader = (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.printAssistantMarkdownBlock("Hello!\n\n- item"); Assert.assertTrue(handler.printAboveCalls().isEmpty()); String rendered = new String(output.toByteArray(), StandardCharsets.UTF_8); Assert.assertTrue(rendered.contains("Hello!")); Assert.assertTrue(rendered.contains("- item")); } finally { terminalIO.close(); context.close(); } } @Test public void test_direct_output_window_bypasses_print_above_even_if_line_reader_reports_reading() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); RecordingLineReaderHandler handler = new RecordingLineReaderHandler(true); LineReader lineReader = (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.beginDirectOutputWindow(); terminalIO.printAssistantMarkdownBlock("Hello!\n\n- item"); Assert.assertTrue(handler.printAboveCalls().isEmpty()); String rendered = new String(output.toByteArray(), StandardCharsets.UTF_8); Assert.assertTrue(rendered.contains("Hello!")); Assert.assertTrue(rendered.contains("- item")); } finally { terminalIO.endDirectOutputWindow(); terminalIO.close(); context.close(); } } @Test public void test_repeated_busy_status_does_not_redundantly_redraw_same_line() throws Exception { String previous = System.getProperty("ai4j.jline.status"); System.setProperty("ai4j.jline.status", "true"); ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); CountingStatus status = new CountingStatus(terminal); status.setBorder(false); JlineShellContext context = newContext(terminal, lineReader, status); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.showResponding(); terminalIO.showResponding(); Assert.assertEquals(1, status.updateCount()); } finally { terminalIO.close(); context.close(); restoreProperty("ai4j.jline.status", previous); } } @Test public void test_status_is_disabled_by_default() throws Exception { String previous = System.getProperty("ai4j.jline.status"); System.clearProperty("ai4j.jline.status"); ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); CountingStatus status = new CountingStatus(terminal); status.setBorder(false); JlineShellContext context = newContext(terminal, lineReader, status); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.showResponding(); Assert.assertEquals(0, status.updateCount()); } finally { terminalIO.close(); context.close(); restoreProperty("ai4j.jline.status", previous); } } @Test public void test_inline_slash_palette_renders_when_status_component_is_unavailable() throws Exception { Path workspace = Files.createTempDirectory("ai4j-inline-slash"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new io.github.lnyocly.ai4j.tui.TuiConfigManager(workspace) ); ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); PaletteBuffer buffer = new PaletteBuffer(); PaletteLineReaderHandler handler = new PaletteLineReaderHandler(buffer); LineReader lineReader = (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, controller); try { controller.openSlashMenu(lineReader); Assert.assertEquals(1, handler.printAboveCalls().size()); String rendered = handler.printAboveCalls().get(0); Assert.assertTrue(rendered.contains("commands")); Assert.assertTrue(rendered.contains("/help")); Assert.assertTrue(rendered.contains("/status")); } finally { terminalIO.close(); context.close(); } } @Test public void test_turn_interrupt_watch_invokes_handler_on_escape() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[]{27}); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); CountDownLatch interrupted = new CountDownLatch(1); try { terminalIO.beginTurnInterruptWatch(new Runnable() { @Override public void run() { interrupted.countDown(); } }); Assert.assertTrue(interrupted.await(2, TimeUnit.SECONDS)); } finally { terminalIO.endTurnInterruptWatch(); terminalIO.close(); context.close(); } } @Test public void test_turn_interrupt_polling_detects_escape() throws Exception { ByteArrayInputStream input = new ByteArrayInputStream(new byte[]{27}); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.beginTurnInterruptPolling(); Assert.assertTrue(terminalIO.pollTurnInterrupt(500L)); } finally { terminalIO.endTurnInterruptPolling(); terminalIO.close(); context.close(); } } @Test public void test_connecting_status_escalates_to_stalled_when_no_model_events_arrive() throws Exception { String previousWaiting = System.getProperty("ai4j.jline.waiting-ms"); String previousStalled = System.getProperty("ai4j.jline.stalled-ms"); try { System.setProperty("ai4j.jline.waiting-ms", "80"); System.setProperty("ai4j.jline.stalled-ms", "220"); ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.showConnecting("Connecting to fake-provider/fake-model"); Thread.sleep(140L); String waiting = terminalIO.currentStatusLine(); Assert.assertTrue(waiting, waiting.contains("Connecting to fake-provider/fake-model (")); Thread.sleep(160L); String stalled = terminalIO.currentStatusLine(); Assert.assertTrue(stalled.contains("Stalled")); Assert.assertTrue(stalled.contains("No response from model stream")); Assert.assertTrue(stalled.contains("press Esc to interrupt")); } finally { terminalIO.close(); context.close(); } } finally { restoreProperty("ai4j.jline.waiting-ms", previousWaiting); restoreProperty("ai4j.jline.stalled-ms", previousStalled); } } @Test public void test_responding_status_changes_to_waiting_when_model_stream_goes_quiet() throws Exception { String previousWaiting = System.getProperty("ai4j.jline.waiting-ms"); String previousStalled = System.getProperty("ai4j.jline.stalled-ms"); try { System.setProperty("ai4j.jline.waiting-ms", "80"); System.setProperty("ai4j.jline.stalled-ms", "400"); ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]); ByteArrayOutputStream output = new ByteArrayOutputStream(); Terminal terminal = TerminalBuilder.builder() .system(false) .dumb(true) .streams(input, output) .encoding(StandardCharsets.UTF_8) .build(); LineReader lineReader = LineReaderBuilder.builder() .terminal(terminal) .appName("ai4j-cli-test") .build(); JlineShellContext context = newContext(terminal, lineReader, null); JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null); try { terminalIO.showResponding(); Thread.sleep(140L); String waiting = terminalIO.currentStatusLine(); Assert.assertTrue(waiting, waiting.contains("Waiting")); Assert.assertTrue(waiting, waiting.contains("No new model output")); } finally { terminalIO.close(); context.close(); } } finally { restoreProperty("ai4j.jline.waiting-ms", previousWaiting); restoreProperty("ai4j.jline.stalled-ms", previousStalled); } } private JlineShellContext newContext(Terminal terminal, LineReader lineReader, Status status) throws Exception { Constructor constructor = JlineShellContext.class .getDeclaredConstructor(Terminal.class, LineReader.class, Status.class); constructor.setAccessible(true); return constructor.newInstance(terminal, lineReader, status); } private void restoreProperty(String key, String value) { if (value == null) { System.clearProperty(key); } else { System.setProperty(key, value); } } private static final class RecordingLineReaderHandler implements InvocationHandler { private final List printAboveCalls = new ArrayList(); private final List widgetCalls = new ArrayList(); private final boolean reading; private RecordingLineReaderHandler() { this(true); } private RecordingLineReaderHandler(boolean reading) { this.reading = reading; } @Override public Object invoke(Object proxy, Method method, Object[] args) { String name = method.getName(); if ("isReading".equals(name)) { return reading; } if ("printAbove".equals(name)) { printAboveCalls.add(args != null && args.length > 0 && args[0] != null ? String.valueOf(args[0]) : null); return null; } if ("callWidget".equals(name)) { if (args != null && args.length > 0 && args[0] != null) { widgetCalls.add(String.valueOf(args[0])); } return Boolean.TRUE; } if ("hashCode".equals(name)) { return System.identityHashCode(proxy); } if ("equals".equals(name)) { return proxy == (args == null || args.length == 0 ? null : args[0]); } if ("toString".equals(name)) { return "RecordingLineReader"; } return defaultValue(method.getReturnType()); } private List printAboveCalls() { return printAboveCalls; } private List widgetCalls() { return widgetCalls; } private Object defaultValue(Class returnType) { if (returnType == null || Void.TYPE.equals(returnType)) { return null; } if (Boolean.TYPE.equals(returnType)) { return Boolean.FALSE; } if (Character.TYPE.equals(returnType)) { return Character.valueOf('\0'); } if (Byte.TYPE.equals(returnType)) { return Byte.valueOf((byte) 0); } if (Short.TYPE.equals(returnType)) { return Short.valueOf((short) 0); } if (Integer.TYPE.equals(returnType)) { return Integer.valueOf(0); } if (Long.TYPE.equals(returnType)) { return Long.valueOf(0L); } if (Float.TYPE.equals(returnType)) { return Float.valueOf(0F); } if (Double.TYPE.equals(returnType)) { return Double.valueOf(0D); } return null; } } private static final class CountingStatus extends Status { private int updateCount; private CountingStatus(Terminal terminal) { super(terminal); } @Override public void update(List lines) { updateCount++; } private int updateCount() { return updateCount; } } private static final class PaletteLineReaderHandler implements InvocationHandler { private final PaletteBuffer buffer; private final List printAboveCalls = new ArrayList(); private PaletteLineReaderHandler(PaletteBuffer buffer) { this.buffer = buffer; } @Override public Object invoke(Object proxy, Method method, Object[] args) { String name = method.getName(); if ("isReading".equals(name)) { return true; } if ("getBuffer".equals(name)) { return buffer.proxy(); } if ("printAbove".equals(name)) { printAboveCalls.add(args != null && args.length > 0 && args[0] != null ? String.valueOf(args[0]) : null); return null; } if ("callWidget".equals(name)) { return Boolean.TRUE; } if ("hashCode".equals(name)) { return System.identityHashCode(proxy); } if ("equals".equals(name)) { return proxy == (args == null || args.length == 0 ? null : args[0]); } if ("toString".equals(name)) { return "PaletteLineReader"; } return defaultValue(method.getReturnType()); } private List printAboveCalls() { return printAboveCalls; } private Object defaultValue(Class returnType) { if (returnType == null || Void.TYPE.equals(returnType)) { return null; } if (Boolean.TYPE.equals(returnType)) { return Boolean.FALSE; } if (Integer.TYPE.equals(returnType) || Short.TYPE.equals(returnType) || Byte.TYPE.equals(returnType)) { return 0; } if (Long.TYPE.equals(returnType)) { return 0L; } if (Float.TYPE.equals(returnType)) { return 0F; } if (Double.TYPE.equals(returnType)) { return 0D; } if (Character.TYPE.equals(returnType)) { return '\0'; } return null; } } private static final class PaletteBuffer { private final StringBuilder value = new StringBuilder(); private Buffer proxy() { InvocationHandler handler = new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) { String name = method.getName(); if ("toString".equals(name)) { return value.toString(); } if ("write".equals(name)) { if (args != null && args.length > 0 && args[0] != null) { value.append(String.valueOf(args[0])); } return null; } if ("clear".equals(name)) { value.setLength(0); return null; } if ("cursor".equals(name) || "length".equals(name)) { return value.length(); } return defaultValue(method.getReturnType()); } }; return (Buffer) Proxy.newProxyInstance( Buffer.class.getClassLoader(), new Class[]{Buffer.class}, handler ); } private Object defaultValue(Class returnType) { if (returnType == null || Void.TYPE.equals(returnType)) { return null; } if (Boolean.TYPE.equals(returnType)) { return Boolean.FALSE; } if (Integer.TYPE.equals(returnType) || Short.TYPE.equals(returnType) || Byte.TYPE.equals(returnType)) { return 0; } if (Long.TYPE.equals(returnType)) { return 0L; } if (Float.TYPE.equals(returnType)) { return 0F; } if (Double.TYPE.equals(returnType)) { return 0D; } if (Character.TYPE.equals(returnType)) { return '\0'; } return null; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/PatchSummaryFormatterTest.java ================================================ package io.github.lnyocly.ai4j.cli; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.cli.render.PatchSummaryFormatter; import org.junit.Test; import java.util.Arrays; import java.util.List; import static org.junit.Assert.assertEquals; public class PatchSummaryFormatterTest { @Test public void summarizePatchRequestSupportsLongAndShortDirectives() { String patch = "*** Begin Patch\n" + "*** Add File: notes/todo.txt\n" + "+hello\n" + "*** Update: src/App.java\n" + "@@\n" + "-old\n" + "+new\n" + "*** Delete File: old.txt\n" + "*** End Patch"; List lines = PatchSummaryFormatter.summarizePatchRequest(patch, 8); assertEquals(Arrays.asList( "Add notes/todo.txt", "Update src/App.java", "Delete old.txt" ), lines); } @Test public void summarizePatchResultPrefersStructuredFileChanges() { JSONObject output = new JSONObject(); JSONArray fileChanges = new JSONArray(); fileChanges.add(new JSONObject() .fluentPut("path", "notes/todo.txt") .fluentPut("operation", "add") .fluentPut("linesAdded", 3) .fluentPut("linesRemoved", 0)); fileChanges.add(new JSONObject() .fluentPut("path", "src/App.java") .fluentPut("operation", "update") .fluentPut("linesAdded", 8) .fluentPut("linesRemoved", 2)); output.put("fileChanges", fileChanges); List lines = PatchSummaryFormatter.summarizePatchResult(output, 8); assertEquals(Arrays.asList( "Created notes/todo.txt (+3 -0)", "Edited src/App.java (+8 -2)" ), lines); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/SlashCommandControllerTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry; import io.github.lnyocly.ai4j.tui.TuiConfigManager; import org.jline.reader.Buffer; import org.jline.reader.Candidate; import org.jline.reader.LineReader; import org.junit.Test; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class SlashCommandControllerTest { @Test public void suggestRootCommandsIncludesBuiltInsAndSpacingRules() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-root"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/", 1); assertContainsValue(candidates, "/help"); assertContainsValue(candidates, "/cmd "); assertContainsValue(candidates, "/theme "); assertContainsValue(candidates, "/stream "); assertContainsValue(candidates, "/mcp "); assertContainsValue(candidates, "/providers"); assertContainsValue(candidates, "/provider "); assertContainsValue(candidates, "/model "); assertContainsValue(candidates, "/experimental "); assertContainsValue(candidates, "/skills "); assertContainsValue(candidates, "/agents "); assertContainsValue(candidates, "/process "); assertContainsValue(candidates, "/team"); } @Test public void suggestCustomCommandNamesFromWorkspaceRegistry() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-cmd"); Path commandsDir = workspace.resolve(".ai4j").resolve("commands"); Files.createDirectories(commandsDir); Files.write(commandsDir.resolve("review.prompt"), "# Review auth flow\nCheck the auth flow.\n".getBytes(StandardCharsets.UTF_8)); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/cmd re", "/cmd re".length()); assertContainsValue(candidates, "review"); } @Test public void suggestThemesIncludesBuiltInThemeNames() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-theme"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/theme a", "/theme a".length()); assertContainsValue(candidates, "amber"); } @Test public void suggestStreamOptionsIncludesOnAndOff() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-stream"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/stream ", "/stream ".length()); assertContainsValue(candidates, "on"); assertContainsValue(candidates, "off"); } @Test public void suggestExperimentalFeaturesIncludesSubagentAndAgentTeams() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-experimental"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/experimental ", "/experimental ".length()); assertContainsValue(candidates, "subagent"); assertContainsValue(candidates, "agent-teams"); } @Test public void suggestExperimentalToggleOptionsIncludesOnAndOff() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-experimental-toggle"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/experimental subagent ", "/experimental subagent ".length()); assertContainsValue(candidates, "on"); assertContainsValue(candidates, "off"); } @Test public void suggestTeamActionsIncludesPersistedStateCommands() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-team"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/team ", "/team ".length()); assertContainsValue(candidates, "list"); assertContainsValue(candidates, "status "); assertContainsValue(candidates, "messages "); assertContainsValue(candidates, "resume "); } @Test public void suggestTeamIdsFromSupplierForStatusAndResume() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-team-id"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setTeamCandidateSupplier(() -> Arrays.asList("experimental-delivery-team", "travel-team")); List statusCandidates = controller.suggest("/team status ", "/team status ".length()); List resumeCandidates = controller.suggest("/team resume ", "/team resume ".length()); assertContainsValue(statusCandidates, "experimental-delivery-team"); assertContainsValue(statusCandidates, "travel-team"); assertContainsValue(resumeCandidates, "experimental-delivery-team"); } @Test public void suggestTeamMessageLimitsAfterTeamId() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-team-messages"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setTeamCandidateSupplier(() -> Arrays.asList("experimental-delivery-team")); List candidates = controller.suggest( "/team messages experimental-delivery-team ", "/team messages experimental-delivery-team ".length() ); assertContainsValue(candidates, "10"); assertContainsValue(candidates, "20"); assertContainsValue(candidates, "50"); } @Test public void suggestSkillNamesFromSupplier() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-skills"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setSkillCandidateSupplier(() -> Arrays.asList("repo-review", "release-checklist")); List candidates = controller.suggest("/skills ", "/skills ".length()); assertContainsValue(candidates, "repo-review"); assertContainsValue(candidates, "release-checklist"); } @Test public void suggestAgentNamesFromSupplier() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-agents"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setAgentCandidateSupplier(() -> Arrays.asList("reviewer", "planner")); List candidates = controller.suggest("/agents ", "/agents ".length()); assertContainsValue(candidates, "reviewer"); assertContainsValue(candidates, "planner"); } @Test public void suggestExactExecutableArgumentCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-stream-exact"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/stream", "/stream".length()); assertContainsValue(candidates, "/stream "); assertTrue(!containsValue(candidates, "/stream on")); assertTrue(!containsValue(candidates, "/stream off")); } @Test public void suggestExactMcpCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-mcp-exact"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/mcp", "/mcp".length()); assertContainsValue(candidates, "/mcp "); assertTrue(!containsValue(candidates, "/mcp add ")); assertTrue(!containsValue(candidates, "/mcp enable ")); } @Test public void suggestMcpActionsAfterTrailingSpaceIncludesManagementCommands() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-mcp-actions"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/mcp ", "/mcp ".length()); assertContainsValue(candidates, "list "); assertContainsValue(candidates, "add "); assertContainsValue(candidates, "enable "); assertContainsValue(candidates, "pause "); assertContainsValue(candidates, "remove "); } @Test public void suggestMcpTransportOptionsAfterTransportFlag() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-mcp-transport"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/mcp add --transport ", "/mcp add --transport ".length()); assertContainsValue(candidates, "stdio"); assertContainsValue(candidates, "sse"); assertContainsValue(candidates, "http"); } @Test public void suggestMcpServerNamesFromSupplier() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-mcp-server"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setMcpServerCandidateSupplier(() -> Arrays.asList("fetch", "time")); List candidates = controller.suggest("/mcp enable ", "/mcp enable ".length()); assertContainsValue(candidates, "fetch"); assertContainsValue(candidates, "time"); } @Test public void suggestExactProviderCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-exact"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/provider", "/provider".length()); assertContainsValue(candidates, "/provider "); assertTrue(!containsValue(candidates, "/provider use ")); } @Test public void suggestProviderActionsAfterTrailingSpaceIncludesAddAndEdit() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-actions"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/provider ", "/provider ".length()); assertContainsValue(candidates, "use "); assertContainsValue(candidates, "save "); assertContainsValue(candidates, "add "); assertContainsValue(candidates, "edit "); } @Test public void suggestProviderNamesFromProfileSupplier() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-name"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setProfileCandidateSupplier(() -> Arrays.asList("openai-main", "zhipu-main")); List candidates = controller.suggest("/provider use ", "/provider use ".length()); assertContainsValue(candidates, "openai-main"); assertContainsValue(candidates, "zhipu-main"); } @Test public void suggestProviderNamesWhenUseActionIsExactWithoutTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-use-exact"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setProfileCandidateSupplier(() -> Arrays.asList("openai-main", "zhipu-main")); List candidates = controller.suggest("/provider use", "/provider use".length()); assertContainsValue(candidates, "/provider use openai-main"); assertContainsValue(candidates, "/provider use zhipu-main"); } @Test public void suggestProviderNamesWhenEditActionIsExactWithoutTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-edit-exact"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setProfileCandidateSupplier(() -> Arrays.asList("openai-main", "zhipu-main")); List candidates = controller.suggest("/provider edit", "/provider edit".length()); assertContainsValue(candidates, "/provider edit openai-main"); assertContainsValue(candidates, "/provider edit zhipu-main"); } @Test public void suggestProviderDefaultNamesAndClearFromProfileSupplier() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-default"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setProfileCandidateSupplier(() -> Arrays.asList("openai-main", "zhipu-main")); List candidates = controller.suggest("/provider default ", "/provider default ".length()); assertContainsValue(candidates, "clear"); assertContainsValue(candidates, "openai-main"); assertContainsValue(candidates, "zhipu-main"); } @Test public void suggestProviderMutationOptionsAfterProfileName() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-mutation-options"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/provider add zhipu-main ", "/provider add zhipu-main ".length()); assertContainsValue(candidates, "--provider "); assertContainsValue(candidates, "--protocol "); assertContainsValue(candidates, "--model "); assertContainsValue(candidates, "--clear-api-key "); } @Test public void suggestProviderMutationProtocolValues() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-provider-mutation-protocol"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/provider add zhipu-main --protocol ", "/provider add zhipu-main --protocol ".length()); assertContainsValue(candidates, "chat"); assertContainsValue(candidates, "responses"); } @Test public void suggestExactModelCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-model-exact"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setModelCandidateSupplier(() -> Arrays.asList( new SlashCommandController.ModelCompletionCandidate("gpt-5-mini", "Current effective model"), new SlashCommandController.ModelCompletionCandidate("gpt-4.1", "Saved profile openai-main") )); List candidates = controller.suggest("/model", "/model".length()); assertContainsValue(candidates, "/model "); assertTrue(!containsValue(candidates, "/model gpt-5-mini")); assertTrue(!containsValue(candidates, "/model reset")); } @Test public void suggestModelCandidatesWithTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-model-space"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setModelCandidateSupplier(() -> Arrays.asList( new SlashCommandController.ModelCompletionCandidate("glm-4.7", "Current effective model"), new SlashCommandController.ModelCompletionCandidate("glm-4.7-plus", "Saved profile zhipu-main") )); List candidates = controller.suggest("/model ", "/model ".length()); assertContainsValue(candidates, "glm-4.7"); assertContainsValue(candidates, "glm-4.7-plus"); assertContainsValue(candidates, "reset"); } @Test public void suggestProcessSubcommandsWhenProcessCommandHasTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-process"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); List candidates = controller.suggest("/process ", "/process ".length()); assertContainsValue(candidates, "status "); assertContainsValue(candidates, "follow "); } @Test public void suggestProcessIdsFromActiveSessionSupplier() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-process-id"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setProcessCandidateSupplier(() -> Arrays.asList( new SlashCommandController.ProcessCompletionCandidate("proc-123", "running | live | mvn test"), new SlashCommandController.ProcessCompletionCandidate("proc-456", "exited | metadata-only | npm run build") )); List candidates = controller.suggest("/process status ", "/process status ".length()); assertContainsValue(candidates, "proc-123"); assertContainsValue(candidates, "proc-456"); } @Test public void suggestProcessLimitsAfterSelectingProcessId() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-process-limit"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setProcessCandidateSupplier(() -> Arrays.asList( new SlashCommandController.ProcessCompletionCandidate("proc-123", "running | live | mvn test") )); List candidates = controller.suggest("/process follow proc-123 ", "/process follow proc-123 ".length()); assertContainsValue(candidates, "800"); assertContainsValue(candidates, "1600"); } @Test public void suggestProcessWriteStopsCompletingAfterProcessId() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-process-write"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setProcessCandidateSupplier(() -> Arrays.asList( new SlashCommandController.ProcessCompletionCandidate("proc-123", "running | live | mvn test") )); List candidates = controller.suggest("/process write proc-123 ", "/process write proc-123 ".length()); assertTrue("Expected no completion candidates after process write text position", candidates.isEmpty()); } @Test public void resolveSlashMenuActionDoesNotInsertDuplicateSlashInsideSlashCommands() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-menu"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); assertTrue(controller.resolveSlashMenuAction("", 0) == SlashCommandController.SlashMenuAction.INSERT_AND_MENU); assertTrue(controller.resolveSlashMenuAction("/", 1) == SlashCommandController.SlashMenuAction.MENU_ONLY); assertTrue(controller.resolveSlashMenuAction("/process", "/process".length()) == SlashCommandController.SlashMenuAction.MENU_ONLY); assertTrue(controller.resolveSlashMenuAction("/process write proc-123 ", "/process write proc-123 ".length()) == SlashCommandController.SlashMenuAction.INSERT_ONLY); } @Test public void resolveAcceptLineActionIgnoresBlankInput() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-enter-action"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); assertTrue(controller.resolveAcceptLineAction("") == SlashCommandController.EnterAction.IGNORE_EMPTY); assertTrue(controller.resolveAcceptLineAction(" ") == SlashCommandController.EnterAction.IGNORE_EMPTY); assertTrue(controller.resolveAcceptLineAction("/exit") == SlashCommandController.EnterAction.ACCEPT); assertTrue(controller.resolveAcceptLineAction("hello") == SlashCommandController.EnterAction.ACCEPT); } @Test public void openSlashMenuListsChoicesWithoutCompletingFirstCommand() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-open-menu"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); RecordingBuffer buffer = new RecordingBuffer(""); List widgetCalls = new ArrayList(); controller.openSlashMenu(recordingLineReader(buffer, widgetCalls)); assertEquals("/", buffer.value()); assertTrue(!widgetCalls.contains(LineReader.LIST_CHOICES)); assertTrue(!widgetCalls.contains(LineReader.MENU_COMPLETE)); SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot(); assertTrue(snapshot.isOpen()); assertEquals("/", snapshot.getQuery()); assertEquals("/help", snapshot.getItems().get(snapshot.getSelectedIndex()).getValue()); } @Test public void openSlashMenuKeepsExistingSlashPrefixWhileRefreshingChoices() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-open-existing"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); RecordingBuffer buffer = new RecordingBuffer("/re"); List widgetCalls = new ArrayList(); controller.openSlashMenu(recordingLineReader(buffer, widgetCalls)); assertEquals("/re", buffer.value()); assertTrue(!widgetCalls.contains(LineReader.LIST_CHOICES)); assertTrue(!widgetCalls.contains(LineReader.MENU_COMPLETE)); SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot(); assertTrue(snapshot.isOpen()); assertEquals("/re", snapshot.getQuery()); } @Test public void movePaletteSelectionAdvancesWithoutTabBootstrap() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-open-move"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); RecordingBuffer buffer = new RecordingBuffer(""); controller.openSlashMenu(recordingLineReader(buffer, new ArrayList())); controller.movePaletteSelection(1); SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot(); assertTrue(snapshot.isOpen()); assertEquals("/status", snapshot.getItems().get(snapshot.getSelectedIndex()).getValue()); } @Test public void acceptSlashSelectionReplacesBufferWithHighlightedCommand() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-accept"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); RecordingBuffer buffer = new RecordingBuffer(""); List widgetCalls = new ArrayList(); LineReader lineReader = recordingLineReader(buffer, widgetCalls); controller.openSlashMenu(lineReader); controller.movePaletteSelection(1); assertTrue(controller.acceptSlashSelection(lineReader, true)); assertEquals("/status", buffer.value()); assertTrue(!controller.getPaletteSnapshot().isOpen()); } @Test public void acceptSlashSelectionKeepsCommandPrefixForArgumentCandidates() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-stream-accept"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); RecordingBuffer buffer = new RecordingBuffer("/stream "); LineReader lineReader = recordingLineReader(buffer, new ArrayList()); java.lang.reflect.Method updatePalette = SlashCommandController.class .getDeclaredMethod("updatePalette", String.class, List.class); updatePalette.setAccessible(true); updatePalette.invoke(controller, "/stream ", controller.suggest("/stream ", "/stream ".length())); controller.movePaletteSelection(1); assertTrue(controller.acceptSlashSelection(lineReader, true)); assertEquals("/stream off", buffer.value()); assertTrue(!controller.getPaletteSnapshot().isOpen()); } @Test public void acceptSlashSelectionFromPartialCommandKeepsPaletteOpenForArgumentChoices() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-slash-stream-chain"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); RecordingBuffer buffer = new RecordingBuffer("/stre"); LineReader lineReader = recordingLineReader(buffer, new ArrayList()); java.lang.reflect.Method updatePalette = SlashCommandController.class .getDeclaredMethod("updatePalette", String.class, List.class); updatePalette.setAccessible(true); updatePalette.invoke(controller, "/stre", controller.suggest("/stre", "/stre".length())); assertTrue(controller.acceptSlashSelection(lineReader, true)); assertEquals("/stream ", buffer.value()); SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot(); assertTrue(snapshot.isOpen()); assertEquals("/stream ", snapshot.getQuery()); assertTrue(containsPaletteValue(snapshot, "on")); assertTrue(containsPaletteValue(snapshot, "off")); } @Test public void acceptLineExecutesExactOptionalCommandInsteadOfApplyingCandidate() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-enter-model-root"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setModelCandidateSupplier(() -> Arrays.asList( new SlashCommandController.ModelCompletionCandidate("glm-4.7", "Current effective model") )); RecordingBuffer buffer = new RecordingBuffer("/model"); List widgetCalls = new ArrayList(); LineReader lineReader = recordingLineReader(buffer, widgetCalls); updatePalette(controller, "/model"); assertTrue(invokeAcceptLine(controller, lineReader)); assertEquals("/model", buffer.value()); assertTrue(widgetCalls.contains(LineReader.ACCEPT_LINE)); assertTrue(!controller.getPaletteSnapshot().isOpen()); } @Test public void acceptLineExecutesExactOptionalCommandWithTrailingSpace() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-enter-model-space"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); controller.setModelCandidateSupplier(() -> Arrays.asList( new SlashCommandController.ModelCompletionCandidate("glm-4.7", "Current effective model") )); RecordingBuffer buffer = new RecordingBuffer("/model "); List widgetCalls = new ArrayList(); LineReader lineReader = recordingLineReader(buffer, widgetCalls); updatePalette(controller, "/model "); assertTrue(invokeAcceptLine(controller, lineReader)); assertEquals("/model ", buffer.value()); assertTrue(widgetCalls.contains(LineReader.ACCEPT_LINE)); assertTrue(!controller.getPaletteSnapshot().isOpen()); } @Test public void acceptLineAcceptsSelectionForIncompletePrefix() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-enter-resume-prefix"); SlashCommandController controller = new SlashCommandController( new CustomCommandRegistry(workspace), new TuiConfigManager(workspace) ); RecordingBuffer buffer = new RecordingBuffer("/res"); List widgetCalls = new ArrayList(); LineReader lineReader = recordingLineReader(buffer, widgetCalls); updatePalette(controller, "/res"); assertTrue(invokeAcceptLine(controller, lineReader)); assertEquals("/resume ", buffer.value()); assertTrue(!widgetCalls.contains(LineReader.ACCEPT_LINE)); assertTrue(controller.getPaletteSnapshot().isOpen()); } private boolean invokeAcceptLine(SlashCommandController controller, LineReader lineReader) throws Exception { java.lang.reflect.Method method = SlashCommandController.class.getDeclaredMethod("acceptLine", LineReader.class); method.setAccessible(true); return Boolean.TRUE.equals(method.invoke(controller, lineReader)); } private void updatePalette(SlashCommandController controller, String line) throws Exception { java.lang.reflect.Method updatePalette = SlashCommandController.class .getDeclaredMethod("updatePalette", String.class, List.class); updatePalette.setAccessible(true); updatePalette.invoke(controller, line, controller.suggest(line, line.length())); } private void assertContainsValue(List candidates, String value) { assertTrue("Expected candidate " + value, containsValue(candidates, value)); } private boolean containsValue(List candidates, String value) { if (candidates == null) { return false; } for (Candidate candidate : candidates) { if (candidate != null && value.equals(candidate.value())) { return true; } } return false; } private boolean containsPaletteValue(SlashCommandController.PaletteSnapshot snapshot, String value) { if (snapshot == null || snapshot.getItems() == null) { return false; } for (SlashCommandController.PaletteItemSnapshot item : snapshot.getItems()) { if (item != null && value.equals(item.getValue())) { return true; } } return false; } private LineReader recordingLineReader(final RecordingBuffer buffer, final List widgetCalls) { InvocationHandler handler = new InvocationHandler() { @Override public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) { String name = method.getName(); if ("getBuffer".equals(name)) { return buffer.proxy(); } if ("callWidget".equals(name)) { if (args != null && args.length > 0 && args[0] != null) { widgetCalls.add(String.valueOf(args[0])); } return true; } if ("isSet".equals(name)) { return false; } if ("getVariables".equals(name)) { return null; } return null; } }; return (LineReader) Proxy.newProxyInstance( LineReader.class.getClassLoader(), new Class[]{LineReader.class}, handler ); } private static final class RecordingBuffer { private final StringBuilder value; private int cursor; private RecordingBuffer(String initialValue) { this.value = new StringBuilder(initialValue == null ? "" : initialValue); this.cursor = this.value.length(); } private Buffer proxy() { InvocationHandler handler = new InvocationHandler() { @Override public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) { String name = method.getName(); if ("toString".equals(name)) { return value.toString(); } if ("cursor".equals(name)) { return cursor; } if ("write".equals(name)) { String appended = args != null && args.length > 0 && args[0] != null ? String.valueOf(args[0]) : ""; value.insert(cursor, appended); cursor += appended.length(); return null; } if ("length".equals(name)) { return value.length(); } if ("clear".equals(name)) { value.setLength(0); cursor = 0; return true; } return null; } }; return (Buffer) Proxy.newProxyInstance( Buffer.class.getClassLoader(), new Class[]{Buffer.class}, handler ); } private String value() { return value.toString(); } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/TranscriptPrinterTest.java ================================================ package io.github.lnyocly.ai4j.cli; import io.github.lnyocly.ai4j.cli.render.TranscriptPrinter; import io.github.lnyocly.ai4j.tui.TerminalIO; import org.junit.Test; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static org.junit.Assert.assertEquals; public class TranscriptPrinterTest { @Test public void printsSingleBlankLineBetweenBlocks() { RecordingTerminalIO terminal = new RecordingTerminalIO(); TranscriptPrinter printer = new TranscriptPrinter(terminal); printer.printBlock(Arrays.asList("", "• Ran echo hello", " └ hello", "")); printer.printBlock(Arrays.asList("• Applied patch")); assertEquals(Arrays.asList("• Ran echo hello", " └ hello", "", "• Applied patch"), terminal.lines()); } @Test public void sectionBreakResetsSpacingWithoutDoubleBlankLines() { RecordingTerminalIO terminal = new RecordingTerminalIO(); TranscriptPrinter printer = new TranscriptPrinter(terminal); printer.printBlock(Arrays.asList("• First block")); printer.printSectionBreak(); printer.printBlock(Arrays.asList("• Second block")); assertEquals(Arrays.asList("• First block", "", "• Second block"), terminal.lines()); } @Test public void streamingBlockUsesSingleSeparatorBeforeNextBlock() { RecordingTerminalIO terminal = new RecordingTerminalIO(); TranscriptPrinter printer = new TranscriptPrinter(terminal); printer.printBlock(Arrays.asList("• First block")); printer.beginStreamingBlock(); terminal.print("chunked"); terminal.println(""); printer.printBlock(Arrays.asList("• Second block")); assertEquals(Arrays.asList("• First block", "chunked", "", "", "• Second block"), terminal.lines()); } @Test public void resetPrintedBlockDropsSeparatorAfterClearedBlock() { RecordingTerminalIO terminal = new RecordingTerminalIO(); TranscriptPrinter printer = new TranscriptPrinter(terminal); printer.beginStreamingBlock(); printer.resetPrintedBlock(); printer.printBlock(Arrays.asList("• Tool block")); assertEquals(Arrays.asList("• Tool block"), terminal.lines()); } private static final class RecordingTerminalIO implements TerminalIO { private final List lines = new ArrayList(); @Override public String readLine(String prompt) throws IOException { throw new UnsupportedOperationException(); } @Override public void print(String message) { lines.add(message == null ? "" : message); } @Override public void println(String message) { lines.add(message == null ? "" : message); } @Override public void errorln(String message) { lines.add(message == null ? "" : message); } private List lines() { return lines; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/acp/AcpCommandTest.java ================================================ package io.github.lnyocly.ai4j.cli.acp; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser; import io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory; import io.github.lnyocly.ai4j.cli.runtime.CodingTaskSessionEventBridge; import io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager; import io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.FileSessionEventStore; import io.github.lnyocly.ai4j.cli.session.StoredCodingSession; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.CodingAgents; import io.github.lnyocly.ai4j.coding.CodingSessionState; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.task.CodingTask; import io.github.lnyocly.ai4j.coding.task.CodingTaskProgress; import io.github.lnyocly.ai4j.coding.task.CodingTaskStatus; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Assert; import org.junit.Test; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStreamReader; import java.io.PipedInputStream; import java.io.PipedOutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; public class AcpCommandTest { @Test public void test_initialize_new_prompt_and_load_history() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-main"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "acp-session", "cwd", workspace.toString() ))) + line(request(3, "session/prompt", params( "sessionId", "acp-session", "prompt", textPrompt("hello from acp") ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, provider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); Assert.assertTrue(hasResponse(messages, 1)); Assert.assertTrue(hasResponse(messages, 2)); Assert.assertTrue(hasResponse(messages, 3)); JSONObject newSessionResult = responseResult(messages, 2); Assert.assertEquals("acp-session", newSessionResult.getString("sessionId")); Assert.assertTrue(containsConfigOption(newSessionResult.getJSONArray("configOptions"), "mode")); Assert.assertTrue(containsConfigOption(newSessionResult.getJSONArray("configOptions"), "model")); Assert.assertEquals("auto", newSessionResult.getJSONObject("modes").getString("currentModeId")); JSONObject availableCommands = findSessionUpdate(messages, "available_commands_update"); Assert.assertNotNull(availableCommands); JSONArray available = availableCommands.getJSONArray("availableCommands"); Assert.assertNotNull(available); Assert.assertFalse(available.isEmpty()); Assert.assertTrue(containsAvailableCommand(available, "status")); Assert.assertTrue(containsAvailableCommand(available, "team")); Assert.assertTrue(containsAvailableCommand(available, "mcp")); Assert.assertTrue(hasSessionUpdate(messages, "user_message_chunk", "hello from acp")); Assert.assertTrue(hasSessionUpdate(messages, "agent_message_chunk", "Echo: hello from acp")); Assert.assertEquals("end_turn", responseResult(messages, 3).getString("stopReason")); ByteArrayOutputStream loadOut = new ByteArrayOutputStream(); ByteArrayOutputStream loadErr = new ByteArrayOutputStream(); String loadInput = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/list", params("cwd", workspace.toString()))) + line(request(3, "session/load", params( "sessionId", "acp-session", "cwd", workspace.toString() ))); int loadExit = new AcpJsonRpcServer( new ByteArrayInputStream(loadInput.getBytes(StandardCharsets.UTF_8)), loadOut, loadErr, options, provider() ).run(); Assert.assertEquals(0, loadExit); List loadMessages = parseLines(loadOut); Assert.assertEquals("acp-session", responseResult(loadMessages, 3).getString("sessionId")); Assert.assertTrue(containsConfigOption(responseResult(loadMessages, 3).getJSONArray("configOptions"), "mode")); Assert.assertTrue(containsConfigOption(responseResult(loadMessages, 3).getJSONArray("configOptions"), "model")); JSONArray sessions = responseResult(loadMessages, 2).getJSONArray("sessions"); Assert.assertNotNull(sessions); Assert.assertFalse(sessions.isEmpty()); JSONObject loadAvailableCommands = findSessionUpdate(loadMessages, "available_commands_update"); Assert.assertNotNull(loadAvailableCommands); Assert.assertTrue(containsAvailableCommand(loadAvailableCommands.getJSONArray("availableCommands"), "status")); Assert.assertTrue(hasSessionUpdate(loadMessages, "user_message_chunk", "hello from acp")); Assert.assertTrue(hasSessionUpdate(loadMessages, "agent_message_chunk", "Echo: hello from acp")); } @Test public void test_slash_command_prompt_is_handled_without_model_round_trip() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-slash"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "slash-session", "cwd", workspace.toString() ))) + line(request(3, "session/prompt", params( "sessionId", "slash-session", "prompt", textPrompt("/status") ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, provider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); Assert.assertEquals("end_turn", responseResult(messages, 3).getString("stopReason")); Assert.assertTrue(hasSessionUpdate(messages, "user_message_chunk", "/status")); Assert.assertTrue(containsSessionUpdatePrefix(messages, "agent_message_chunk", "status:")); Assert.assertFalse(hasSessionUpdate(messages, "agent_message_chunk", "Echo: /status")); } @Test public void test_provider_and_model_slash_commands_rebind_runtime() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-provider-model"); Path fakeHome = Files.createTempDirectory("ai4j-acp-provider-home"); String originalUserHome = System.getProperty("user.home"); PipedOutputStream clientToServer = new PipedOutputStream(); PipedInputStream serverInput = new PipedInputStream(clientToServer); PipedOutputStream serverToClient = new PipedOutputStream(); PipedInputStream clientInput = new PipedInputStream(serverToClient); ByteArrayOutputStream err = new ByteArrayOutputStream(); try { System.setProperty("user.home", fakeHome.toString()); CodeCommandOptions options = parseOptions(workspace, "--provider", "openai", "--model", "fake-model"); final AcpJsonRpcServer server = new AcpJsonRpcServer( serverInput, serverToClient, err, options, runtimeEchoProvider() ); Thread serverThread = new Thread(new Runnable() { @Override public void run() { server.run(); } }); serverThread.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8)); List messages = new ArrayList(); clientToServer.write(line(request(1, "initialize", params("protocolVersion", 1))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(2, "session/new", params( "sessionId", "provider-model-session", "cwd", workspace.toString() ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 1); readUntilResponse(reader, messages, 2); readUntilSessionUpdate(reader, messages, "available_commands_update"); JSONObject availableCommands = findSessionUpdate(messages, "available_commands_update"); Assert.assertNotNull(availableCommands); JSONArray available = availableCommands.getJSONArray("availableCommands"); Assert.assertTrue(containsAvailableCommand(available, "providers")); Assert.assertTrue(containsAvailableCommand(available, "provider")); Assert.assertTrue(containsAvailableCommand(available, "model")); sendPrompt(clientToServer, 3, "provider-model-session", "/provider add zhipu-main --provider zhipu --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4"); readUntilResponse(reader, messages, 3); sendPrompt(clientToServer, 4, "provider-model-session", "/provider use zhipu-main"); readUntilResponse(reader, messages, 4); sendPrompt(clientToServer, 5, "provider-model-session", "/model glm-4.7-plus"); readUntilResponse(reader, messages, 5); sendPrompt(clientToServer, 6, "provider-model-session", "hello from switched runtime"); readUntilResponse(reader, messages, 6); Assert.assertEquals("end_turn", responseResult(messages, 6).getString("stopReason")); Assert.assertTrue(hasSessionUpdate(messages, "agent_message_chunk", "Echo[zhipu/glm-4.7-plus]: hello from switched runtime")); JSONObject workspaceConfig = JSON.parseObject(Files.readAllBytes(workspace.resolve(".ai4j").resolve("workspace.json"))); Assert.assertEquals("zhipu-main", workspaceConfig.getString("activeProfile")); Assert.assertEquals("glm-4.7-plus", workspaceConfig.getString("modelOverride")); JSONObject providersConfig = JSON.parseObject(Files.readAllBytes(fakeHome.resolve(".ai4j").resolve("providers.json"))); Assert.assertNotNull(providersConfig.getJSONObject("profiles").getJSONObject("zhipu-main")); clientToServer.close(); serverThread.join(5000L); } finally { System.setProperty("user.home", originalUserHome); } } @Test public void test_set_config_option_updates_model_and_emits_config_update() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-set-config"); Path fakeHome = Files.createTempDirectory("ai4j-acp-set-config-home"); String originalUserHome = System.getProperty("user.home"); try { System.setProperty("user.home", fakeHome.toString()); Files.createDirectories(fakeHome.resolve(".ai4j")); Files.write(fakeHome.resolve(".ai4j").resolve("providers.json"), ("{\n" + " \"profiles\": {\n" + " \"openai-alt\": {\n" + " \"provider\": \"openai\",\n" + " \"model\": \"fake-model-2\"\n" + " }\n" + " }\n" + "}").getBytes(StandardCharsets.UTF_8)); CodeCommandOptions options = parseOptions(workspace, "--provider", "openai", "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "config-session", "cwd", workspace.toString() ))) + line(request(3, "session/set_config_option", params( "sessionId", "config-session", "configId", "model", "value", "fake-model-2" ))) + line(request(4, "session/prompt", params( "sessionId", "config-session", "prompt", textPrompt("hello from set_config_option") ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, runtimeEchoProvider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); Assert.assertTrue(containsConfigOptionValue(responseResult(messages, 3).getJSONArray("configOptions"), "model", "fake-model-2")); JSONObject configUpdate = findSessionUpdate(messages, "config_option_update"); Assert.assertNotNull(configUpdate); Assert.assertTrue(containsConfigOptionValue(configUpdate.getJSONArray("configOptions"), "model", "fake-model-2")); Assert.assertTrue(hasSessionUpdate(messages, "agent_message_chunk", "Echo[openai/fake-model-2]: hello from set_config_option")); } finally { System.setProperty("user.home", originalUserHome); } } @Test public void test_set_config_option_mode_rebinds_permission_behavior_for_factory_bound_decorator() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-mode-config-factory-bound"); PipedOutputStream clientToServer = new PipedOutputStream(); PipedInputStream serverInput = new PipedInputStream(clientToServer); PipedOutputStream serverToClient = new PipedOutputStream(); PipedInputStream clientInput = new PipedInputStream(serverToClient); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); final AcpJsonRpcServer server = new AcpJsonRpcServer( serverInput, serverToClient, err, options, factoryBoundApprovalProvider() ); Thread serverThread = new Thread(new Runnable() { @Override public void run() { server.run(); } }); serverThread.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8)); List messages = new ArrayList(); clientToServer.write(line(request(1, "initialize", params("protocolVersion", 1))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(2, "session/new", params( "sessionId", "factory-bound-mode-session", "cwd", workspace.toString() ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 1); readUntilResponse(reader, messages, 2); readUntilSessionUpdate(reader, messages, "available_commands_update"); clientToServer.write(line(request(3, "session/set_config_option", params( "sessionId", "factory-bound-mode-session", "configId", "mode", "value", "manual" ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 3); readUntilSessionUpdate(reader, messages, "current_mode_update"); readUntilSessionUpdate(reader, messages, "config_option_update"); sendPrompt(clientToServer, 4, "factory-bound-mode-session", "run bash now"); boolean sawPermissionRequest = false; boolean sawPromptResponse = false; for (int i = 0; i < 40; i++) { String messageLine = reader.readLine(); Assert.assertNotNull(messageLine); JSONObject message = JSON.parseObject(messageLine); messages.add(message); if ("session/request_permission".equals(message.getString("method"))) { sawPermissionRequest = true; Object requestId = message.get("id"); clientToServer.write(line(response(requestId, params( "outcome", params( "outcome", "selected", "optionId", "allow_once" ) ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); continue; } if (Integer.valueOf(4).equals(message.get("id"))) { sawPromptResponse = true; break; } } clientToServer.close(); serverThread.join(5000L); Assert.assertTrue(sawPermissionRequest); Assert.assertTrue(sawPromptResponse); } @Test public void test_set_config_option_rejects_model_not_present_in_select_options() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-set-config-invalid"); CodeCommandOptions options = parseOptions(workspace, "--provider", "openai", "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "invalid-config-session", "cwd", workspace.toString() ))) + line(request(3, "session/set_config_option", params( "sessionId", "invalid-config-session", "configId", "model", "value", "not-listed-model" ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, runtimeEchoProvider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); JSONObject errorResponse = findError(messages, 3); Assert.assertNotNull(errorResponse); Assert.assertTrue(errorResponse.getJSONObject("error").getString("message").contains("Unsupported model")); } @Test public void test_set_mode_switches_permission_behavior_and_emits_mode_updates() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-set-mode"); PipedOutputStream clientToServer = new PipedOutputStream(); PipedInputStream serverInput = new PipedInputStream(clientToServer); PipedOutputStream serverToClient = new PipedOutputStream(); PipedInputStream clientInput = new PipedInputStream(serverToClient); ByteArrayOutputStream err = new ByteArrayOutputStream(); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); final AcpJsonRpcServer server = new AcpJsonRpcServer( serverInput, serverToClient, err, options, provider() ); Thread serverThread = new Thread(new Runnable() { @Override public void run() { server.run(); } }); serverThread.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8)); List messages = new ArrayList(); clientToServer.write(line(request(1, "initialize", params("protocolVersion", 1))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(2, "session/new", params( "sessionId", "mode-session", "cwd", workspace.toString() ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 1); readUntilResponse(reader, messages, 2); readUntilSessionUpdate(reader, messages, "available_commands_update"); clientToServer.write(line(request(3, "session/set_mode", params( "sessionId", "mode-session", "modeId", "manual" ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 3); readUntilSessionUpdate(reader, messages, "current_mode_update"); readUntilSessionUpdate(reader, messages, "config_option_update"); clientToServer.write(line(request(4, "session/prompt", params( "sessionId", "mode-session", "prompt", textPrompt("run bash now") ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); boolean sawPermissionRequest = false; boolean sawPromptResponse = false; for (int i = 0; i < 30; i++) { String messageLine = reader.readLine(); Assert.assertNotNull(messageLine); JSONObject message = JSON.parseObject(messageLine); messages.add(message); if ("session/request_permission".equals(message.getString("method"))) { sawPermissionRequest = true; Object requestId = message.get("id"); clientToServer.write(line(response(requestId, params( "outcome", params( "outcome", "selected", "optionId", "allow_once" ) ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); continue; } if (Integer.valueOf(4).equals(message.get("id"))) { sawPromptResponse = true; break; } } clientToServer.close(); serverThread.join(5000L); Assert.assertTrue(sawPermissionRequest); Assert.assertTrue(sawPromptResponse); JSONObject currentModeUpdate = findSessionUpdate(messages, "current_mode_update"); Assert.assertNotNull(currentModeUpdate); Assert.assertEquals("manual", currentModeUpdate.getString("currentModeId")); JSONObject configUpdate = findSessionUpdate(messages, "config_option_update"); Assert.assertNotNull(configUpdate); Assert.assertTrue(containsConfigOptionValue(configUpdate.getJSONArray("configOptions"), "mode", "manual")); } @Test public void test_set_mode_during_active_prompt_applies_to_next_turn() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-set-mode-active"); PipedOutputStream clientToServer = new PipedOutputStream(); PipedInputStream serverInput = new PipedInputStream(clientToServer); PipedOutputStream serverToClient = new PipedOutputStream(); PipedInputStream clientInput = new PipedInputStream(serverToClient); ByteArrayOutputStream err = new ByteArrayOutputStream(); CountDownLatch promptStarted = new CountDownLatch(1); CountDownLatch releasePrompt = new CountDownLatch(1); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); final AcpJsonRpcServer server = new AcpJsonRpcServer( serverInput, serverToClient, err, options, blockingProvider(promptStarted, releasePrompt) ); Thread serverThread = new Thread(new Runnable() { @Override public void run() { server.run(); } }); serverThread.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8)); List messages = new ArrayList(); clientToServer.write(line(request(1, "initialize", params("protocolVersion", 1))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(2, "session/new", params( "sessionId", "mode-active-session", "cwd", workspace.toString() ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 1); readUntilResponse(reader, messages, 2); readUntilSessionUpdate(reader, messages, "available_commands_update"); sendPrompt(clientToServer, 3, "mode-active-session", "hold mode switch"); Assert.assertTrue(promptStarted.await(5, TimeUnit.SECONDS)); clientToServer.write(line(request(4, "session/set_mode", params( "sessionId", "mode-active-session", "modeId", "manual" ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 4); readUntilSessionUpdate(reader, messages, "current_mode_update"); readUntilSessionUpdate(reader, messages, "config_option_update"); releasePrompt.countDown(); readUntilResponse(reader, messages, 3); sendPrompt(clientToServer, 5, "mode-active-session", "run bash now"); boolean sawPermissionRequest = false; boolean sawPromptResponse = false; for (int i = 0; i < 40; i++) { String messageLine = reader.readLine(); Assert.assertNotNull(messageLine); JSONObject message = JSON.parseObject(messageLine); messages.add(message); if ("session/request_permission".equals(message.getString("method"))) { sawPermissionRequest = true; Object requestId = message.get("id"); clientToServer.write(line(response(requestId, params( "outcome", params( "outcome", "selected", "optionId", "allow_once" ) ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); continue; } if (Integer.valueOf(5).equals(message.get("id"))) { sawPromptResponse = true; break; } } clientToServer.close(); serverThread.join(5000L); Assert.assertTrue(sawPermissionRequest); Assert.assertTrue(sawPromptResponse); Assert.assertTrue(hasSessionUpdate(messages, "agent_message_chunk", "Echo: hold mode switch")); } @Test public void test_set_config_option_during_active_prompt_applies_to_next_turn() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-set-config-active"); Path fakeHome = Files.createTempDirectory("ai4j-acp-set-config-active-home"); String originalUserHome = System.getProperty("user.home"); PipedOutputStream clientToServer = new PipedOutputStream(); PipedInputStream serverInput = new PipedInputStream(clientToServer); PipedOutputStream serverToClient = new PipedOutputStream(); PipedInputStream clientInput = new PipedInputStream(serverToClient); ByteArrayOutputStream err = new ByteArrayOutputStream(); CountDownLatch promptStarted = new CountDownLatch(1); CountDownLatch releasePrompt = new CountDownLatch(1); try { System.setProperty("user.home", fakeHome.toString()); Files.createDirectories(fakeHome.resolve(".ai4j")); Files.write(fakeHome.resolve(".ai4j").resolve("providers.json"), ("{\n" + " \"profiles\": {\n" + " \"openai-alt\": {\n" + " \"provider\": \"openai\",\n" + " \"model\": \"fake-model-2\"\n" + " }\n" + " }\n" + "}").getBytes(StandardCharsets.UTF_8)); CodeCommandOptions options = parseOptions(workspace, "--provider", "openai", "--model", "fake-model"); final AcpJsonRpcServer server = new AcpJsonRpcServer( serverInput, serverToClient, err, options, blockingRuntimeEchoProvider(promptStarted, releasePrompt) ); Thread serverThread = new Thread(new Runnable() { @Override public void run() { server.run(); } }); serverThread.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8)); List messages = new ArrayList(); clientToServer.write(line(request(1, "initialize", params("protocolVersion", 1))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(2, "session/new", params( "sessionId", "config-active-session", "cwd", workspace.toString() ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 1); readUntilResponse(reader, messages, 2); readUntilSessionUpdate(reader, messages, "available_commands_update"); sendPrompt(clientToServer, 3, "config-active-session", "hold config switch"); Assert.assertTrue(promptStarted.await(5, TimeUnit.SECONDS)); clientToServer.write(line(request(4, "session/set_config_option", params( "sessionId", "config-active-session", "configId", "model", "value", "fake-model-2" ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 4); readUntilSessionUpdate(reader, messages, "config_option_update"); releasePrompt.countDown(); readUntilResponse(reader, messages, 3); sendPrompt(clientToServer, 5, "config-active-session", "after deferred model switch"); readUntilResponse(reader, messages, 5); clientToServer.close(); serverThread.join(5000L); Assert.assertTrue(containsConfigOptionValue(responseResult(messages, 4).getJSONArray("configOptions"), "model", "fake-model-2")); Assert.assertTrue(hasSessionUpdate(messages, "agent_message_chunk", "Echo[openai/fake-model]: hold config switch")); Assert.assertTrue(hasSessionUpdate(messages, "agent_message_chunk", "Echo[openai/fake-model-2]: after deferred model switch")); } finally { System.setProperty("user.home", originalUserHome); } } @Test public void test_load_history_replays_delegate_task_events_as_tool_updates() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-task-history"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); String sessionId = "task-history-session"; seedDelegateTaskHistory(workspace, sessionId); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/load", params( "sessionId", sessionId, "cwd", workspace.toString() ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, provider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); Assert.assertEquals(sessionId, responseResult(messages, 2).getString("sessionId")); JSONObject toolCall = findSessionUpdate(messages, "tool_call"); Assert.assertNotNull(toolCall); Assert.assertEquals("task-1", toolCall.getString("toolCallId")); Assert.assertEquals("Delegate plan", toolCall.getString("title")); Assert.assertEquals("other", toolCall.getString("kind")); Assert.assertEquals("pending", toolCall.getString("status")); Assert.assertEquals("task-1", toolCall.getJSONObject("rawInput").getString("taskId")); Assert.assertEquals("plan", toolCall.getJSONObject("rawInput").getString("definition")); Assert.assertEquals("delegate-session-1", toolCall.getJSONObject("rawInput").getString("childSessionId")); Assert.assertEquals("fork", toolCall.getJSONObject("rawInput").getString("sessionMode")); JSONObject toolCallUpdate = findSessionUpdate(messages, "tool_call_update"); Assert.assertNotNull(toolCallUpdate); Assert.assertEquals("task-1", toolCallUpdate.getString("toolCallId")); Assert.assertEquals("completed", toolCallUpdate.getString("status")); JSONArray content = toolCallUpdate.getJSONArray("content"); Assert.assertNotNull(content); Assert.assertFalse(content.isEmpty()); Assert.assertEquals("content", content.getJSONObject(0).getString("type")); Assert.assertEquals("delegate plan ready", content.getJSONObject(0).getJSONObject("content").getString("text")); Assert.assertEquals("delegate plan ready", toolCallUpdate.getJSONObject("rawOutput").getString("text")); } @Test public void test_subagent_handoff_is_streamed_as_live_tool_updates() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-subagent-live"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "subagent-session", "cwd", workspace.toString() ))) + line(request(3, "session/prompt", params( "sessionId", "subagent-session", "prompt", textPrompt("run subagent review") ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, subagentProvider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); Assert.assertEquals("end_turn", responseResult(messages, 3).getString("stopReason")); JSONObject handoffCall = findSessionUpdateByToolCallId(messages, "tool_call", "handoff:review-call"); Assert.assertNotNull(handoffCall); Assert.assertEquals("Subagent reviewer", handoffCall.getString("title")); Assert.assertEquals("pending", handoffCall.getString("status")); Assert.assertEquals("reviewer", handoffCall.getJSONObject("rawInput").getString("subagent")); JSONObject handoffUpdate = findSessionUpdateByToolCallId(messages, "tool_call_update", "handoff:review-call"); Assert.assertNotNull(handoffUpdate); Assert.assertEquals("completed", handoffUpdate.getString("status")); Assert.assertEquals("review-ready", handoffUpdate.getJSONObject("rawOutput").getString("text")); } @Test public void test_team_subagent_tasks_are_streamed_as_live_tool_updates() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-team-live"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "team-session", "cwd", workspace.toString() ))) + line(request(3, "session/prompt", params( "sessionId", "team-session", "prompt", textPrompt("run team review") ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, teamSubagentProvider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); Assert.assertEquals("end_turn", responseResult(messages, 3).getString("stopReason")); JSONObject taskCall = findSessionUpdateByToolCallId(messages, "tool_call", "team-task:review"); Assert.assertNotNull(taskCall); Assert.assertEquals("Team task review", taskCall.getString("title")); Assert.assertEquals("pending", taskCall.getString("status")); Assert.assertEquals("reviewer", taskCall.getJSONObject("rawInput").getString("memberId")); Assert.assertEquals("reviewer", taskCall.getJSONObject("rawInput").getString("memberName")); Assert.assertEquals("Review this patch", taskCall.getJSONObject("rawInput").getString("task")); Assert.assertEquals(Integer.valueOf(0), taskCall.getJSONObject("rawInput").getInteger("percent")); JSONObject taskRunningUpdate = findSessionUpdateByToolCallIdAndStatus(messages, "tool_call_update", "team-task:review", "in_progress"); Assert.assertNotNull(taskRunningUpdate); Assert.assertEquals("Assigned to Reviewer.", taskRunningUpdate.getJSONObject("rawOutput").getString("text")); Assert.assertEquals("running", taskRunningUpdate.getJSONObject("rawOutput").getString("phase")); Assert.assertEquals(Integer.valueOf(15), taskRunningUpdate.getJSONObject("rawOutput").getInteger("percent")); Assert.assertTrue(taskRunningUpdate.getJSONArray("content").getJSONObject(0).getJSONObject("content").getString("text") .contains("running 15%")); JSONObject taskUpdate = findSessionUpdateByToolCallId(messages, "tool_call_update", "team-task:review"); Assert.assertNotNull(taskUpdate); Assert.assertEquals("completed", taskUpdate.getString("status")); Assert.assertEquals("team-review-ready", taskUpdate.getJSONObject("rawOutput").getString("text")); Assert.assertEquals("completed", taskUpdate.getJSONObject("rawOutput").getString("phase")); Assert.assertEquals(Integer.valueOf(100), taskUpdate.getJSONObject("rawOutput").getInteger("percent")); JSONObject teamMessage = findSessionUpdateByToolCallIdAndRawOutputType(messages, "team-task:review", "team_message"); Assert.assertNotNull(teamMessage); Assert.assertEquals("tool_call_update", teamMessage.getString("sessionUpdate")); Assert.assertEquals("task.assigned", teamMessage.getJSONObject("rawOutput").getString("messageType")); Assert.assertEquals("system", teamMessage.getJSONObject("rawOutput").getString("fromMemberId")); Assert.assertEquals("reviewer", teamMessage.getJSONObject("rawOutput").getString("toMemberId")); Assert.assertEquals("review", teamMessage.getJSONObject("rawOutput").getString("taskId")); Assert.assertEquals("Review this patch", teamMessage.getJSONObject("rawOutput").getString("text")); JSONArray teamMessageContent = teamMessage.getJSONArray("content"); Assert.assertNotNull(teamMessageContent); Assert.assertFalse(teamMessageContent.isEmpty()); Assert.assertTrue(teamMessageContent.getJSONObject(0).getJSONObject("content").getString("text") .contains("[task.assigned] system -> reviewer")); Assert.assertEquals(0, countSessionUpdates(messages, "team_message")); } @Test public void test_permission_request_round_trip() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-permission"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model", "--approval", "manual"); PipedOutputStream clientToServer = new PipedOutputStream(); PipedInputStream serverInput = new PipedInputStream(clientToServer); PipedOutputStream serverToClient = new PipedOutputStream(); PipedInputStream clientInput = new PipedInputStream(serverToClient); ByteArrayOutputStream err = new ByteArrayOutputStream(); final AcpJsonRpcServer server = new AcpJsonRpcServer( serverInput, serverToClient, err, options, provider() ); Thread serverThread = new Thread(new Runnable() { @Override public void run() { server.run(); } }); serverThread.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8)); clientToServer.write(line(request(1, "initialize", params("protocolVersion", 1))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(2, "session/new", params( "sessionId", "permission-session", "cwd", workspace.toString() ))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(3, "session/prompt", params( "sessionId", "permission-session", "prompt", textPrompt("run bash now") ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); boolean sawPermissionRequest = false; boolean sawToolCallUpdate = false; boolean sawPromptResponse = false; List seenMessages = new ArrayList(); for (int i = 0; i < 20; i++) { String line = reader.readLine(); Assert.assertNotNull(line); JSONObject message = JSON.parseObject(line); seenMessages.add(message); if ("session/request_permission".equals(message.getString("method"))) { sawPermissionRequest = true; Object requestId = message.get("id"); clientToServer.write(line(response(requestId, params( "outcome", params( "outcome", "selected", "optionId", "allow_once" ) ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); continue; } if ("session/update".equals(message.getString("method"))) { JSONObject update = message.getJSONObject("params").getJSONObject("update"); if ("tool_call_update".equals(update.getString("sessionUpdate"))) { sawToolCallUpdate = true; } continue; } if (Integer.valueOf(3).equals(message.get("id"))) { sawPromptResponse = true; Assert.assertEquals("end_turn", message.getJSONObject("result").getString("stopReason")); break; } } clientToServer.close(); serverThread.join(5000L); Assert.assertTrue(sawPermissionRequest); Assert.assertTrue(sawToolCallUpdate); Assert.assertTrue(sawPromptResponse); Assert.assertEquals(1, countSessionUpdates(seenMessages, "tool_call")); JSONObject toolCall = findSessionUpdate(seenMessages, "tool_call"); Assert.assertNotNull(toolCall); Assert.assertEquals("other", toolCall.getString("kind")); Assert.assertEquals("pending", toolCall.getString("status")); JSONObject toolCallUpdate = findSessionUpdate(seenMessages, "tool_call_update"); Assert.assertNotNull(toolCallUpdate); Assert.assertEquals("completed", toolCallUpdate.getString("status")); JSONArray content = toolCallUpdate.getJSONArray("content"); Assert.assertNotNull(content); Assert.assertFalse(content.isEmpty()); Assert.assertEquals("content", content.getJSONObject(0).getString("type")); Assert.assertNotNull(content.getJSONObject(0).getJSONObject("content")); Assert.assertTrue(content.getJSONObject(0).getJSONObject("content").getString("text").contains("cmd /c echo hi")); } @Test public void test_permission_request_uses_generated_session_id_when_session_new_omits_session_id() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-permission-generated-session"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model", "--approval", "manual"); PipedOutputStream clientToServer = new PipedOutputStream(); PipedInputStream serverInput = new PipedInputStream(clientToServer); PipedOutputStream serverToClient = new PipedOutputStream(); PipedInputStream clientInput = new PipedInputStream(serverToClient); ByteArrayOutputStream err = new ByteArrayOutputStream(); final AcpJsonRpcServer server = new AcpJsonRpcServer( serverInput, serverToClient, err, options, provider() ); Thread serverThread = new Thread(new Runnable() { @Override public void run() { server.run(); } }); serverThread.start(); BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8)); List messages = new ArrayList(); clientToServer.write(line(request(1, "initialize", params("protocolVersion", 1))).getBytes(StandardCharsets.UTF_8)); clientToServer.write(line(request(2, "session/new", params( "cwd", workspace.toString() ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); readUntilResponse(reader, messages, 1); readUntilResponse(reader, messages, 2); readUntilSessionUpdate(reader, messages, "available_commands_update"); JSONObject newSessionResult = responseResult(messages, 2); Assert.assertNotNull(newSessionResult); String generatedSessionId = newSessionResult.getString("sessionId"); Assert.assertNotNull(generatedSessionId); Assert.assertFalse(generatedSessionId.trim().isEmpty()); sendPrompt(clientToServer, 3, generatedSessionId, "run bash now"); boolean sawPermissionRequest = false; boolean sawPromptResponse = false; for (int i = 0; i < 30; i++) { String messageLine = reader.readLine(); Assert.assertNotNull(messageLine); JSONObject message = JSON.parseObject(messageLine); messages.add(message); if ("session/request_permission".equals(message.getString("method"))) { sawPermissionRequest = true; JSONObject permissionParams = message.getJSONObject("params"); Assert.assertNotNull(permissionParams); Assert.assertEquals(generatedSessionId, permissionParams.getString("sessionId")); Object requestId = message.get("id"); clientToServer.write(line(response(requestId, params( "outcome", params( "outcome", "selected", "optionId", "allow_once" ) ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); continue; } if (Integer.valueOf(3).equals(message.get("id"))) { sawPromptResponse = true; Assert.assertEquals("end_turn", message.getJSONObject("result").getString("stopReason")); break; } } clientToServer.close(); serverThread.join(5000L); Assert.assertTrue(sawPermissionRequest); Assert.assertTrue(sawPromptResponse); Assert.assertEquals(1, countSessionUpdates(messages, "tool_call")); } @Test public void test_markdown_newlines_are_preserved_in_agent_chunks() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-markdown"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String promptText = "markdown demo"; String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "markdown-session", "cwd", workspace.toString() ))) + line(request(3, "session/prompt", params( "sessionId", "markdown-session", "prompt", textPrompt(promptText) ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, provider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); JSONObject update = findSessionUpdate(messages, "agent_message_chunk"); Assert.assertNotNull(update); Assert.assertEquals("# Title\n\n- item", update.getJSONObject("content").getString("text")); } @Test public void test_markdown_char_stream_is_forwarded_without_coalescing() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-markdown-stream"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "markdown-stream-session", "cwd", workspace.toString() ))) + line(request(3, "session/prompt", params( "sessionId", "markdown-stream-session", "prompt", textPrompt("markdown stream demo") ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, provider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); List chunks = sessionUpdateTexts(messages, "agent_message_chunk"); Assert.assertFalse(chunks.isEmpty()); Assert.assertEquals(Arrays.asList("##", " Heading\n\n", "1", ". item\n\n", "Done", "."), chunks); Assert.assertEquals("## Heading\n\n1. item\n\nDone.", join(chunks)); } @Test public void test_whitespace_only_stream_chunks_are_forwarded() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-whitespace-stream"); CodeCommandOptions options = parseOptions(workspace, "--model", "fake-model"); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); String input = line(request(1, "initialize", params("protocolVersion", 1))) + line(request(2, "session/new", params( "sessionId", "whitespace-stream-session", "cwd", workspace.toString() ))) + line(request(3, "session/prompt", params( "sessionId", "whitespace-stream-session", "prompt", textPrompt("whitespace stream demo") ))); int exitCode = new AcpJsonRpcServer( new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, err, options, provider() ).run(); Assert.assertEquals(0, exitCode); List messages = parseLines(out); List chunks = sessionUpdateTexts(messages, "agent_message_chunk"); Assert.assertEquals(Arrays.asList("# Heading", "\n\n", "- item"), chunks); Assert.assertEquals("# Heading\n\n- item", join(chunks)); } private AcpJsonRpcServer.AgentFactoryProvider provider() { return provider(new FakeAcpModelClient()); } private AcpJsonRpcServer.AgentFactoryProvider provider(final AgentModelClient modelClient) { return new AcpJsonRpcServer.AgentFactoryProvider() { @Override public CodingCliAgentFactory create(final CodeCommandOptions options, final AcpToolApprovalDecorator.PermissionGateway permissionGateway, final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) { return new CodingCliAgentFactory() { @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) { return prepare(runtimeOptions, null, null, Collections.emptySet()); } @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions, io.github.lnyocly.ai4j.tui.TerminalIO terminal, io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState, java.util.Collection pausedMcpServers) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(modelClient) .model(runtimeOptions.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway)) .build()) .build(), runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(), resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig) ); } }; } }; } private AcpJsonRpcServer.AgentFactoryProvider blockingProvider(CountDownLatch promptStarted, CountDownLatch releasePrompt) { return provider(new BlockingAcpModelClient(promptStarted, releasePrompt)); } private AcpJsonRpcServer.AgentFactoryProvider factoryBoundApprovalProvider() { return new AcpJsonRpcServer.AgentFactoryProvider() { @Override public CodingCliAgentFactory create(final CodeCommandOptions options, final AcpToolApprovalDecorator.PermissionGateway permissionGateway, final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) { final ApprovalMode capturedApprovalMode = options == null ? ApprovalMode.AUTO : options.getApprovalMode(); return new CodingCliAgentFactory() { @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) { return prepare(runtimeOptions, null, null, Collections.emptySet()); } @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions, io.github.lnyocly.ai4j.tui.TerminalIO terminal, io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState, java.util.Collection pausedMcpServers) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(new FakeAcpModelClient()) .model(runtimeOptions.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new AcpToolApprovalDecorator(capturedApprovalMode, permissionGateway)) .build()) .build(), runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(), resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig) ); } }; } }; } private AcpJsonRpcServer.AgentFactoryProvider subagentProvider() { return new AcpJsonRpcServer.AgentFactoryProvider() { @Override public CodingCliAgentFactory create(final CodeCommandOptions options, final AcpToolApprovalDecorator.PermissionGateway permissionGateway, final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) { return new CodingCliAgentFactory() { @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) { return prepare(runtimeOptions, null, null, Collections.emptySet()); } @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions, io.github.lnyocly.ai4j.tui.TerminalIO terminal, io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState, java.util.Collection pausedMcpServers) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(new FakeSubagentRootModelClient()) .model(runtimeOptions.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway)) .build()) .subAgent(SubAgentDefinition.builder() .name("reviewer") .toolName("subagent_review") .description("Review code changes") .agent(Agents.react() .modelClient(new FakeSubagentWorkerModelClient()) .model(runtimeOptions.getModel()) .build()) .build()) .build(), runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(), resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig) ); } }; } }; } private AcpJsonRpcServer.AgentFactoryProvider teamSubagentProvider() { return new AcpJsonRpcServer.AgentFactoryProvider() { @Override public CodingCliAgentFactory create(final CodeCommandOptions options, final AcpToolApprovalDecorator.PermissionGateway permissionGateway, final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) { return new CodingCliAgentFactory() { @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) { return prepare(runtimeOptions, null, null, Collections.emptySet()); } @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions, io.github.lnyocly.ai4j.tui.TerminalIO terminal, io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState, java.util.Collection pausedMcpServers) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(new FakeSubagentRootModelClient()) .model(runtimeOptions.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway)) .build()) .subAgent(SubAgentDefinition.builder() .name("team-reviewer") .toolName("subagent_review") .description("Review code changes with a team") .agent(Agents.team() .planner((objective, members, teamOptions) -> AgentTeamPlan.builder() .tasks(Arrays.asList( AgentTeamTask.builder() .id("review") .memberId("reviewer") .task("Review this patch") .build() )) .build()) .synthesizerAgent(Agents.react() .modelClient(new FakeSubagentWorkerModelClient("team-synth-complete")) .model(runtimeOptions.getModel()) .build()) .member(AgentTeamMember.builder() .id("reviewer") .name("Reviewer") .agent(Agents.react() .modelClient(new FakeSubagentWorkerModelClient("team-review-ready")) .model(runtimeOptions.getModel()) .build()) .build()) .buildAgent()) .build()) .build(), runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(), resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig) ); } }; } }; } private AcpJsonRpcServer.AgentFactoryProvider runtimeEchoProvider() { return new AcpJsonRpcServer.AgentFactoryProvider() { @Override public CodingCliAgentFactory create(final CodeCommandOptions options, final AcpToolApprovalDecorator.PermissionGateway permissionGateway, final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) { return new CodingCliAgentFactory() { @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) { return prepare(runtimeOptions, null, null, Collections.emptySet()); } @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions, io.github.lnyocly.ai4j.tui.TerminalIO terminal, io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState, java.util.Collection pausedMcpServers) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(new RuntimeEchoModelClient( runtimeOptions.getProvider() == null ? null : runtimeOptions.getProvider().getPlatform(), runtimeOptions.getModel())) .model(runtimeOptions.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway)) .build()) .build(), runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(), resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig) ); } }; } }; } private AcpJsonRpcServer.AgentFactoryProvider blockingRuntimeEchoProvider(final CountDownLatch promptStarted, final CountDownLatch releasePrompt) { return new AcpJsonRpcServer.AgentFactoryProvider() { @Override public CodingCliAgentFactory create(final CodeCommandOptions options, final AcpToolApprovalDecorator.PermissionGateway permissionGateway, final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) { return new CodingCliAgentFactory() { @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) { return prepare(runtimeOptions, null, null, Collections.emptySet()); } @Override public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions, io.github.lnyocly.ai4j.tui.TerminalIO terminal, io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState, java.util.Collection pausedMcpServers) { return new PreparedCodingAgent( CodingAgents.builder() .modelClient(new BlockingRuntimeEchoModelClient( runtimeOptions.getProvider() == null ? null : runtimeOptions.getProvider().getPlatform(), runtimeOptions.getModel(), promptStarted, releasePrompt)) .model(runtimeOptions.getModel()) .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build()) .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build()) .codingOptions(CodingAgentOptions.builder() .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway)) .build()) .build(), runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(), resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig) ); } }; } }; } private CodeCommandOptions parseOptions(Path workspace, String... args) { CodeCommandOptionsParser parser = new CodeCommandOptionsParser(); List values = new ArrayList(Arrays.asList(args)); values.add("--workspace"); values.add(workspace.toString()); return parser.parse(values, Collections.emptyMap(), new Properties(), workspace); } private JSONObject request(Object id, String method, Map params) { JSONObject object = new JSONObject(); object.put("jsonrpc", "2.0"); object.put("id", id); object.put("method", method); object.put("params", params); return object; } private JSONObject response(Object id, Map result) { JSONObject object = new JSONObject(); object.put("jsonrpc", "2.0"); object.put("id", id); object.put("result", result); return object; } private JSONArray textPrompt(String text) { JSONArray prompt = new JSONArray(); JSONObject block = new JSONObject(); block.put("type", "text"); block.put("text", text); prompt.add(block); return prompt; } private Map params(Object... values) { JSONObject object = new JSONObject(); for (int i = 0; i + 1 < values.length; i += 2) { object.put(String.valueOf(values[i]), values[i + 1]); } return object; } private String line(JSONObject object) { return object.toJSONString() + "\n"; } private void sendPrompt(PipedOutputStream clientToServer, int id, String sessionId, String text) throws Exception { clientToServer.write(line(request(id, "session/prompt", params( "sessionId", sessionId, "prompt", textPrompt(text) ))).getBytes(StandardCharsets.UTF_8)); clientToServer.flush(); } private void readUntilResponse(BufferedReader reader, List messages, int responseId) throws Exception { for (int i = 0; i < 100; i++) { String messageLine = reader.readLine(); Assert.assertNotNull(messageLine); if (messageLine.trim().isEmpty()) { continue; } JSONObject message = JSON.parseObject(messageLine); messages.add(message); if (Integer.valueOf(responseId).equals(message.get("id"))) { return; } } Assert.fail("Timed out waiting for ACP response id=" + responseId); } private void readUntilSessionUpdate(BufferedReader reader, List messages, String sessionUpdateType) throws Exception { if (findSessionUpdate(messages, sessionUpdateType) != null) { return; } for (int i = 0; i < 100; i++) { String messageLine = reader.readLine(); Assert.assertNotNull(messageLine); if (messageLine.trim().isEmpty()) { continue; } JSONObject message = JSON.parseObject(messageLine); messages.add(message); if ("session/update".equals(message.getString("method"))) { JSONObject params = message.getJSONObject("params"); JSONObject update = params == null ? null : params.getJSONObject("update"); if (update != null && sessionUpdateType.equals(update.getString("sessionUpdate"))) { return; } } } Assert.fail("Timed out waiting for ACP session update type=" + sessionUpdateType); } private List parseLines(ByteArrayOutputStream output) throws Exception { BufferedReader reader = new BufferedReader(new InputStreamReader( new ByteArrayInputStream(output.toByteArray()), StandardCharsets.UTF_8 )); List messages = new ArrayList(); String line; while ((line = reader.readLine()) != null) { if (!line.trim().isEmpty()) { messages.add(JSON.parseObject(line)); } } return messages; } private boolean hasResponse(List messages, int id) { for (JSONObject message : messages) { if (Integer.valueOf(id).equals(message.get("id")) && message.containsKey("result")) { return true; } } return false; } private JSONObject responseResult(List messages, int id) { for (JSONObject message : messages) { if (Integer.valueOf(id).equals(message.get("id")) && message.containsKey("result")) { return message.getJSONObject("result"); } } return null; } private JSONObject findError(List messages, int id) { for (JSONObject message : messages) { if (Integer.valueOf(id).equals(message.get("id")) && message.containsKey("error")) { return message; } } return null; } private boolean hasSessionUpdate(List messages, String updateType, String expectedText) { for (JSONObject message : messages) { if (!"session/update".equals(message.getString("method"))) { continue; } JSONObject params = message.getJSONObject("params"); if (params == null) { continue; } JSONObject update = params.getJSONObject("update"); if (update == null || !updateType.equals(update.getString("sessionUpdate"))) { continue; } JSONObject content = update.getJSONObject("content"); if (content != null && expectedText.equals(content.getString("text"))) { return true; } } return false; } private int countSessionUpdates(List messages, String updateType) { int count = 0; for (JSONObject message : messages) { if (!"session/update".equals(message.getString("method"))) { continue; } JSONObject params = message.getJSONObject("params"); if (params == null) { continue; } JSONObject update = params.getJSONObject("update"); if (update != null && updateType.equals(update.getString("sessionUpdate"))) { count++; } } return count; } private boolean containsAvailableCommand(JSONArray commands, String name) { if (commands == null || name == null) { return false; } for (int i = 0; i < commands.size(); i++) { JSONObject command = commands.getJSONObject(i); if (command != null && name.equals(command.getString("name"))) { return true; } } return false; } private boolean containsConfigOption(JSONArray configOptions, String id) { if (configOptions == null || id == null) { return false; } for (int i = 0; i < configOptions.size(); i++) { JSONObject option = configOptions.getJSONObject(i); if (option != null && id.equals(option.getString("id"))) { return true; } } return false; } private boolean containsConfigOptionValue(JSONArray configOptions, String id, String expectedValue) { if (configOptions == null || id == null || expectedValue == null) { return false; } for (int i = 0; i < configOptions.size(); i++) { JSONObject option = configOptions.getJSONObject(i); if (option != null && id.equals(option.getString("id")) && expectedValue.equals(option.getString("currentValue"))) { return true; } } return false; } private boolean containsSessionUpdatePrefix(List messages, String updateType, String prefix) { if (prefix == null) { return false; } List texts = sessionUpdateTexts(messages, updateType); for (String text : texts) { if (text != null && text.startsWith(prefix)) { return true; } } return false; } private JSONObject findSessionUpdate(List messages, String updateType) { for (JSONObject message : messages) { if (!"session/update".equals(message.getString("method"))) { continue; } JSONObject params = message.getJSONObject("params"); if (params == null) { continue; } JSONObject update = params.getJSONObject("update"); if (update != null && updateType.equals(update.getString("sessionUpdate"))) { return update; } } return null; } private JSONObject findSessionUpdateByToolCallId(List messages, String updateType, String toolCallId) { JSONObject matched = null; for (JSONObject message : messages) { if (!"session/update".equals(message.getString("method"))) { continue; } JSONObject params = message.getJSONObject("params"); if (params == null) { continue; } JSONObject update = params.getJSONObject("update"); if (update == null || !updateType.equals(update.getString("sessionUpdate"))) { continue; } if (toolCallId.equals(update.getString("toolCallId"))) { matched = update; } } return matched; } private JSONObject findSessionUpdateByToolCallIdAndStatus(List messages, String updateType, String toolCallId, String status) { for (JSONObject message : messages) { if (!"session/update".equals(message.getString("method"))) { continue; } JSONObject params = message.getJSONObject("params"); if (params == null) { continue; } JSONObject update = params.getJSONObject("update"); if (update == null || !updateType.equals(update.getString("sessionUpdate"))) { continue; } if (toolCallId.equals(update.getString("toolCallId")) && status.equals(update.getString("status"))) { return update; } } return null; } private JSONObject findSessionUpdateByToolCallIdAndRawOutputType(List messages, String toolCallId, String rawOutputType) { for (JSONObject message : messages) { if (!"session/update".equals(message.getString("method"))) { continue; } JSONObject params = message.getJSONObject("params"); if (params == null) { continue; } JSONObject update = params.getJSONObject("update"); if (update == null || !"tool_call_update".equals(update.getString("sessionUpdate"))) { continue; } JSONObject rawOutput = update.getJSONObject("rawOutput"); if (toolCallId.equals(update.getString("toolCallId")) && rawOutput != null && rawOutputType.equals(rawOutput.getString("type"))) { return update; } } return null; } private List sessionUpdateTexts(List messages, String updateType) { List texts = new ArrayList(); for (JSONObject message : messages) { if (!"session/update".equals(message.getString("method"))) { continue; } JSONObject params = message.getJSONObject("params"); if (params == null) { continue; } JSONObject update = params.getJSONObject("update"); if (update == null || !updateType.equals(update.getString("sessionUpdate"))) { continue; } JSONObject content = update.getJSONObject("content"); if (content != null) { texts.add(content.getString("text")); } } return texts; } private String join(List values) { StringBuilder builder = new StringBuilder(); for (String value : values) { if (value != null) { builder.append(value); } } return builder.toString(); } private void seedDelegateTaskHistory(Path workspace, String sessionId) throws Exception { Path sessionDirectory = workspace.resolve(".ai4j").resolve("sessions"); long now = System.currentTimeMillis(); new FileCodingSessionStore(sessionDirectory).save(StoredCodingSession.builder() .sessionId(sessionId) .rootSessionId(sessionId) .provider("openai") .protocol("responses") .model("fake-model") .workspace(workspace.toString()) .summary("delegate history") .createdAtEpochMs(now) .updatedAtEpochMs(now) .state(CodingSessionState.builder() .sessionId(sessionId) .workspaceRoot(workspace.toString()) .build()) .build()); DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager( new FileCodingSessionStore(sessionDirectory), new FileSessionEventStore(sessionDirectory.resolve("events")) ); CodingTaskSessionEventBridge bridge = new CodingTaskSessionEventBridge(sessionManager); CodingTask task = CodingTask.builder() .taskId("task-1") .definitionName("plan") .parentSessionId(sessionId) .childSessionId("delegate-session-1") .background(true) .status(CodingTaskStatus.QUEUED) .progress(CodingTaskProgress.builder() .phase("queued") .message("Task queued for execution.") .percent(0) .updatedAtEpochMs(now) .build()) .createdAtEpochMs(now) .build(); CodingSessionLink link = CodingSessionLink.builder() .linkId("link-1") .taskId("task-1") .definitionName("plan") .parentSessionId(sessionId) .childSessionId("delegate-session-1") .sessionMode(CodingSessionMode.FORK) .background(true) .createdAtEpochMs(now) .build(); bridge.onTaskCreated(task, link); bridge.onTaskUpdated(task.toBuilder() .status(CodingTaskStatus.COMPLETED) .outputText("delegate plan ready") .progress(task.getProgress().toBuilder() .phase("completed") .message("Delegated session completed.") .percent(100) .updatedAtEpochMs(now + 1) .build()) .build()); } private static final class FakeAcpModelClient implements AgentModelClient { @Override public AgentModelResult create(AgentPrompt prompt) { String toolOutput = findLastToolOutput(prompt); if (toolOutput != null) { return AgentModelResult.builder().outputText("Tool done: " + toolOutput).build(); } String userText = findLastUserText(prompt); if (userText != null && userText.toLowerCase().contains("markdown demo")) { return AgentModelResult.builder().outputText("# Title\n\n- item").build(); } if (userText != null && userText.toLowerCase().contains("markdown stream demo")) { return AgentModelResult.builder().outputText("## Heading\n\n1. item\n\nDone.").build(); } if (userText != null && userText.toLowerCase().contains("whitespace stream demo")) { return AgentModelResult.builder().outputText("# Heading\n\n- item").build(); } if (userText != null && userText.toLowerCase().contains("run bash")) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("bash-call") .name("bash") .arguments("{\"action\":\"exec\",\"command\":\"cmd /c echo hi\"}") .build())) .build(); } return AgentModelResult.builder().outputText("Echo: " + userText).build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null) { if (result.getOutputText() != null && !result.getOutputText().isEmpty()) { String userText = findLastUserText(prompt); if (userText != null && userText.toLowerCase().contains("markdown stream demo")) { List tinyChunks = Arrays.asList("##", " Heading\n\n", "1", ". item\n\n", "Done", "."); for (String chunk : tinyChunks) { listener.onDeltaText(chunk); } } else if (userText != null && userText.toLowerCase().contains("whitespace stream demo")) { for (String chunk : Arrays.asList("# Heading", "\n\n", "- item")) { listener.onDeltaText(chunk); } } else { listener.onDeltaText(result.getOutputText()); } } if (result.getToolCalls() != null) { for (AgentToolCall call : result.getToolCalls()) { listener.onToolCall(call); } } listener.onComplete(result); } return result; } private String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } List parts = (List) content; for (Object part : parts) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if ("input_text".equals(partMap.get("type"))) { Object text = partMap.get("text"); return text == null ? "" : String.valueOf(text); } } } return ""; } private String findLastToolOutput(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return null; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"function_call_output".equals(map.get("type"))) { continue; } Object output = map.get("output"); if (output != null) { return String.valueOf(output); } } return null; } } private static final class RuntimeEchoModelClient implements AgentModelClient { private final String provider; private final String model; private RuntimeEchoModelClient(String provider, String model) { this.provider = provider; this.model = model; } @Override public AgentModelResult create(AgentPrompt prompt) { return AgentModelResult.builder() .outputText("Echo[" + provider + "/" + model + "]: " + findLastUserText(prompt)) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } List parts = (List) content; for (Object part : parts) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if ("input_text".equals(partMap.get("type"))) { Object text = partMap.get("text"); return text == null ? "" : String.valueOf(text); } } } return ""; } } private static final class BlockingRuntimeEchoModelClient implements AgentModelClient { private final String provider; private final String model; private final CountDownLatch promptStarted; private final CountDownLatch releasePrompt; private BlockingRuntimeEchoModelClient(String provider, String model, CountDownLatch promptStarted, CountDownLatch releasePrompt) { this.provider = provider; this.model = model; this.promptStarted = promptStarted; this.releasePrompt = releasePrompt; } @Override public AgentModelResult create(AgentPrompt prompt) { return AgentModelResult.builder() .outputText("Echo[" + provider + "/" + model + "]: " + findLastUserText(prompt)) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { String userText = findLastUserText(prompt); if (userText != null && userText.toLowerCase().contains("hold config switch")) { promptStarted.countDown(); awaitRelease(releasePrompt); } AgentModelResult result = create(prompt); if (listener != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } } private static final class BlockingAcpModelClient implements AgentModelClient { private final FakeAcpModelClient delegate = new FakeAcpModelClient(); private final CountDownLatch promptStarted; private final CountDownLatch releasePrompt; private BlockingAcpModelClient(CountDownLatch promptStarted, CountDownLatch releasePrompt) { this.promptStarted = promptStarted; this.releasePrompt = releasePrompt; } @Override public AgentModelResult create(AgentPrompt prompt) { return delegate.create(prompt); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { String userText = findLastUserText(prompt); if (userText != null && userText.toLowerCase().contains("hold mode switch")) { promptStarted.countDown(); awaitRelease(releasePrompt); } return delegate.createStream(prompt, listener); } } private static void awaitRelease(CountDownLatch releasePrompt) { try { releasePrompt.await(5, TimeUnit.SECONDS); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new IllegalStateException("Interrupted while waiting to release prompt", ex); } } private static String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return ""; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"message".equals(map.get("type")) || !"user".equals(map.get("role"))) { continue; } Object content = map.get("content"); if (!(content instanceof List)) { continue; } List parts = (List) content; for (Object part : parts) { if (!(part instanceof Map)) { continue; } Map partMap = (Map) part; if ("input_text".equals(partMap.get("type"))) { Object text = partMap.get("text"); return text == null ? "" : String.valueOf(text); } } } return ""; } private static final class FakeSubagentRootModelClient implements AgentModelClient { @Override public AgentModelResult create(AgentPrompt prompt) { String toolOutput = findLastToolOutput(prompt); if (toolOutput != null) { return AgentModelResult.builder().outputText("Root completed after " + toolOutput).build(); } return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .callId("review-call") .name("subagent_review") .arguments("{\"task\":\"Review this patch\"}") .build())) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null) { if (result.getToolCalls() != null) { for (AgentToolCall call : result.getToolCalls()) { listener.onToolCall(call); } } if (result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); } listener.onComplete(result); } return result; } private String findLastToolOutput(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null) { return null; } List items = prompt.getItems(); for (int i = items.size() - 1; i >= 0; i--) { Object item = items.get(i); if (!(item instanceof Map)) { continue; } Map map = (Map) item; if (!"function_call_output".equals(map.get("type"))) { continue; } Object output = map.get("output"); if (output != null) { return String.valueOf(output); } } return null; } } private static final class FakeSubagentWorkerModelClient implements AgentModelClient { private final String output; private FakeSubagentWorkerModelClient() { this("review-ready"); } private FakeSubagentWorkerModelClient(String output) { this.output = output; } @Override public AgentModelResult create(AgentPrompt prompt) { return AgentModelResult.builder().outputText(output).build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/acp/AcpSlashCommandSupportTest.java ================================================ package io.github.lnyocly.ai4j.cli.acp; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberSnapshot; import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus; import io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore; import io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager; import io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.FileSessionEventStore; import io.github.lnyocly.ai4j.cli.session.StoredCodingSession; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionState; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import org.junit.Assert; import org.junit.Test; import java.nio.file.Path; import java.nio.file.Files; import java.util.HashMap; import java.util.List; import java.util.Map; public class AcpSlashCommandSupportTest { @Test public void availableCommandsShouldIncludeTeam() { Assert.assertTrue(containsCommand("team")); Assert.assertTrue(containsCommand("providers")); Assert.assertTrue(containsCommand("provider")); Assert.assertTrue(containsCommand("model")); Assert.assertTrue(containsCommand("experimental")); } @Test public void executeExperimentalShouldDelegateToRuntimeHandler() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-experimental-command"); String sessionId = "experimental-command-session"; DefaultCodingSessionManager sessionManager = seedTeamHistory(workspace, sessionId); ManagedCodingSession session = new ManagedCodingSession( new CodingSession(sessionId, null, null, null, null), "openai", "responses", "fake-model", workspace.toString(), null, null, null, sessionId, null, System.currentTimeMillis(), System.currentTimeMillis() ); AcpSlashCommandSupport.ExecutionResult result = AcpSlashCommandSupport.execute( new AcpSlashCommandSupport.Context( session, sessionManager, null, null, null, new AcpSlashCommandSupport.RuntimeCommandHandler() { @Override public String executeProviders() { return "providers"; } @Override public String executeProvider(String argument) { return "provider " + argument; } @Override public String executeModel(String argument) { return "model " + argument; } @Override public String executeExperimental(String argument) { return "experimental " + argument; } } ), "/experimental subagent off" ); Assert.assertNotNull(result); Assert.assertEquals("experimental subagent off", result.getOutput()); } @Test public void executeTeamShouldRenderMemberLaneBoard() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-team-command"); String sessionId = "team-command-session"; DefaultCodingSessionManager sessionManager = seedTeamHistory(workspace, sessionId); ManagedCodingSession session = new ManagedCodingSession( new CodingSession(sessionId, null, null, null, null), "openai", "responses", "fake-model", workspace.toString(), null, null, null, sessionId, null, System.currentTimeMillis(), System.currentTimeMillis() ); AcpSlashCommandSupport.ExecutionResult result = AcpSlashCommandSupport.execute( new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null), "/team" ); Assert.assertNotNull(result); Assert.assertTrue(result.getOutput().contains("team board:")); Assert.assertTrue(result.getOutput().contains("lane Reviewer")); Assert.assertTrue(result.getOutput().contains("Review this patch")); Assert.assertTrue(result.getOutput().contains("[task.assigned] system -> reviewer")); } @Test public void executeTeamSubcommandsShouldRenderPersistedState() throws Exception { Path workspace = Files.createTempDirectory("ai4j-acp-team-persisted"); String sessionId = "team-persisted-session"; DefaultCodingSessionManager sessionManager = seedTeamHistory(workspace, sessionId); seedPersistedTeamState(workspace, "experimental-delivery-team"); ManagedCodingSession session = new ManagedCodingSession( new CodingSession(sessionId, null, null, null, null), "openai", "responses", "fake-model", workspace.toString(), null, null, null, sessionId, null, System.currentTimeMillis(), System.currentTimeMillis() ); AcpSlashCommandSupport.ExecutionResult listResult = AcpSlashCommandSupport.execute( new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null), "/team list" ); AcpSlashCommandSupport.ExecutionResult statusResult = AcpSlashCommandSupport.execute( new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null), "/team status experimental-delivery-team" ); AcpSlashCommandSupport.ExecutionResult messageResult = AcpSlashCommandSupport.execute( new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null), "/team messages experimental-delivery-team 5" ); AcpSlashCommandSupport.ExecutionResult resumeResult = AcpSlashCommandSupport.execute( new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null), "/team resume experimental-delivery-team" ); Assert.assertNotNull(listResult); Assert.assertTrue(listResult.getOutput().contains("teams:")); Assert.assertTrue(listResult.getOutput().contains("experimental-delivery-team")); Assert.assertNotNull(statusResult); Assert.assertTrue(statusResult.getOutput().contains("team status:")); Assert.assertTrue(statusResult.getOutput().contains("objective=Deliver a travel planner demo.")); Assert.assertNotNull(messageResult); Assert.assertTrue(messageResult.getOutput().contains("team messages:")); Assert.assertTrue(messageResult.getOutput().contains("Define the backend contract first.")); Assert.assertNotNull(resumeResult); Assert.assertTrue(resumeResult.getOutput().contains("team resumed: experimental-delivery-team")); Assert.assertTrue(resumeResult.getOutput().contains("team board:")); } private DefaultCodingSessionManager seedTeamHistory(Path workspace, String sessionId) throws Exception { Path sessionDirectory = workspace.resolve(".ai4j").resolve("sessions"); long now = System.currentTimeMillis(); new FileCodingSessionStore(sessionDirectory).save(StoredCodingSession.builder() .sessionId(sessionId) .rootSessionId(sessionId) .provider("openai") .protocol("responses") .model("fake-model") .workspace(workspace.toString()) .summary("team history") .createdAtEpochMs(now) .updatedAtEpochMs(now) .state(CodingSessionState.builder() .sessionId(sessionId) .workspaceRoot(workspace.toString()) .build()) .build()); DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager( new FileCodingSessionStore(sessionDirectory), new FileSessionEventStore(sessionDirectory.resolve("events")) ); Map createdPayload = new HashMap(); createdPayload.put("taskId", "review"); createdPayload.put("callId", "team-task:review"); createdPayload.put("title", "Team task review"); createdPayload.put("task", "Review this patch"); createdPayload.put("status", "pending"); createdPayload.put("phase", "planned"); createdPayload.put("percent", Integer.valueOf(0)); createdPayload.put("memberId", "reviewer"); createdPayload.put("memberName", "Reviewer"); Map updatedPayload = new HashMap(createdPayload); updatedPayload.put("status", "running"); updatedPayload.put("phase", "heartbeat"); updatedPayload.put("percent", Integer.valueOf(15)); updatedPayload.put("detail", "Heartbeat from reviewer."); updatedPayload.put("heartbeatCount", Integer.valueOf(2)); updatedPayload.put("updatedAtEpochMs", Long.valueOf(now + 1L)); Map messagePayload = new HashMap(); messagePayload.put("messageId", "msg-1"); messagePayload.put("taskId", "review"); messagePayload.put("fromMemberId", "system"); messagePayload.put("toMemberId", "reviewer"); messagePayload.put("messageType", "task.assigned"); messagePayload.put("content", "Review this patch"); messagePayload.put("createdAt", Long.valueOf(now + 2L)); sessionManager.appendEvent(sessionId, SessionEvent.builder() .sessionId(sessionId) .type(SessionEventType.TASK_CREATED) .timestamp(now) .summary("Team task review [pending]") .payload(createdPayload) .build()); sessionManager.appendEvent(sessionId, SessionEvent.builder() .sessionId(sessionId) .type(SessionEventType.TASK_UPDATED) .timestamp(now + 1L) .summary("Team task review [running]") .payload(updatedPayload) .build()); sessionManager.appendEvent(sessionId, SessionEvent.builder() .sessionId(sessionId) .type(SessionEventType.TEAM_MESSAGE) .timestamp(now + 2L) .summary("Team message system -> reviewer [task.assigned]") .payload(messagePayload) .build()); return sessionManager; } private void seedPersistedTeamState(Path workspace, String teamId) { Path teamRoot = workspace.resolve(".ai4j").resolve("teams"); long now = System.currentTimeMillis(); AgentTeamTask backendTask = AgentTeamTask.builder() .id("backend") .memberId("backend") .task("Define travel destination API") .build(); AgentTeamTaskState backendState = AgentTeamTaskState.builder() .taskId("backend") .task(backendTask) .status(AgentTeamTaskStatus.IN_PROGRESS) .claimedBy("backend") .phase("running") .detail("Drafting OpenAPI paths and payloads.") .percent(Integer.valueOf(40)) .heartbeatCount(2) .updatedAtEpochMs(now) .build(); AgentTeamMessage message = AgentTeamMessage.builder() .id("msg-1") .fromMemberId("architect") .toMemberId("backend") .type("contract.note") .taskId("backend") .content("Define the backend contract first.") .createdAt(now) .build(); AgentTeamState state = AgentTeamState.builder() .teamId(teamId) .objective("Deliver a travel planner demo.") .members(java.util.Arrays.asList( AgentTeamMemberSnapshot.builder().id("architect").name("Architect").description("System design").build(), AgentTeamMemberSnapshot.builder().id("backend").name("Backend").description("API implementation").build() )) .taskStates(java.util.Collections.singletonList(backendState)) .messages(java.util.Collections.singletonList(message)) .lastOutput("Architecture and backend work are in progress.") .lastRounds(2) .lastRunStartedAt(now - 3000L) .updatedAt(now) .runActive(true) .build(); new FileAgentTeamStateStore(teamRoot.resolve("state")).save(state); new FileAgentTeamMessageBus(teamRoot.resolve("mailbox").resolve(teamId + ".jsonl")) .restore(java.util.Collections.singletonList(message)); } private boolean containsCommand(String name) { List> commands = AcpSlashCommandSupport.availableCommands(); if (commands == null) { return false; } for (Map command : commands) { if (command != null && name.equals(command.get("name"))) { return true; } } return false; } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/agent/CliCodingAgentRegistryTest.java ================================================ package io.github.lnyocly.ai4j.cli.agent; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingIsolationMode; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import org.junit.Assert; import org.junit.Test; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.List; public class CliCodingAgentRegistryTest { @Test public void loadRegistryParsesWorkspaceAndConfiguredAgentDirectories() throws Exception { Path workspace = Files.createTempDirectory("ai4j-cli-agent-workspace"); Path configuredDirectory = Files.createTempDirectory("ai4j-cli-agent-configured"); Path workspaceAgents = workspace.resolve(".ai4j").resolve("agents"); Files.createDirectories(workspaceAgents); Files.write(workspaceAgents.resolve("reviewer.md"), ( "---\n" + "name: reviewer\n" + "description: Review code changes for regressions.\n" + "tools: read-only\n" + "background: true\n" + "---\n" + "Inspect diffs and summarize correctness, risk, and missing tests.\n" ).getBytes(StandardCharsets.UTF_8)); Files.write(configuredDirectory.resolve("planner.prompt"), ( "---\n" + "name: planner\n" + "toolName: delegate_planner_custom\n" + "model: gpt-5-mini\n" + "---\n" + "Read the workspace and return a concrete implementation plan.\n" ).getBytes(StandardCharsets.UTF_8)); CliCodingAgentRegistry registry = new CliCodingAgentRegistry( workspace, Arrays.asList(configuredDirectory.toString()) ); List definitions = registry.listDefinitions(); CodingAgentDefinition reviewer = registry.loadRegistry().getDefinition("reviewer"); CodingAgentDefinition planner = registry.loadRegistry().getDefinition("delegate_planner_custom"); Assert.assertEquals(2, definitions.size()); Assert.assertNotNull(reviewer); Assert.assertEquals("delegate_reviewer", reviewer.getToolName()); Assert.assertEquals(CodingToolNames.readOnlyBuiltIn(), reviewer.getAllowedToolNames()); Assert.assertEquals(CodingIsolationMode.READ_ONLY, reviewer.getIsolationMode()); Assert.assertTrue(reviewer.isBackground()); Assert.assertNotNull(planner); Assert.assertEquals("planner", planner.getName()); Assert.assertEquals("gpt-5-mini", planner.getModel()); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/AgentHandoffSessionEventSupportTest.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; public class AgentHandoffSessionEventSupportTest { @Test public void shouldMapHandoffEventsToTaskSessionEvents() { Map startPayload = new LinkedHashMap(); startPayload.put("handoffId", "handoff:call-1"); startPayload.put("tool", "subagent_review"); startPayload.put("subagent", "review"); startPayload.put("title", "Subagent review"); startPayload.put("detail", "Delegating to subagent review."); startPayload.put("status", "starting"); startPayload.put("sessionMode", "new_session"); AgentEvent startEvent = AgentEvent.builder() .type(AgentEventType.HANDOFF_START) .step(0) .message("Subagent review") .payload(startPayload) .build(); Assert.assertTrue(AgentHandoffSessionEventSupport.supports(startEvent)); Assert.assertEquals(SessionEventType.TASK_CREATED, AgentHandoffSessionEventSupport.resolveSessionEventType(startEvent)); Assert.assertEquals("Subagent review [starting]", AgentHandoffSessionEventSupport.buildSummary(startEvent)); Map createdPayload = AgentHandoffSessionEventSupport.buildPayload(startEvent); Assert.assertEquals("handoff:call-1", createdPayload.get("taskId")); Assert.assertEquals("handoff:call-1", createdPayload.get("callId")); Assert.assertEquals("Subagent review", createdPayload.get("title")); Assert.assertEquals("starting", createdPayload.get("status")); Assert.assertEquals("new_session", createdPayload.get("sessionMode")); Map endPayload = new LinkedHashMap(); endPayload.put("handoffId", "handoff:call-1"); endPayload.put("tool", "subagent_review"); endPayload.put("subagent", "review"); endPayload.put("title", "Subagent review"); endPayload.put("detail", "Subagent completed."); endPayload.put("status", "completed"); endPayload.put("output", "review line 1\nreview line 2"); endPayload.put("attempts", Integer.valueOf(1)); endPayload.put("durationMillis", Long.valueOf(42L)); AgentEvent endEvent = AgentEvent.builder() .type(AgentEventType.HANDOFF_END) .step(0) .message("Subagent review") .payload(endPayload) .build(); Assert.assertEquals(SessionEventType.TASK_UPDATED, AgentHandoffSessionEventSupport.resolveSessionEventType(endEvent)); Map updatedPayload = AgentHandoffSessionEventSupport.buildPayload(endEvent); Assert.assertEquals("completed", updatedPayload.get("status")); Assert.assertEquals("review line 1\nreview line 2", updatedPayload.get("output")); Assert.assertEquals(Arrays.asList("review line 1", "review line 2"), updatedPayload.get("previewLines")); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamMessageSessionEventSupportTest.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; public class AgentTeamMessageSessionEventSupportTest { @Test public void shouldMapTeamMessageEventToSessionEvent() { Map payload = new LinkedHashMap(); payload.put("messageId", "msg-1"); payload.put("fromMemberId", "reviewer"); payload.put("toMemberId", "lead"); payload.put("taskId", "review"); payload.put("type", "task.result"); payload.put("content", "Patch looks good.\nNo blocking issue found."); AgentEvent event = AgentEvent.builder() .type(AgentEventType.TEAM_MESSAGE) .step(2) .message("Patch looks good.") .payload(payload) .build(); Assert.assertTrue(AgentTeamMessageSessionEventSupport.supports(event)); Assert.assertEquals("Team message reviewer -> lead [task.result]", AgentTeamMessageSessionEventSupport.buildSummary(event)); SessionEvent sessionEvent = AgentTeamMessageSessionEventSupport.toSessionEvent("session-1", "turn-1", event); Assert.assertNotNull(sessionEvent); Assert.assertEquals(SessionEventType.TEAM_MESSAGE, sessionEvent.getType()); Assert.assertEquals("msg-1", sessionEvent.getPayload().get("messageId")); Assert.assertEquals("team-task:review", sessionEvent.getPayload().get("callId")); Assert.assertEquals("task.result", sessionEvent.getPayload().get("messageType")); Assert.assertEquals(Arrays.asList("Patch looks good.", "No blocking issue found."), sessionEvent.getPayload().get("previewLines")); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamSessionEventSupportTest.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; public class AgentTeamSessionEventSupportTest { @Test public void shouldMapTeamTaskEventsToTaskSessionEvents() { Map createdPayload = new LinkedHashMap(); createdPayload.put("taskId", "team-task:collect"); createdPayload.put("callId", "team-task:collect"); createdPayload.put("title", "Team task collect"); createdPayload.put("status", "planned"); createdPayload.put("memberId", "researcher"); createdPayload.put("memberName", "Researcher"); createdPayload.put("task", "Collect requirements"); createdPayload.put("dependsOn", Arrays.asList("seed")); createdPayload.put("detail", "Task planned."); createdPayload.put("phase", "planned"); createdPayload.put("percent", Integer.valueOf(0)); AgentEvent createdEvent = AgentEvent.builder() .type(AgentEventType.TEAM_TASK_CREATED) .step(1) .message("Team task collect") .payload(createdPayload) .build(); Assert.assertTrue(AgentTeamSessionEventSupport.supports(createdEvent)); Assert.assertEquals(SessionEventType.TASK_CREATED, AgentTeamSessionEventSupport.resolveSessionEventType(createdEvent)); Assert.assertEquals("Team task collect [planned]", AgentTeamSessionEventSupport.buildSummary(createdEvent)); Map createdSessionPayload = AgentTeamSessionEventSupport.buildPayload(createdEvent); Assert.assertEquals("team-task:collect", createdSessionPayload.get("taskId")); Assert.assertEquals("researcher", createdSessionPayload.get("memberId")); Assert.assertEquals("Researcher", createdSessionPayload.get("memberName")); Assert.assertEquals("Collect requirements", createdSessionPayload.get("task")); Assert.assertEquals("planned", createdSessionPayload.get("phase")); Assert.assertEquals(Integer.valueOf(0), createdSessionPayload.get("percent")); Map updatedPayload = new LinkedHashMap(); updatedPayload.put("taskId", "team-task:collect"); updatedPayload.put("callId", "team-task:collect"); updatedPayload.put("title", "Team task collect"); updatedPayload.put("status", "completed"); updatedPayload.put("phase", "completed"); updatedPayload.put("percent", Integer.valueOf(100)); updatedPayload.put("heartbeatCount", Integer.valueOf(2)); updatedPayload.put("output", "Collected requirement list\nCaptured risks"); updatedPayload.put("durationMillis", Long.valueOf(33L)); AgentEvent updatedEvent = AgentEvent.builder() .type(AgentEventType.TEAM_TASK_UPDATED) .step(1) .message("Team task collect") .payload(updatedPayload) .build(); Assert.assertEquals(SessionEventType.TASK_UPDATED, AgentTeamSessionEventSupport.resolveSessionEventType(updatedEvent)); Map updatedSessionPayload = AgentTeamSessionEventSupport.buildPayload(updatedEvent); Assert.assertEquals("completed", updatedSessionPayload.get("status")); Assert.assertEquals("Collected requirement list\nCaptured risks", updatedSessionPayload.get("output")); Assert.assertEquals("completed", updatedSessionPayload.get("phase")); Assert.assertEquals(Integer.valueOf(100), updatedSessionPayload.get("percent")); Assert.assertEquals(Integer.valueOf(2), updatedSessionPayload.get("heartbeatCount")); Assert.assertEquals(Arrays.asList("Collected requirement list", "Captured risks"), updatedSessionPayload.get("previewLines")); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/CodingTaskSessionEventBridgeTest.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager; import io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.coding.task.CodingTask; import io.github.lnyocly.ai4j.coding.task.CodingTaskProgress; import io.github.lnyocly.ai4j.coding.task.CodingTaskStatus; import org.junit.Assert; import org.junit.Test; import java.nio.file.Paths; import java.util.List; public class CodingTaskSessionEventBridgeTest { @Test public void shouldAppendCreatedAndUpdatedTaskEvents() throws Exception { DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager( new InMemoryCodingSessionStore(Paths.get("(memory-sessions)")), new InMemorySessionEventStore() ); CodingTaskSessionEventBridge bridge = new CodingTaskSessionEventBridge(sessionManager); CodingTask task = CodingTask.builder() .taskId("task-1") .definitionName("explore") .parentSessionId("session-1") .childSessionId("child-1") .background(true) .status(CodingTaskStatus.QUEUED) .progress(CodingTaskProgress.builder() .phase("queued") .message("Task queued for execution.") .percent(0) .updatedAtEpochMs(System.currentTimeMillis()) .build()) .createdAtEpochMs(System.currentTimeMillis()) .build(); CodingSessionLink link = CodingSessionLink.builder() .linkId("link-1") .taskId("task-1") .definitionName("explore") .parentSessionId("session-1") .childSessionId("child-1") .sessionMode(CodingSessionMode.FORK) .background(true) .createdAtEpochMs(System.currentTimeMillis()) .build(); bridge.onTaskCreated(task, link); bridge.onTaskUpdated(task.toBuilder() .status(CodingTaskStatus.COMPLETED) .outputText("delegate complete") .progress(task.getProgress().toBuilder() .phase("completed") .message("Delegated session completed.") .percent(100) .build()) .build()); List events = sessionManager.listEvents("session-1", null, null); Assert.assertEquals(2, events.size()); Assert.assertEquals(SessionEventType.TASK_CREATED, events.get(0).getType()); Assert.assertEquals(SessionEventType.TASK_UPDATED, events.get(1).getType()); Assert.assertEquals("task-1", events.get(0).getPayload().get("taskId")); Assert.assertEquals("fork", events.get(0).getPayload().get("sessionMode")); Assert.assertEquals("completed", events.get(1).getPayload().get("status")); Assert.assertEquals("delegate complete", events.get(1).getPayload().get("output")); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/HeadlessCodingSessionRuntimeTest.java ================================================ package io.github.lnyocly.ai4j.cli.runtime; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.CliProtocol; import io.github.lnyocly.ai4j.cli.CliUiMode; import io.github.lnyocly.ai4j.cli.command.CodeCommandOptions; import io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager; import io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore; import io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.CodingAgents; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.loop.CodingStopReason; import io.github.lnyocly.ai4j.coding.session.ManagedCodingSession; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import io.github.lnyocly.ai4j.service.PlatformType; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class HeadlessCodingSessionRuntimeTest { private static final String STUB_TOOL = "stub_tool"; @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldMapApprovalBlockToBlockedStopReasonAndAppendBlockedEvent() throws Exception { DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager( new InMemoryCodingSessionStore(Paths.get("(memory-sessions)")), new InMemorySessionEventStore() ); CodeCommandOptions options = testOptions(); HeadlessCodingSessionRuntime runtime = new HeadlessCodingSessionRuntime(options, sessionManager); CollectingObserver observer = new CollectingObserver(); InspectableQueueModelClient modelClient = new InspectableQueueModelClient(); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-1")); modelClient.enqueue(assistantResult("Approval required before proceeding.")); try (ManagedCodingSession session = managedSession(newAgent(modelClient, approvalRejectedToolExecutor()), options)) { HeadlessCodingSessionRuntime.PromptResult result = runtime.runPrompt(session, "Run the protected operation.", null, observer); List events = sessionManager.listEvents(session.getSessionId(), null, null); assertEquals("blocked", result.getStopReason()); assertEquals(CodingStopReason.BLOCKED_BY_APPROVAL, result.getCodingStopReason()); assertTrue(hasEvent(events, SessionEventType.BLOCKED)); assertTrue(hasEvent(observer.sessionEvents, SessionEventType.BLOCKED)); assertFalse(hasEvent(events, SessionEventType.AUTO_CONTINUE)); } } @Test public void shouldAppendAutoContinueAndAutoStopEventsForLoopedPrompt() throws Exception { DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager( new InMemoryCodingSessionStore(Paths.get("(memory-sessions)")), new InMemorySessionEventStore() ); CodeCommandOptions options = testOptions(); HeadlessCodingSessionRuntime runtime = new HeadlessCodingSessionRuntime(options, sessionManager); CollectingObserver observer = new CollectingObserver(); InspectableQueueModelClient modelClient = new InspectableQueueModelClient(); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-1")); modelClient.enqueue(assistantResult("Continuing with remaining work.")); modelClient.enqueue(assistantResult("Completed the requested change.")); try (ManagedCodingSession session = managedSession(newAgent(modelClient, okToolExecutor()), options)) { HeadlessCodingSessionRuntime.PromptResult result = runtime.runPrompt(session, "Implement the requested change.", null, observer); List events = sessionManager.listEvents(session.getSessionId(), null, null); assertEquals("end_turn", result.getStopReason()); assertEquals(CodingStopReason.COMPLETED, result.getCodingStopReason()); assertTrue(hasEvent(events, SessionEventType.AUTO_CONTINUE)); assertTrue(hasEvent(events, SessionEventType.AUTO_STOP)); assertTrue(hasEvent(observer.sessionEvents, SessionEventType.AUTO_CONTINUE)); assertTrue(hasEvent(observer.sessionEvents, SessionEventType.AUTO_STOP)); } } private CodeCommandOptions testOptions() { return new CodeCommandOptions( false, CliUiMode.CLI, PlatformType.OPENAI, CliProtocol.CHAT, "glm-4.5-flash", null, null, "(workspace)", "JUnit workspace", null, null, null, 0, null, null, null, null, false, null, null, null, null, null, ApprovalMode.AUTO, true, false, false, 128000, 16384, 20000, 400, false ); } private ManagedCodingSession managedSession(CodingAgent agent, CodeCommandOptions options) throws Exception { Path workspaceRoot = temporaryFolder.newFolder("headless-runtime-workspace").toPath(); CodingSession session = agent.newSession(); long now = System.currentTimeMillis(); return new ManagedCodingSession( session, options.getProvider().getPlatform(), options.getProtocol().getValue(), options.getModel(), workspaceRoot.toString(), options.getWorkspaceDescription(), options.getSystemPrompt(), options.getInstructions(), session.getSessionId(), null, now, now ); } private CodingAgent newAgent(InspectableQueueModelClient modelClient, ToolExecutor toolExecutor) throws Exception { Path workspaceRoot = temporaryFolder.newFolder("headless-agent-workspace").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit headless runtime workspace") .build(); return CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(CodingAgentOptions.builder() .autoCompactEnabled(false) .autoContinueEnabled(true) .maxAutoFollowUps(2) .maxTotalTurns(6) .build()) .toolRegistry(singleToolRegistry(STUB_TOOL)) .toolExecutor(toolExecutor) .build(); } private AgentToolRegistry singleToolRegistry(String toolName) { Tool.Function function = new Tool.Function(); function.setName(toolName); function.setDescription("Stub tool for testing"); return new StaticToolRegistry(Collections.singletonList(new Tool("function", function))); } private ToolExecutor okToolExecutor() { return new ToolExecutor() { @Override public String execute(AgentToolCall call) { return "{\"ok\":true}"; } }; } private ToolExecutor approvalRejectedToolExecutor() { return new ToolExecutor() { @Override public String execute(AgentToolCall call) { return "[approval-rejected] protected action"; } }; } private boolean hasEvent(List events, SessionEventType type) { if (events == null) { return false; } for (SessionEvent event : events) { if (event != null && event.getType() == type) { return true; } } return false; } private AgentModelResult toolCallResult(String toolName, String callId) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .name(toolName) .arguments("{}") .callId(callId) .type("function") .build())) .memoryItems(Collections.emptyList()) .build(); } private AgentModelResult assistantResult(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", text))) .build(); } private static final class CollectingObserver extends HeadlessTurnObserver.Adapter { private final List sessionEvents = new ArrayList(); @Override public void onSessionEvent(ManagedCodingSession session, SessionEvent event) { if (event != null) { sessionEvents.add(event); } } } private static final class InspectableQueueModelClient implements AgentModelClient { private final Deque results = new ArrayDeque(); private final List prompts = new ArrayList(); private void enqueue(AgentModelResult result) { results.addLast(result); } @Override public AgentModelResult create(AgentPrompt prompt) { prompts.add(prompt == null ? null : prompt.toBuilder().build()); AgentModelResult result = results.removeFirst(); return result == null ? AgentModelResult.builder().build() : result; } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/AnsiTuiRuntimeTest.java ================================================ package io.github.lnyocly.ai4j.tui; import org.junit.Assert; import org.junit.Test; import java.io.IOException; public class AnsiTuiRuntimeTest { @Test public void shouldPrintFramesWithoutTrailingNewlineAndSkipDuplicateRenders() { RecordingTerminalIO terminal = new RecordingTerminalIO(); SequenceRenderer renderer = new SequenceRenderer("frame-1", "frame-1", "frame-2"); AnsiTuiRuntime runtime = new AnsiTuiRuntime(terminal, renderer, false); runtime.render(TuiScreenModel.builder().build()); runtime.render(TuiScreenModel.builder().build()); runtime.render(TuiScreenModel.builder().build()); Assert.assertEquals(0, terminal.clearCount); Assert.assertEquals(2, terminal.moveHomeCount); Assert.assertEquals(4, terminal.printCount); Assert.assertEquals(0, terminal.printlnCount); Assert.assertEquals("frame-1\u001b[Jframe-2\u001b[J", terminal.printed.toString()); } private static final class SequenceRenderer implements TuiRenderer { private final String[] frames; private int index; private SequenceRenderer(String... frames) { this.frames = frames == null ? new String[0] : frames; } @Override public int getMaxEvents() { return 0; } @Override public String getThemeName() { return "test"; } @Override public void updateTheme(TuiConfig config, TuiTheme theme) { } @Override public String render(TuiScreenModel screenModel) { if (frames.length == 0) { return ""; } int current = Math.min(index, frames.length - 1); index++; return frames[current]; } } private static final class RecordingTerminalIO implements TerminalIO { private final StringBuilder printed = new StringBuilder(); private int printCount; private int printlnCount; private int clearCount; private int moveHomeCount; @Override public String readLine(String prompt) throws IOException { return null; } @Override public void print(String message) { printCount++; printed.append(message == null ? "" : message); } @Override public void println(String message) { printlnCount++; printed.append(message == null ? "" : message).append('\n'); } @Override public void errorln(String message) { } @Override public void clearScreen() { clearCount++; } @Override public void moveCursorHome() { moveHomeCount++; } @Override public boolean supportsAnsi() { return true; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/AppendOnlyTuiRuntimeTest.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import org.junit.Assert; import org.junit.Test; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; public class AppendOnlyTuiRuntimeTest { @Test public void shouldNotDuplicateLiveReasoningWhenAssistantEventArrives() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.render(screenModel( TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.THINKING) .reasoningText("Inspecting the workspace") .build() )); runtime.render(TuiScreenModel.builder() .cachedEvents(Arrays.asList(SessionEvent.builder() .eventId("evt-1") .type(SessionEventType.ASSISTANT_MESSAGE) .timestamp(1L) .payload(payload("kind", "reasoning", "output", "Inspecting the workspace")) .build())) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.COMPLETE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); Assert.assertEquals(1, countOccurrences(output, "Inspecting the workspace")); Assert.assertTrue(output.contains("Thinking: Inspecting the workspace")); } @Test public void shouldAppendMissingAssistantSuffixWithoutPrintingANewBlock() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.render(screenModel( TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.GENERATING) .text("Hello") .build() )); runtime.render(TuiScreenModel.builder() .cachedEvents(Arrays.asList(SessionEvent.builder() .eventId("evt-2") .type(SessionEventType.ASSISTANT_MESSAGE) .timestamp(2L) .payload(payload("kind", "assistant", "output", "Hello world")) .build())) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.COMPLETE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("Hello world")); Assert.assertFalse(output.contains("Hello\n\n• world")); } @Test public void shouldNotRepeatLocalCommandOutputInFooterPreview() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); runtime.render(TuiScreenModel.builder() .assistantOutput("No saved sessions found in ./.ai4j/memory-sessions") .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.IDLE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); Assert.assertEquals(1, countOccurrences(output, "No saved sessions found in ./.ai4j/memory-sessions")); } @Test public void shouldKeepReasoningTextAndToolResultInTranscriptOrder() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.render(screenModel( TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.THINKING) .reasoningText("Inspecting project files") .build() )); runtime.render(TuiScreenModel.builder() .cachedEvents(Arrays.asList( SessionEvent.builder() .eventId("evt-r") .type(SessionEventType.ASSISTANT_MESSAGE) .timestamp(1L) .payload(payload("kind", "reasoning", "output", "Inspecting project files")) .build(), SessionEvent.builder() .eventId("evt-a") .type(SessionEventType.ASSISTANT_MESSAGE) .timestamp(2L) .payload(payload("kind", "assistant", "output", "I will run the tests first.")) .build(), SessionEvent.builder() .eventId("evt-t") .type(SessionEventType.TOOL_RESULT) .timestamp(3L) .payload(payload( "tool", "bash", "title", "mvn test", "detail", "exit=0", "previewLines", Arrays.asList("BUILD SUCCESS"))) .build())) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.COMPLETE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); int reasoningIndex = output.indexOf("Thinking: Inspecting project files"); int textIndex = output.indexOf("I will run the tests first."); int toolIndex = output.indexOf("Ran mvn test"); Assert.assertTrue(reasoningIndex >= 0); Assert.assertTrue(textIndex > reasoningIndex); Assert.assertTrue(toolIndex > textIndex); } @Test public void shouldNotRedrawFooterWhenOnlyTheSameAppendOnlyStatusIsRenderedAgain() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); TuiScreenModel model = screenModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.THINKING) .phaseDetail("Streaming reasoning...") .updatedAtEpochMs(1000L) .build()); runtime.render(model); String firstRender = terminal.printed.toString(); runtime.render(model); Assert.assertEquals(firstRender, terminal.printed.toString()); } @Test public void shouldRenderStableErrorFooterWithoutSpinnerPrefix() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); runtime.render(screenModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.ERROR) .phaseDetail("Authentication failed") .updatedAtEpochMs(2000L) .build())); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("Error")); Assert.assertFalse(output.contains("- Error")); Assert.assertFalse(output.contains("/ Error")); Assert.assertFalse(output.contains("| Error")); Assert.assertFalse(output.contains("\\ Error")); } @Test public void shouldRenderStableThinkingFooterWithBulletPrefix() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); runtime.render(screenModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.THINKING) .phaseDetail("Thinking about: hello") .animationTick(2) .updatedAtEpochMs(2000L) .build())); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("• Thinking")); Assert.assertTrue(output.contains(": hello")); Assert.assertTrue(containsSpinnerFrame(output)); Assert.assertFalse(output.contains("- Thinking")); Assert.assertFalse(output.contains("/ Thinking")); Assert.assertFalse(output.contains("| Thinking")); Assert.assertFalse(output.contains("\\ Thinking")); Assert.assertFalse(output.contains("Thinking: Thinking about: hello")); } @Test public void shouldRenderBulletHeaderHintAndReadyFooter() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); runtime.render(TuiScreenModel.builder() .assistantOutput("Ask AI4J to inspect this repository\nOpen the command palette with /\nReplay recent history with Ctrl+R") .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.IDLE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("• Type / for commands, Enter to send")); Assert.assertTrue(output.contains("• Ask AI4J to inspect this repository")); Assert.assertTrue(output.contains("• Open the command palette with /")); Assert.assertTrue(output.contains("• Replay recent history with Ctrl+R")); Assert.assertTrue(output.contains("• Ready")); } @Test public void shouldKeepInitialHintAsBulletLinesEvenWhenSessionEventsAlreadyExist() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); runtime.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .eventId("evt-session-created") .type(SessionEventType.SESSION_CREATED) .timestamp(1L) .summary("session created") .build())) .assistantOutput("Ask AI4J to inspect this repository\nOpen the command palette with /\nReplay recent history with Ctrl+R") .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.IDLE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("• Ask AI4J to inspect this repository")); Assert.assertTrue(output.contains("• Open the command palette with /")); Assert.assertTrue(output.contains("• Replay recent history with Ctrl+R")); Assert.assertFalse(output.contains(" Open the command palette with /")); Assert.assertFalse(output.contains(" Replay recent history with Ctrl+R")); } @Test public void shouldRenderPaletteWithBulletItemsAndHeader() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); TuiInteractionState interaction = new TuiInteractionState(); interaction.openSlashPalette(Arrays.asList( new TuiPaletteItem("status", "command", "/status", "Show current session status", "/status"), new TuiPaletteItem("session", "command", "/session", "Show current session metadata", "/session") ), "/s"); runtime.render(TuiScreenModel.builder() .interactionState(interaction) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.IDLE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("• Commands: /s")); Assert.assertTrue(output.contains("• /status Show current session status")); Assert.assertTrue(output.contains("• /session Show current session metadata")); Assert.assertFalse(output.contains("> /status")); } @Test public void shouldRenderProcessEventsAsStructuredTranscriptBlocks() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .eventId("proc-1") .type(SessionEventType.PROCESS_UPDATED) .timestamp(1L) .summary("process updated: proc_demo (RUNNING)") .payload(payload( "processId", "proc_demo", "status", "RUNNING", "command", "python demo.py", "workingDirectory", "workspace" )) .build())) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.IDLE) .build()) .build()); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("• Process: proc_demo (RUNNING)")); Assert.assertTrue(output.contains("└ python demo.py")); Assert.assertTrue(output.contains("cwd workspace")); Assert.assertFalse(output.contains("• Process: process updated: proc_demo")); } @Test public void shouldReturnToColumnOneBeforeRedrawingFooterAfterLiveOutput() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); TuiInteractionState interaction = new TuiInteractionState(); interaction.replaceInputBuffer("status"); runtime.render(TuiScreenModel.builder() .interactionState(interaction) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.IDLE) .build()) .build()); runtime.render(TuiScreenModel.builder() .interactionState(interaction) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.GENERATING) .text("Hello") .build()) .build()); String raw = terminal.printed.toString(); Assert.assertTrue(raw.contains("Hello\r\u001b[2K")); } @Test public void shouldRenderWorkingSpinnerForPendingToolStatus() { RecordingTerminalIO terminal = new RecordingTerminalIO(); AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal); runtime.enter(); runtime.render(screenModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.WAITING_TOOL_RESULT) .animationTick(4) .tools(Collections.singletonList(TuiAssistantToolView.builder() .toolName("bash") .title("mvn test") .status("pending") .build())) .build())); String output = stripAnsi(terminal.printed.toString()); Assert.assertTrue(output.contains("• Working")); Assert.assertTrue(output.contains("Running mvn test")); Assert.assertTrue(containsSpinnerFrame(output)); } private static TuiScreenModel screenModel(TuiAssistantViewModel assistantViewModel) { return TuiScreenModel.builder() .assistantViewModel(assistantViewModel) .build(); } private static Map payload(Object... pairs) { Map payload = new LinkedHashMap(); if (pairs == null) { return payload; } for (int i = 0; i + 1 < pairs.length; i += 2) { payload.put(String.valueOf(pairs[i]), pairs[i + 1]); } return payload; } private static String stripAnsi(String value) { if (value == null) { return ""; } return value.replaceAll("\\u001B\\[[;\\d]*[ -/]*[@-~]", "") .replace("\r", ""); } private static int countOccurrences(String text, String needle) { if (text == null || needle == null || needle.isEmpty()) { return 0; } int count = 0; int index = 0; while (true) { index = text.indexOf(needle, index); if (index < 0) { return count; } count++; index += needle.length(); } } private static boolean containsSpinnerFrame(String text) { return text != null && (text.contains("⠋") || text.contains("⠙") || text.contains("⠹") || text.contains("⠸") || text.contains("⠼") || text.contains("⠴") || text.contains("⠦") || text.contains("⠧") || text.contains("⠇") || text.contains("⠏")); } private static final class RecordingTerminalIO implements TerminalIO { private final StringBuilder printed = new StringBuilder(); @Override public String readLine(String prompt) throws IOException { return null; } @Override public void print(String message) { printed.append(message == null ? "" : message); } @Override public void println(String message) { printed.append(message == null ? "" : message).append('\n'); } @Override public void errorln(String message) { } @Override public boolean supportsAnsi() { return true; } } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/StreamsTerminalIOTest.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.tui.io.DefaultStreamsTerminalIO; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.lang.reflect.Method; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public class StreamsTerminalIOTest { @Test public void shouldUseConfiguredCharsetForChineseInputAndOutput() throws Exception { String original = System.getProperty("ai4j.terminal.encoding"); System.setProperty("ai4j.terminal.encoding", "UTF-8"); try { ByteArrayInputStream in = new ByteArrayInputStream("中文输入\n".getBytes(StandardCharsets.UTF_8)); ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream err = new ByteArrayOutputStream(); StreamsTerminalIO terminal = new StreamsTerminalIO(in, out, err, false); String line = terminal.readLine("> "); terminal.println("中文输出"); terminal.errorln("错误输出"); String stdout = new String(out.toByteArray(), StandardCharsets.UTF_8); String stderr = new String(err.toByteArray(), StandardCharsets.UTF_8); Assert.assertEquals("中文输入", line); Assert.assertTrue(stdout.contains("> ")); Assert.assertTrue(stdout.contains("中文输出")); Assert.assertTrue(stderr.contains("错误输出")); } finally { if (original == null) { System.clearProperty("ai4j.terminal.encoding"); } else { System.setProperty("ai4j.terminal.encoding", original); } } } @Test public void shouldPreferUtf8HintsBeforeLegacyPlatformFallback() throws Exception { Method resolveCharset = DefaultStreamsTerminalIO.class.getDeclaredMethod( "resolveTerminalCharset", String[].class, String[].class, String[].class, boolean.class ); resolveCharset.setAccessible(true); Charset charset = (Charset) resolveCharset.invoke( null, new Object[]{ new String[0], new String[0], new String[]{"GBK", "GBK"}, true } ); Assert.assertEquals(StandardCharsets.UTF_8, charset); } @Test public void shouldDrainBufferedScriptedKeyStrokesWithTimeoutReads() throws Exception { ByteArrayInputStream in = new ByteArrayInputStream(new byte[]{16, 'e', 'x', 'i', 't', '\n'}); StreamsTerminalIO terminal = new StreamsTerminalIO( in, new ByteArrayOutputStream(), new ByteArrayOutputStream(), false ); Assert.assertEquals(TuiKeyType.CTRL_P, terminal.readKeyStroke(150L).getType()); Assert.assertEquals("e", terminal.readKeyStroke(150L).getText()); Assert.assertEquals("x", terminal.readKeyStroke(150L).getText()); Assert.assertEquals("i", terminal.readKeyStroke(150L).getText()); Assert.assertEquals("t", terminal.readKeyStroke(150L).getText()); Assert.assertEquals(TuiKeyType.ENTER, terminal.readKeyStroke(150L).getType()); Assert.assertNull(terminal.readKeyStroke(10L)); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/TuiConfigManagerTest.java ================================================ package io.github.lnyocly.ai4j.tui; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class TuiConfigManagerTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldLoadBuiltInThemesAndPersistWorkspaceConfig() throws Exception { Path workspace = temporaryFolder.newFolder("workspace").toPath(); TuiConfigManager manager = new TuiConfigManager(workspace); List themeNames = manager.listThemeNames(); TuiTheme ocean = manager.resolveTheme("ocean"); TuiTheme defaults = manager.resolveTheme("default"); TuiTheme githubDark = manager.resolveTheme("github-dark"); TuiConfig saved = manager.switchTheme("matrix"); TuiConfig loaded = manager.load(null); assertTrue(themeNames.contains("default")); assertTrue(themeNames.contains("ocean")); assertTrue(themeNames.contains("github-dark")); assertTrue(themeNames.contains("github-light")); assertNotNull(ocean); assertEquals("ocean", ocean.getName()); assertEquals("#161b22", defaults.getCodeBackground()); assertEquals("#30363d", defaults.getCodeBorder()); assertEquals("#c9d1d9", defaults.getCodeText()); assertEquals("#ff7b72", defaults.getCodeKeyword()); assertEquals("#a5d6ff", defaults.getCodeString()); assertEquals("#8b949e", defaults.getCodeComment()); assertEquals("#79c0ff", defaults.getCodeNumber()); assertEquals("github-dark", githubDark.getName()); assertEquals("#161b22", githubDark.getCodeBackground()); assertEquals("matrix", saved.getTheme()); assertEquals("matrix", loaded.getTheme()); assertTrue(Files.exists(workspace.resolve(".ai4j").resolve("tui.json"))); } @Test public void shouldPreferWorkspaceThemeOverrides() throws Exception { Path workspace = temporaryFolder.newFolder("workspace-override").toPath(); Path themesDir = workspace.resolve(".ai4j").resolve("themes"); Files.createDirectories(themesDir); Files.write(themesDir.resolve("custom.json"), ("{\n" + " \"name\": \"custom\",\n" + " \"brand\": \"#112233\",\n" + " \"accent\": \"#445566\"\n" + "}").getBytes(StandardCharsets.UTF_8)); TuiConfigManager manager = new TuiConfigManager(workspace); TuiTheme custom = manager.resolveTheme("custom"); assertEquals("custom", custom.getName()); assertEquals("#112233", custom.getBrand()); assertEquals("#161b22", custom.getCodeBackground()); assertEquals("#ff7b72", custom.getCodeKeyword()); assertEquals("#79c0ff", custom.getCodeNumber()); assertTrue(manager.listThemeNames().contains("custom")); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/TuiInteractionStateTest.java ================================================ package io.github.lnyocly.ai4j.tui; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.concurrent.atomic.AtomicInteger; public class TuiInteractionStateTest { @Test public void shouldConsumeInputSilentlyWithoutTriggeringRender() { TuiInteractionState state = new TuiInteractionState(); AtomicInteger renders = new AtomicInteger(); state.setRenderCallback(new Runnable() { @Override public void run() { renders.incrementAndGet(); } }); state.appendInput("hello"); Assert.assertEquals(1, renders.get()); String value = state.consumeInputBufferSilently(); Assert.assertEquals("hello", value); Assert.assertEquals(1, renders.get()); Assert.assertEquals("", state.getInputBuffer()); } @Test public void shouldSkipRenderWhenTranscriptScrollAlreadyAtZero() { TuiInteractionState state = new TuiInteractionState(); AtomicInteger renders = new AtomicInteger(); state.setRenderCallback(new Runnable() { @Override public void run() { renders.incrementAndGet(); } }); state.resetTranscriptScroll(); Assert.assertEquals(0, renders.get()); state.moveTranscriptScroll(1); Assert.assertEquals(1, renders.get()); state.resetTranscriptScroll(); Assert.assertEquals(2, renders.get()); } @Test public void shouldNormalizeSlashPaletteQueryWithoutLeadingSlash() { TuiInteractionState state = new TuiInteractionState(); state.openSlashPalette(Arrays.asList( new TuiPaletteItem("status", "command", "/status", "Show current session status", "/status"), new TuiPaletteItem("session", "command", "/session", "Show current session metadata", "/session") ), "/s"); Assert.assertEquals("s", state.getPaletteQuery()); Assert.assertEquals(2, state.getPaletteItems().size()); } @Test public void shouldAppendSlashInputAndOpenPaletteInSingleRender() { TuiInteractionState state = new TuiInteractionState(); AtomicInteger renders = new AtomicInteger(); state.setRenderCallback(new Runnable() { @Override public void run() { renders.incrementAndGet(); } }); state.appendInputAndSyncSlashPalette("/", Arrays.asList( new TuiPaletteItem("help", "command", "/help", "Show help", "/help"), new TuiPaletteItem("status", "command", "/status", "Show current session status", "/status") )); Assert.assertEquals(1, renders.get()); Assert.assertEquals("/", state.getInputBuffer()); Assert.assertTrue(state.isPaletteOpen()); Assert.assertEquals(TuiInteractionState.PaletteMode.SLASH, state.getPaletteMode()); Assert.assertEquals("", state.getPaletteQuery()); } @Test public void shouldReplaceSlashSelectionAndClosePaletteInSingleRender() { TuiInteractionState state = new TuiInteractionState(); AtomicInteger renders = new AtomicInteger(); state.setRenderCallback(new Runnable() { @Override public void run() { renders.incrementAndGet(); } }); state.openSlashPalette(Arrays.asList( new TuiPaletteItem("status", "command", "/status", "Show current session status", "/status") ), "/s"); renders.set(0); state.replaceInputBufferAndClosePalette("/status"); Assert.assertEquals(1, renders.get()); Assert.assertEquals("/status", state.getInputBuffer()); Assert.assertFalse(state.isPaletteOpen()); } } ================================================ FILE: ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/TuiSessionViewTest.java ================================================ package io.github.lnyocly.ai4j.tui; import io.github.lnyocly.ai4j.coding.CodingSessionSnapshot; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.process.BashProcessStatus; import io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor; import io.github.lnyocly.ai4j.coding.session.SessionEvent; import io.github.lnyocly.ai4j.coding.session.SessionEventType; import org.junit.Assert; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; public class TuiSessionViewTest { @Test public void shouldRenderReplayViewerOverlay() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); view.setCachedReplay(Arrays.asList( "you> first", "assistant> second", "", "you> third" )); state.openReplayViewer(); state.moveReplayScroll(2); String rendered = view.render(null, TuiRenderContext.builder().model("glm-4.5-flash").build(), state); Assert.assertTrue(rendered.contains("History")); Assert.assertTrue(rendered.contains("• third")); Assert.assertTrue(rendered.contains("↑/↓ scroll Esc close")); Assert.assertFalse(rendered.contains("USER_MESSAGE")); Assert.assertFalse(rendered.contains("you>")); Assert.assertFalse(rendered.contains("assistant>")); Assert.assertTrue(rendered.contains("third")); } @Test public void shouldRenderTeamBoardOverlay() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); java.util.List boardLines = Arrays.asList( "summary tasks=2 running=1 completed=1 failed=0 blocked=0 members=2", "", "lane reviewer", " [running/heartbeat 15%] Review this patch (review)", " Heartbeat from reviewer.", " heartbeats: 2", " messages:", " - [task.assigned] system -> reviewer | task=review | Review this patch", "", "lane planner", " [completed/completed 100%] Build final summary (plan)" ); state.openTeamBoard(); state.moveTeamBoardScroll(1); String rendered = view.render(TuiScreenModel.builder() .interactionState(state) .cachedTeamBoard(boardLines) .build()); Assert.assertTrue(rendered.contains("Team Board")); Assert.assertTrue(rendered.contains("lane reviewer")); Assert.assertTrue(rendered.contains("running/heartbeat 15%")); Assert.assertTrue(rendered.contains("task.assigned")); Assert.assertTrue(rendered.contains("↑/↓ scroll Esc close")); } @Test public void shouldRenderProcessInspectorOverlay() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); state.openProcessInspector("proc_demo"); state.appendProcessInput("npm run dev"); view.setProcessInspector( BashProcessInfo.builder() .processId("proc_demo") .command("npm run dev") .workingDirectory(".") .status(BashProcessStatus.RUNNING) .controlAvailable(true) .build(), BashProcessLogChunk.builder() .processId("proc_demo") .offset(0L) .nextOffset(20L) .content("[stdout] ready\n") .status(BashProcessStatus.RUNNING) .build() ); String rendered = view.render(null, TuiRenderContext.builder().model("glm-4.5-flash").build(), state); Assert.assertTrue(rendered.contains("Process proc_demo")); Assert.assertTrue(rendered.contains("status running")); Assert.assertTrue(rendered.contains("proc_demo")); Assert.assertTrue(rendered.contains("stdin> npm run dev")); Assert.assertTrue(rendered.contains("ready")); } @Test public void shouldRenderSlashCommandOverlay() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); state.replaceInputBuffer("/re"); state.openSlashPalette(Arrays.asList( new TuiPaletteItem("replay", "command", "/replay", "Replay recent turns", "/replay 20"), new TuiPaletteItem("resume", "command", "/resume", "Resume a saved session", "/resume") ), "/re"); String rendered = view.render(TuiScreenModel.builder() .interactionState(state) .build()); int composerIndex = rendered.indexOf("> /re"); int replayIndex = rendered.lastIndexOf("/replay 20"); Assert.assertTrue(rendered.contains("> /replay 20")); Assert.assertTrue(rendered.contains("/replay 20")); Assert.assertTrue(rendered.contains("/resume")); Assert.assertTrue(replayIndex > composerIndex); } @Test public void shouldScrollSlashCommandWindowWithSelection() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); state.replaceInputBuffer("/"); state.openSlashPalette(Arrays.asList( new TuiPaletteItem("one", "command", "/one", "One", "/one"), new TuiPaletteItem("two", "command", "/two", "Two", "/two"), new TuiPaletteItem("three", "command", "/three", "Three", "/three"), new TuiPaletteItem("four", "command", "/four", "Four", "/four"), new TuiPaletteItem("five", "command", "/five", "Five", "/five"), new TuiPaletteItem("six", "command", "/six", "Six", "/six"), new TuiPaletteItem("seven", "command", "/seven", "Seven", "/seven"), new TuiPaletteItem("eight", "command", "/eight", "Eight", "/eight"), new TuiPaletteItem("nine", "command", "/nine", "Nine", "/nine"), new TuiPaletteItem("ten", "command", "/ten", "Ten", "/ten") ), "/"); for (int i = 0; i < 8; i++) { state.movePaletteSelection(1); } String rendered = view.render(TuiScreenModel.builder() .interactionState(state) .build()); Assert.assertTrue(rendered.contains("> /nine")); Assert.assertTrue(rendered.contains("/ten")); Assert.assertTrue(rendered.contains("...")); Assert.assertFalse(rendered.contains("> /one")); } @Test public void shouldRenderStructuredAssistantTrace() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); String rendered = view.render(TuiScreenModel.builder() .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.WAITING_TOOL_RESULT) .step(1) .phaseDetail("Running command...") .reasoningText("Thinking through the workspace.") .text("Scanning the workspace.\nPreparing the next action.") .tools(Arrays.asList( TuiAssistantToolView.builder() .toolName("bash") .status("done") .title("$ type sample.txt") .detail("exit=0 | cwd=workspace") .previewLines(Arrays.asList("hello-cli", "done")) .build())) .build()) .build()); Assert.assertTrue(rendered.contains("running command...")); Assert.assertTrue(rendered.contains("Thinking through the workspace.")); Assert.assertTrue(rendered.contains("Scanning the workspace.")); Assert.assertTrue(rendered.contains("• Ran type sample.txt")); Assert.assertTrue(rendered.contains("└ hello-cli")); Assert.assertFalse(rendered.contains("exit=0 | cwd=workspace")); } @Test public void shouldRenderMinimalHeader() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); state.openReplayViewer(); String rendered = view.render(TuiScreenModel.builder() .descriptor(CodingSessionDescriptor.builder() .sessionId("session-alpha") .rootSessionId("session-alpha") .provider("zhipu") .protocol("chat") .model("glm-4.5-flash") .workspace("workspace") .build()) .snapshot(CodingSessionSnapshot.builder() .sessionId("session-alpha") .estimatedContextTokens(512) .lastCompactMode("manual") .build()) .cachedReplay(Arrays.asList( "you> first", "assistant> second" )) .interactionState(state) .build()); Assert.assertTrue(rendered.contains("AI4J")); Assert.assertTrue(rendered.contains("glm-4.5-flash")); Assert.assertTrue(rendered.contains("workspace")); Assert.assertFalse(rendered.contains("focus=")); Assert.assertFalse(rendered.contains("overlay=")); } @Test public void shouldRenderStartupTipsWhenIdle() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); String rendered = view.render(TuiScreenModel.builder() .build()); Assert.assertTrue(rendered.contains("Ask AI4J to inspect this repository")); Assert.assertTrue(rendered.contains("Type `/` for commands")); Assert.assertTrue(rendered.contains("Ctrl+R")); Assert.assertFalse(rendered.contains("SYSTEM")); } @Test public void shouldNotDuplicateAssistantOutputWhenLatestEventMatchesLiveText() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map payload = new HashMap(); payload.put("output", "Line one\nLine two"); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .type(SessionEventType.ASSISTANT_MESSAGE) .summary("Line one Line two") .payload(payload) .build())) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.COMPLETE) .text("Line one\nLine two") .build()) .build()); Assert.assertFalse(rendered.contains("assistant>")); Assert.assertEquals(1, countOccurrences(rendered, "Line one")); Assert.assertTrue(rendered.contains("Line two")); } @Test public void shouldRenderAssistantMarkdownCodeBlocks() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); String rendered = view.render(TuiScreenModel.builder() .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.COMPLETE) .text("## Files\n- src/main/java\n> note\nUse `rg` and **fast path**\n```java\nSystem.out.println(\"hi\");\n```") .build()) .build()); Assert.assertFalse(rendered.contains("assistant>")); Assert.assertTrue(rendered.contains("## Files")); Assert.assertTrue(rendered.contains("- src/main/java")); Assert.assertTrue(rendered.contains("> note")); Assert.assertTrue(rendered.contains("Use rg and fast path")); Assert.assertTrue(rendered.contains("System.out.println(\"hi\");")); Assert.assertFalse(rendered.contains("+-- code: java")); Assert.assertFalse(rendered.contains("| System.out.println(\"hi\");")); } @Test public void shouldRenderTranscriptInEventOrderWithPersistentToolEntries() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map toolCall = new HashMap(); toolCall.put("tool", "bash"); toolCall.put("callId", "call-bash"); toolCall.put("arguments", "{\"action\":\"exec\",\"command\":\"type sample.txt\"}"); Map toolResult = new HashMap(); toolResult.put("tool", "bash"); toolResult.put("callId", "call-bash"); toolResult.put("arguments", "{\"action\":\"exec\",\"command\":\"type sample.txt\"}"); toolResult.put("output", "{\"exitCode\":0,\"workingDirectory\":\"workspace\",\"stdout\":\"hello-cli\"}"); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Arrays.asList( SessionEvent.builder() .type(SessionEventType.USER_MESSAGE) .payload(Collections.singletonMap("input", "inspect sample")) .build(), SessionEvent.builder() .type(SessionEventType.ASSISTANT_MESSAGE) .payload(Collections.singletonMap("output", "I will inspect the file first.")) .build(), SessionEvent.builder() .type(SessionEventType.TOOL_CALL) .payload(toolCall) .build(), SessionEvent.builder() .type(SessionEventType.TOOL_RESULT) .payload(toolResult) .build(), SessionEvent.builder() .type(SessionEventType.ASSISTANT_MESSAGE) .payload(Collections.singletonMap("output", "The file contains hello-cli.")) .build())) .build()); int firstAssistant = rendered.indexOf("I will inspect the file first."); int toolResultIndex = rendered.indexOf("• Ran type sample.txt"); int secondAssistant = rendered.indexOf("The file contains hello-cli."); Assert.assertTrue(firstAssistant >= 0); Assert.assertTrue(rendered.contains("• inspect sample\n\n• I will inspect the file first.")); Assert.assertFalse(rendered.contains("you> inspect sample")); Assert.assertFalse(rendered.contains("• Running type sample.txt")); Assert.assertTrue(toolResultIndex > firstAssistant); Assert.assertTrue(secondAssistant > toolResultIndex); Assert.assertTrue(rendered.contains("└ hello-cli")); Assert.assertFalse(rendered.contains("exit=0")); } @Test public void shouldPreferPersistedToolMetadataWhenArgumentsAreClipped() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map toolCall = new HashMap(); toolCall.put("tool", "bash"); toolCall.put("callId", "call-python"); toolCall.put("arguments", "{\"action\":\"exec\""); toolCall.put("title", "$ python -c \"print(1)\""); toolCall.put("detail", "Running command..."); toolCall.put("previewLines", Collections.singletonList("cwd> workspace")); Map toolResult = new HashMap(); toolResult.put("tool", "bash"); toolResult.put("callId", "call-python"); toolResult.put("arguments", "{\"action\":\"exec\""); toolResult.put("output", "{\"exitCode\":0"); toolResult.put("title", "$ python -c \"print(1)\""); toolResult.put("detail", "exit=0 | cwd=workspace"); toolResult.put("previewLines", Collections.singletonList("1")); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Arrays.asList( SessionEvent.builder() .type(SessionEventType.TOOL_CALL) .payload(toolCall) .build(), SessionEvent.builder() .type(SessionEventType.TOOL_RESULT) .payload(toolResult) .build())) .build()); Assert.assertFalse(rendered.contains("• Running python -c \"print(1)\"")); Assert.assertTrue(rendered.contains("• Ran python -c \"print(1)\"")); Assert.assertFalse(rendered.contains("exit=0 | cwd=workspace")); Assert.assertTrue(rendered.contains("└ 1")); Assert.assertFalse(rendered.contains("(empty command)")); } @Test public void shouldIndentMultilineToolPreviewOutput() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map toolResult = new HashMap(); toolResult.put("tool", "bash"); toolResult.put("callId", "call-date"); toolResult.put("title", "$ date /t && time /t"); toolResult.put("previewLines", Collections.singletonList("2026/03/21 Sat\n21:18")); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .type(SessionEventType.TOOL_RESULT) .payload(toolResult) .build())) .build()); Assert.assertTrue(rendered.contains("• Ran date /t && time /t")); Assert.assertTrue(rendered.contains("└ 2026/03/21 Sat")); Assert.assertTrue(rendered.contains("\n 21:18")); } @Test public void shouldAllowScrollingFullTranscriptWithoutTruncatingAssistantLines() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); StringBuilder assistantText = new StringBuilder(); for (int i = 1; i <= 30; i++) { if (assistantText.length() > 0) { assistantText.append('\n'); } assistantText.append("line ").append(i); } ArrayList events = new ArrayList(); events.add(SessionEvent.builder() .type(SessionEventType.ASSISTANT_MESSAGE) .payload(Collections.singletonMap("output", assistantText.toString())) .build()); String bottomRendered = view.render(TuiScreenModel.builder() .cachedEvents(events) .interactionState(state) .build()); Assert.assertTrue(bottomRendered.contains("line 30")); Assert.assertFalse(bottomRendered.contains(" ...")); state.moveTranscriptScroll(6); String scrolledRendered = view.render(TuiScreenModel.builder() .cachedEvents(events) .interactionState(state) .build()); Assert.assertTrue(scrolledRendered.contains("line 1")); Assert.assertTrue(scrolledRendered.contains("line 24")); Assert.assertFalse(scrolledRendered.contains("line 30")); Assert.assertFalse(scrolledRendered.contains("assistant>")); } @Test public void shouldUseTerminalHeightToSizeTranscriptViewport() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); TuiInteractionState state = new TuiInteractionState(); StringBuilder assistantText = new StringBuilder(); for (int i = 1; i <= 30; i++) { if (assistantText.length() > 0) { assistantText.append('\n'); } assistantText.append("line ").append(i); } ArrayList events = new ArrayList(); events.add(SessionEvent.builder() .type(SessionEventType.ASSISTANT_MESSAGE) .payload(Collections.singletonMap("output", assistantText.toString())) .build()); TuiRenderContext context = TuiRenderContext.builder() .model("glm-4.5-flash") .terminalRows(14) .build(); String bottomRendered = view.render(TuiScreenModel.builder() .cachedEvents(events) .interactionState(state) .renderContext(context) .build()); Assert.assertTrue(bottomRendered.contains("line 23")); Assert.assertTrue(bottomRendered.contains("line 30")); Assert.assertFalse(bottomRendered.contains("line 22")); state.moveTranscriptScroll(3); String scrolledRendered = view.render(TuiScreenModel.builder() .cachedEvents(events) .interactionState(state) .renderContext(context) .build()); Assert.assertTrue(scrolledRendered.contains("line 20")); Assert.assertTrue(scrolledRendered.contains("line 27")); Assert.assertFalse(scrolledRendered.contains("line 19")); Assert.assertFalse(scrolledRendered.contains("line 28")); } @Test public void shouldRenderLiveToolImmediatelyEvenWhenHistoryContainsOlderToolEvents() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map oldToolCall = new HashMap(); oldToolCall.put("tool", "bash"); oldToolCall.put("callId", "old-call"); oldToolCall.put("arguments", "{\"action\":\"exec\",\"command\":\"pwd\"}"); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .type(SessionEventType.TOOL_CALL) .payload(oldToolCall) .build())) .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.WAITING_TOOL_RESULT) .tools(Collections.singletonList(TuiAssistantToolView.builder() .callId("current-call") .toolName("bash") .status("pending") .title("$ date") .detail("Running command...") .build())) .build()) .build()); Assert.assertTrue(rendered.contains("• Running date")); Assert.assertFalse(rendered.contains("running command `date`")); } @Test public void shouldShowInvalidBashCallWithoutEmptyCommandFallback() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map toolResult = new HashMap(); toolResult.put("tool", "bash"); toolResult.put("callId", "call-bash-invalid"); toolResult.put("arguments", "{\"action\":\"exec\"}"); toolResult.put("output", "TOOL_ERROR: {\"error\":\"bash exec requires a non-empty command\"}"); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .type(SessionEventType.TOOL_RESULT) .payload(toolResult) .build())) .build()); Assert.assertTrue(rendered.contains("• Command failed bash exec")); Assert.assertTrue(rendered.contains("bash exec requires a non-empty command")); Assert.assertFalse(rendered.contains("(empty command)")); } @Test public void shouldHighlightAnsiCodeBlocks() { TuiTheme theme = new TuiTheme(); theme.setText("#eeeeee"); theme.setMuted("#778899"); theme.setBrand("#3366ff"); theme.setAccent("#ff9900"); theme.setSuccess("#33cc99"); theme.setPanelBorder("#445566"); theme.setCodeBackground("#111827"); theme.setCodeBorder("#445566"); theme.setCodeText("#eeeeee"); theme.setCodeKeyword("#3366ff"); theme.setCodeString("#33cc99"); theme.setCodeComment("#778899"); theme.setCodeNumber("#ff9900"); TuiSessionView view = new TuiSessionView(new TuiConfig(), theme, true); String rendered = view.render(TuiScreenModel.builder() .assistantViewModel(TuiAssistantViewModel.builder() .phase(TuiAssistantPhase.COMPLETE) .text("```java\npublic class Demo { String value = \"hi\"; int count = 42; // note\n}\n```") .build()) .build()); Assert.assertFalse(rendered.contains("+-- code: java")); Assert.assertTrue(rendered.contains("\u001b[1;38;2;51;102;255;48;2;17;24;39mpublic")); Assert.assertTrue(rendered.contains("\u001b[38;2;51;204;153;48;2;17;24;39m\"hi\"")); Assert.assertTrue(rendered.contains("\u001b[38;2;255;153;0;48;2;17;24;39m42")); Assert.assertTrue(rendered.contains("\u001b[38;2;119;136;153;48;2;17;24;39m// note")); } @Test public void shouldRenderCodexStyleToolLabelsWithoutMetadataNoise() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map statusResult = new HashMap(); statusResult.put("tool", "bash"); statusResult.put("callId", "call-status"); statusResult.put("arguments", "{\"action\":\"status\",\"processId\":\"proc_demo\"}"); statusResult.put("output", "{\"processId\":\"proc_demo\",\"status\":\"RUNNING\",\"command\":\"python demo.py\",\"workingDirectory\":\"workspace\"}"); Map logsResult = new HashMap(); logsResult.put("tool", "bash"); logsResult.put("callId", "call-logs"); logsResult.put("arguments", "{\"action\":\"logs\",\"processId\":\"proc_demo\"}"); logsResult.put("output", "{\"content\":\"line 1\\nline 2\",\"status\":\"RUNNING\"}"); Map writeResult = new HashMap(); writeResult.put("tool", "bash"); writeResult.put("callId", "call-write"); writeResult.put("arguments", "{\"action\":\"write\",\"processId\":\"proc_demo\",\"input\":\"status\\n\"}"); writeResult.put("output", "{\"bytesWritten\":7,\"process\":{\"processId\":\"proc_demo\",\"status\":\"RUNNING\"}}"); Map patchResult = new HashMap(); patchResult.put("tool", "apply_patch"); patchResult.put("callId", "call-patch"); patchResult.put("arguments", "{\"patch\":\"*** Begin Patch\\n*** End Patch\\n\"}"); patchResult.put("output", "{\"filesChanged\":0,\"operationsApplied\":0,\"changedFiles\":[]}"); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Arrays.asList( SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(statusResult).build(), SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(logsResult).build(), SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(writeResult).build(), SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(patchResult).build())) .build()); Assert.assertTrue(rendered.contains("• Checked proc_demo")); Assert.assertTrue(rendered.contains("└ python demo.py")); Assert.assertTrue(rendered.contains("• Read logs proc_demo")); Assert.assertTrue(rendered.contains("└ line 1")); Assert.assertTrue(rendered.contains("• Wrote to proc_demo")); Assert.assertTrue(rendered.contains("• Applied patch")); Assert.assertFalse(rendered.contains("cwd>")); Assert.assertFalse(rendered.contains("process=")); Assert.assertFalse(rendered.contains("bytes=7")); Assert.assertFalse(rendered.contains("files=0 | ops=0")); Assert.assertFalse(rendered.contains("(no changed files)")); } @Test public void shouldRenderTeamMessageEventsAsNotes() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map payload = new HashMap(); payload.put("taskId", "review"); payload.put("content", "Please double-check the auth diff."); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .type(SessionEventType.TEAM_MESSAGE) .summary("Team message reviewer -> lead [peer.ask]") .payload(payload) .build())) .build()); Assert.assertTrue(rendered.contains("Team message reviewer -> lead [peer.ask]")); Assert.assertTrue(rendered.contains("task: review")); Assert.assertTrue(rendered.contains("Please double-check the auth diff.")); } @Test public void shouldRenderTeamTaskProgressMetadata() { TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false); Map payload = new HashMap(); payload.put("title", "Team task review"); payload.put("detail", "Heartbeat from reviewer."); payload.put("memberName", "Reviewer"); payload.put("status", "running"); payload.put("phase", "heartbeat"); payload.put("percent", Integer.valueOf(15)); payload.put("heartbeatCount", Integer.valueOf(2)); String rendered = view.render(TuiScreenModel.builder() .cachedEvents(Collections.singletonList(SessionEvent.builder() .type(SessionEventType.TASK_UPDATED) .summary("Team task review [running]") .payload(payload) .build())) .build()); Assert.assertTrue(rendered.contains("Team task review [running]")); Assert.assertTrue(rendered.contains("Heartbeat from reviewer.")); Assert.assertTrue(rendered.contains("member: Reviewer")); Assert.assertTrue(rendered.contains("status: running | phase: heartbeat | progress: 15%")); Assert.assertTrue(rendered.contains("heartbeats: 2")); } private int countOccurrences(String text, String needle) { int count = 0; int index = 0; while (text != null && needle != null && (index = text.indexOf(needle, index)) >= 0) { count++; index += needle.length(); } return count; } } ================================================ FILE: ai4j-coding/pom.xml ================================================ 4.0.0 io.github.lnyo-cly ai4j-sdk 2.3.0 ai4j-coding jar ai4j-coding ai4j Coding Agent 运行时模块,提供工作区感知工具、outer loop 与 compaction。 Coding agent runtime for workspace-aware tools, outer loops, and compaction. The Apache License, Version 2.0 https://www.apache.org/licenses/LICENSE-2.0.txt GitHub https://github.com/LnYo-Cly/ai4j/issues https://github.com/LnYo-Cly/ai4j LnYo-Cly LnYo-Cly lnyocly@gmail.com https://github.com/LnYo-Cly/ai4j +8 https://github.com/LnYo-Cly/ai4j scm:git:https://github.com/LnYo-Cly/ai4j.git scm:git:https://github.com/LnYo-Cly/ai4j.git io.github.lnyo-cly ai4j ${project.version} io.github.lnyo-cly ai4j-agent ${project.version} com.alibaba.fastjson2 fastjson2 2.0.43 junit junit 4.13.2 test release org.codehaus.mojo flatten-maven-plugin ${flatten-maven-plugin.version} ossrh flatten process-resources flatten flatten-clean clean clean org.apache.maven.plugins maven-source-plugin 3.3.1 attach-sources jar-no-fork org.apache.maven.plugins maven-javadoc-plugin 3.6.3 attach-javadocs jar none false Author a Author: Description a Description: Date a Date: org.apache.maven.plugins maven-gpg-plugin 1.6 D:\Develop\DevelopEnv\GnuPG\bin\gpg.exe cly sign-artifacts verify sign org.sonatype.central central-publishing-maven-plugin 0.4.0 true LnYo-Cly true ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgent.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.runtime.CodingRuntime; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.task.CodingTask; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.util.List; import java.util.UUID; public class CodingAgent { private final Agent delegate; private final WorkspaceContext workspaceContext; private final CodingAgentOptions options; private final AgentToolRegistry customToolRegistry; private final ToolExecutor customToolExecutor; private final CodingRuntime runtime; private final SubAgentRegistry subAgentRegistry; private final HandoffPolicy handoffPolicy; public CodingAgent(Agent delegate, WorkspaceContext workspaceContext, CodingAgentOptions options, AgentToolRegistry customToolRegistry, ToolExecutor customToolExecutor, CodingRuntime runtime, SubAgentRegistry subAgentRegistry, HandoffPolicy handoffPolicy) { this.delegate = delegate; this.workspaceContext = workspaceContext; this.options = options; this.customToolRegistry = customToolRegistry; this.customToolExecutor = customToolExecutor; this.runtime = runtime; this.subAgentRegistry = subAgentRegistry; this.handoffPolicy = handoffPolicy; } public CodingAgentResult run(String input) throws Exception { try (CodingSession session = newSession()) { return session.run(input); } } public CodingAgentResult run(CodingAgentRequest request) throws Exception { try (CodingSession session = newSession()) { return session.run(request); } } public CodingAgentResult runStream(CodingAgentRequest request, AgentListener listener) throws Exception { try (CodingSession session = newSession()) { return session.runStream(request, listener); } } public CodingSession newSession() { return newSession((String) null, null); } public CodingSession newSession(CodingSessionState state) { return newSession(state == null ? null : state.getSessionId(), state); } public CodingSession newSession(String sessionId, CodingSessionState state) { AgentSession rawSession = delegate.newSession(); SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, options); CodingAgentDefinitionRegistry definitionRegistry = getDefinitionRegistry(); AgentToolRegistry builtInRegistry = CodingAgentBuilder.createBuiltInRegistry(options, definitionRegistry); ToolExecutor builtInExecutor = CodingAgentBuilder.createBuiltInToolExecutor( workspaceContext, options, processRegistry, runtime, definitionRegistry ); AgentToolRegistry mergedBaseRegistry = CodingAgentBuilder.mergeToolRegistry( builtInRegistry, customToolRegistry ); ToolExecutor mergedBaseExecutor = CodingAgentBuilder.mergeToolExecutor( builtInRegistry, builtInExecutor, customToolRegistry, customToolExecutor ); AgentToolRegistry mergedRegistry = CodingAgentBuilder.mergeSubAgentToolRegistry( mergedBaseRegistry, subAgentRegistry ); ToolExecutor mergedExecutor = CodingAgentBuilder.mergeSubAgentToolExecutor( mergedBaseExecutor, subAgentRegistry, handoffPolicy ); AgentSession session = new AgentSession( rawSession.getRuntime(), rawSession.getContext().toBuilder() .toolRegistry(mergedRegistry) .toolExecutor(mergedExecutor) .build() ); CodingSession codingSession = new CodingSession( isBlank(sessionId) ? UUID.randomUUID().toString() : sessionId, session, workspaceContext, options, processRegistry, runtime ); if (state != null) { codingSession.restore(state); } return codingSession; } public Agent getDelegate() { return delegate; } public WorkspaceContext getWorkspaceContext() { return workspaceContext; } public CodingAgentOptions getOptions() { return options; } public CodingRuntime getRuntime() { return runtime; } public CodingAgentDefinitionRegistry getDefinitionRegistry() { return runtime == null ? null : runtime.getDefinitionRegistry(); } public CodingDelegateResult delegate(CodingDelegateRequest request) throws Exception { try (CodingSession session = newSession()) { return session.delegate(request); } } public CodingTask getTask(String taskId) { return runtime == null ? null : runtime.getTask(taskId); } public List listTasks() { return runtime == null ? java.util.Collections.emptyList() : runtime.listTasks(); } public List listTasks(String parentSessionId) { return runtime == null ? java.util.Collections.emptyList() : runtime.listTasksByParentSessionId(parentSessionId); } public List listSessionLinks(String parentSessionId) { return runtime == null ? java.util.Collections.emptyList() : runtime.listSessionLinks(parentSessionId); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilder.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentBuilder; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRuntime; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.StaticSubAgentRegistry; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry; import io.github.lnyocly.ai4j.agent.subagent.SubAgentToolExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.definition.BuiltInCodingAgentDefinitions; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateToolExecutor; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateToolRegistry; import io.github.lnyocly.ai4j.coding.policy.CodingToolPolicyResolver; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.prompt.CodingContextPromptAssembler; import io.github.lnyocly.ai4j.coding.runtime.CodingRuntime; import io.github.lnyocly.ai4j.coding.runtime.DefaultCodingRuntime; import io.github.lnyocly.ai4j.coding.session.CodingSessionLinkStore; import io.github.lnyocly.ai4j.coding.session.InMemoryCodingSessionLinkStore; import io.github.lnyocly.ai4j.coding.skill.CodingSkillDiscovery; import io.github.lnyocly.ai4j.coding.task.CodingTaskManager; import io.github.lnyocly.ai4j.coding.task.InMemoryCodingTaskManager; import io.github.lnyocly.ai4j.coding.tool.ApplyPatchToolExecutor; import io.github.lnyocly.ai4j.coding.tool.BashToolExecutor; import io.github.lnyocly.ai4j.coding.tool.CodingToolRegistryFactory; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.tool.ReadFileToolExecutor; import io.github.lnyocly.ai4j.coding.tool.RoutingToolExecutor; import io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator; import io.github.lnyocly.ai4j.coding.tool.WriteFileToolExecutor; import io.github.lnyocly.ai4j.coding.workspace.LocalWorkspaceFileService; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileService; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; public class CodingAgentBuilder { private AgentRuntime runtime; private AgentModelClient modelClient; private WorkspaceContext workspaceContext; private AgentOptions agentOptions; private CodingAgentOptions codingOptions; private AgentToolRegistry toolRegistry; private ToolExecutor toolExecutor; private CodingAgentDefinitionRegistry definitionRegistry; private SubAgentRegistry subAgentRegistry; private HandoffPolicy handoffPolicy; private final List subAgentDefinitions = new ArrayList(); private CodingTaskManager taskManager; private CodingSessionLinkStore sessionLinkStore; private CodingToolPolicyResolver toolPolicyResolver; private CodingRuntime codingRuntime; private String model; private String instructions; private String systemPrompt; private Double temperature; private Double topP; private Integer maxOutputTokens; private Object reasoning; private Object toolChoice; private Boolean parallelToolCalls; private Boolean store; private String user; private Map extraBody; public CodingAgentBuilder runtime(AgentRuntime runtime) { this.runtime = runtime; return this; } public CodingAgentBuilder modelClient(AgentModelClient modelClient) { this.modelClient = modelClient; return this; } public CodingAgentBuilder workspaceContext(WorkspaceContext workspaceContext) { this.workspaceContext = workspaceContext; return this; } public CodingAgentBuilder agentOptions(AgentOptions agentOptions) { this.agentOptions = agentOptions; return this; } public CodingAgentBuilder codingOptions(CodingAgentOptions codingOptions) { this.codingOptions = codingOptions; return this; } public CodingAgentBuilder toolRegistry(AgentToolRegistry toolRegistry) { this.toolRegistry = toolRegistry; return this; } public CodingAgentBuilder toolExecutor(ToolExecutor toolExecutor) { this.toolExecutor = toolExecutor; return this; } public CodingAgentBuilder definitionRegistry(CodingAgentDefinitionRegistry definitionRegistry) { this.definitionRegistry = definitionRegistry; return this; } public CodingAgentBuilder subAgentRegistry(SubAgentRegistry subAgentRegistry) { this.subAgentRegistry = subAgentRegistry; return this; } public CodingAgentBuilder handoffPolicy(HandoffPolicy handoffPolicy) { this.handoffPolicy = handoffPolicy; return this; } public CodingAgentBuilder subAgent(SubAgentDefinition definition) { if (definition != null) { this.subAgentDefinitions.add(definition); } return this; } public CodingAgentBuilder subAgents(List definitions) { if (definitions != null && !definitions.isEmpty()) { this.subAgentDefinitions.addAll(definitions); } return this; } public CodingAgentBuilder taskManager(CodingTaskManager taskManager) { this.taskManager = taskManager; return this; } public CodingAgentBuilder sessionLinkStore(CodingSessionLinkStore sessionLinkStore) { this.sessionLinkStore = sessionLinkStore; return this; } public CodingAgentBuilder toolPolicyResolver(CodingToolPolicyResolver toolPolicyResolver) { this.toolPolicyResolver = toolPolicyResolver; return this; } public CodingAgentBuilder codingRuntime(CodingRuntime codingRuntime) { this.codingRuntime = codingRuntime; return this; } public CodingAgentBuilder model(String model) { this.model = model; return this; } public CodingAgentBuilder instructions(String instructions) { this.instructions = instructions; return this; } public CodingAgentBuilder systemPrompt(String systemPrompt) { this.systemPrompt = systemPrompt; return this; } public CodingAgentBuilder temperature(Double temperature) { this.temperature = temperature; return this; } public CodingAgentBuilder topP(Double topP) { this.topP = topP; return this; } public CodingAgentBuilder maxOutputTokens(Integer maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; return this; } public CodingAgentBuilder reasoning(Object reasoning) { this.reasoning = reasoning; return this; } public CodingAgentBuilder toolChoice(Object toolChoice) { this.toolChoice = toolChoice; return this; } public CodingAgentBuilder parallelToolCalls(Boolean parallelToolCalls) { this.parallelToolCalls = parallelToolCalls; return this; } public CodingAgentBuilder store(Boolean store) { this.store = store; return this; } public CodingAgentBuilder user(String user) { this.user = user; return this; } public CodingAgentBuilder extraBody(Map extraBody) { this.extraBody = extraBody; return this; } public CodingAgent build() { if (modelClient == null) { throw new IllegalStateException("modelClient is required"); } if (isBlank(model)) { throw new IllegalStateException("model is required"); } WorkspaceContext resolvedWorkspaceContext = workspaceContext == null ? WorkspaceContext.builder().build() : workspaceContext; resolvedWorkspaceContext = CodingSkillDiscovery.enrich(resolvedWorkspaceContext); CodingAgentOptions resolvedCodingOptions = codingOptions == null ? CodingAgentOptions.builder().build() : codingOptions; AgentOptions resolvedAgentOptions = agentOptions == null ? AgentOptions.builder().maxSteps(0).build() : agentOptions; CodingAgentDefinitionRegistry resolvedDefinitionRegistry = definitionRegistry == null ? BuiltInCodingAgentDefinitions.registry() : definitionRegistry; SubAgentRegistry resolvedSubAgentRegistry = resolveSubAgentRegistry(); HandoffPolicy resolvedHandoffPolicy = handoffPolicy == null ? HandoffPolicy.builder().build() : handoffPolicy; CodingTaskManager resolvedTaskManager = taskManager == null ? new InMemoryCodingTaskManager() : taskManager; CodingSessionLinkStore resolvedSessionLinkStore = sessionLinkStore == null ? new InMemoryCodingSessionLinkStore() : sessionLinkStore; CodingToolPolicyResolver resolvedToolPolicyResolver = toolPolicyResolver == null ? new CodingToolPolicyResolver() : toolPolicyResolver; CodingRuntime resolvedCodingRuntime = codingRuntime == null ? new DefaultCodingRuntime( resolvedWorkspaceContext, resolvedCodingOptions, toolRegistry, toolExecutor, resolvedDefinitionRegistry, resolvedTaskManager, resolvedSessionLinkStore, resolvedToolPolicyResolver, resolvedSubAgentRegistry, resolvedHandoffPolicy ) : codingRuntime; AgentToolRegistry builtInRegistry = createBuiltInRegistry(resolvedCodingOptions, resolvedDefinitionRegistry); ToolExecutor builtInExecutor = createBuiltInToolExecutor( resolvedWorkspaceContext, resolvedCodingOptions, new SessionProcessRegistry(resolvedWorkspaceContext, resolvedCodingOptions), resolvedCodingRuntime, resolvedDefinitionRegistry ); AgentToolRegistry resolvedToolRegistry = mergeToolRegistry(builtInRegistry, toolRegistry); ToolExecutor resolvedToolExecutor = mergeToolExecutor(builtInRegistry, builtInExecutor, toolRegistry, toolExecutor); resolvedToolRegistry = mergeSubAgentToolRegistry(resolvedToolRegistry, resolvedSubAgentRegistry); resolvedToolExecutor = mergeSubAgentToolExecutor(resolvedToolExecutor, resolvedSubAgentRegistry, resolvedHandoffPolicy); String resolvedSystemPrompt = resolvedCodingOptions.isPrependWorkspaceInstructions() ? CodingContextPromptAssembler.mergeSystemPrompt(systemPrompt, resolvedWorkspaceContext) : systemPrompt; AgentBuilder delegate = new AgentBuilder(); if (runtime != null) { delegate.runtime(runtime); } Agent agent = delegate .modelClient(modelClient) .model(model) .instructions(instructions) .systemPrompt(resolvedSystemPrompt) .options(resolvedAgentOptions) .toolRegistry(resolvedToolRegistry) .toolExecutor(resolvedToolExecutor) .temperature(temperature) .topP(topP) .maxOutputTokens(maxOutputTokens) .reasoning(reasoning) .toolChoice(toolChoice) .parallelToolCalls(parallelToolCalls) .store(store) .user(user) .extraBody(extraBody) .build(); return new CodingAgent( agent, resolvedWorkspaceContext, resolvedCodingOptions, toolRegistry, toolExecutor, resolvedCodingRuntime, resolvedSubAgentRegistry, resolvedHandoffPolicy ); } public static AgentToolRegistry createBuiltInRegistry(CodingAgentOptions options) { return createBuiltInRegistry(options, null); } public static AgentToolRegistry createBuiltInRegistry(CodingAgentOptions options, CodingAgentDefinitionRegistry definitionRegistry) { if (options == null || !options.isIncludeBuiltInTools()) { return StaticToolRegistry.empty(); } AgentToolRegistry builtInRegistry = CodingToolRegistryFactory.createBuiltInRegistry(); AgentToolRegistry delegateRegistry = new CodingDelegateToolRegistry(definitionRegistry); if (delegateRegistry.getTools().isEmpty()) { return builtInRegistry; } return new CompositeToolRegistry(builtInRegistry, delegateRegistry); } public static ToolExecutor createBuiltInToolExecutor(WorkspaceContext workspaceContext, CodingAgentOptions options, SessionProcessRegistry processRegistry) { return createBuiltInToolExecutor(workspaceContext, options, processRegistry, null, null); } public static ToolExecutor createBuiltInToolExecutor(WorkspaceContext workspaceContext, CodingAgentOptions options, SessionProcessRegistry processRegistry, CodingRuntime codingRuntime, CodingAgentDefinitionRegistry definitionRegistry) { if (options == null || !options.isIncludeBuiltInTools()) { return null; } WorkspaceFileService workspaceFileService = new LocalWorkspaceFileService(workspaceContext); ToolExecutorDecorator decorator = options.getToolExecutorDecorator(); List routes = new ArrayList(); routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.READ_FILE), decorate(CodingToolNames.READ_FILE, new ReadFileToolExecutor(workspaceFileService, options), decorator))); routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.WRITE_FILE), decorate(CodingToolNames.WRITE_FILE, new WriteFileToolExecutor(workspaceContext), decorator))); routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.APPLY_PATCH), decorate(CodingToolNames.APPLY_PATCH, new ApplyPatchToolExecutor(workspaceContext), decorator))); routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.BASH), decorate(CodingToolNames.BASH, new BashToolExecutor(workspaceContext, options, processRegistry), decorator))); if (codingRuntime != null && definitionRegistry != null) { ToolExecutor delegateExecutor = new CodingDelegateToolExecutor(codingRuntime, definitionRegistry); routes.add(RoutingToolExecutor.route(resolveToolNames(new CodingDelegateToolRegistry(definitionRegistry)), delegateExecutor)); } return new RoutingToolExecutor(routes, null); } public static AgentToolRegistry mergeToolRegistry(AgentToolRegistry builtInRegistry, AgentToolRegistry customRegistry) { if (customRegistry == null) { return builtInRegistry; } return new CompositeToolRegistry(builtInRegistry, customRegistry); } public static ToolExecutor mergeToolExecutor(AgentToolRegistry builtInRegistry, ToolExecutor builtInExecutor, AgentToolRegistry customRegistry, ToolExecutor customExecutor) { if (customRegistry != null && customExecutor == null) { throw new IllegalStateException("toolExecutor is required when custom toolRegistry is provided"); } if (builtInExecutor == null) { return customExecutor; } if (customExecutor == null) { return builtInExecutor; } List routes = new ArrayList<>(); routes.add(RoutingToolExecutor.route(resolveToolNames(builtInRegistry), builtInExecutor)); routes.add(RoutingToolExecutor.route(resolveToolNames(customRegistry), customExecutor)); return new RoutingToolExecutor(routes, null); } public static AgentToolRegistry mergeSubAgentToolRegistry(AgentToolRegistry baseRegistry, SubAgentRegistry subAgentRegistry) { if (subAgentRegistry == null || subAgentRegistry.getTools() == null || subAgentRegistry.getTools().isEmpty()) { return baseRegistry == null ? StaticToolRegistry.empty() : baseRegistry; } AgentToolRegistry safeBaseRegistry = baseRegistry == null ? StaticToolRegistry.empty() : baseRegistry; return new CompositeToolRegistry(safeBaseRegistry, new StaticToolRegistry(subAgentRegistry.getTools())); } public static ToolExecutor mergeSubAgentToolExecutor(ToolExecutor baseExecutor, SubAgentRegistry subAgentRegistry, HandoffPolicy handoffPolicy) { if (subAgentRegistry == null) { return baseExecutor; } return new SubAgentToolExecutor( subAgentRegistry, baseExecutor, handoffPolicy == null ? HandoffPolicy.builder().build() : handoffPolicy ); } public static Set resolveToolNames(AgentToolRegistry registry) { if (registry == null || registry.getTools() == null) { return Collections.emptySet(); } Set toolNames = new HashSet<>(); for (Object tool : registry.getTools()) { if (tool instanceof Tool) { Tool.Function function = ((Tool) tool).getFunction(); if (function != null && !isBlank(function.getName())) { toolNames.add(function.getName()); } } } return toolNames; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static ToolExecutor decorate(String toolName, ToolExecutor executor, ToolExecutorDecorator decorator) { if (decorator == null || executor == null) { return executor; } return decorator.decorate(toolName, executor); } private SubAgentRegistry resolveSubAgentRegistry() { if (subAgentRegistry != null) { return subAgentRegistry; } if (!subAgentDefinitions.isEmpty()) { return new StaticSubAgentRegistry(subAgentDefinitions); } return null; } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentOptions.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingAgentOptions { @Builder.Default private boolean includeBuiltInTools = true; @Builder.Default private boolean prependWorkspaceInstructions = true; @Builder.Default private int defaultFileListMaxDepth = 4; @Builder.Default private int defaultFileListMaxEntries = 200; @Builder.Default private int defaultReadMaxChars = 12000; @Builder.Default private long defaultCommandTimeoutMs = 30000L; @Builder.Default private int defaultBashLogChars = 12000; @Builder.Default private int maxProcessOutputChars = 120000; @Builder.Default private long processStopGraceMs = 1000L; @Builder.Default private boolean autoCompactEnabled = true; @Builder.Default private int compactContextWindowTokens = 128000; @Builder.Default private int compactReserveTokens = 16384; @Builder.Default private int compactKeepRecentTokens = 20000; @Builder.Default private int compactSummaryMaxOutputTokens = 400; @Builder.Default private boolean toolResultMicroCompactEnabled = true; @Builder.Default private int toolResultMicroCompactKeepRecent = 3; @Builder.Default private int toolResultMicroCompactMaxTokens = 1200; @Builder.Default private int autoCompactMaxConsecutiveFailures = 3; @Builder.Default private boolean autoContinueEnabled = true; @Builder.Default private int maxAutoFollowUps = 2; @Builder.Default private int maxTotalTurns = 6; @Builder.Default private boolean continueAfterCompact = true; @Builder.Default private boolean stopOnApprovalBlock = true; @Builder.Default private boolean stopOnExplicitQuestion = true; private ToolExecutorDecorator toolExecutorDecorator; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentRequest.java ================================================ package io.github.lnyocly.ai4j.coding; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CodingAgentRequest { private String input; private Map metadata; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentResult.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.coding.loop.CodingStopReason; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CodingAgentResult { private String sessionId; private String outputText; private Object rawResponse; private List toolCalls; private List toolResults; private int steps; @Builder.Default private int turns = 1; private CodingStopReason stopReason; private boolean autoContinued; private int autoFollowUpCount; private boolean lastCompactApplied; public static CodingAgentResult from(String sessionId, AgentResult result) { if (result == null) { return CodingAgentResult.builder().sessionId(sessionId).build(); } return CodingAgentResult.builder() .sessionId(sessionId) .outputText(result.getOutputText()) .rawResponse(result.getRawResponse()) .toolCalls(result.getToolCalls()) .toolResults(result.getToolResults()) .steps(result.getSteps()) .turns(1) .build(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgents.java ================================================ package io.github.lnyocly.ai4j.coding; public final class CodingAgents { private CodingAgents() { } public static CodingAgentBuilder builder() { return new CodingAgentBuilder(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSession.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.memory.AgentMemory; import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.coding.loop.CodingAgentLoopController; import io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision; import io.github.lnyocly.ai4j.coding.compact.CodingSessionCompactor; import io.github.lnyocly.ai4j.coding.compact.CodingToolResultMicroCompactResult; import io.github.lnyocly.ai4j.coding.compact.CodingToolResultMicroCompactor; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.runtime.CodingRuntime; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.UUID; public class CodingSession implements AutoCloseable { private static final CodingSessionCompactor COMPACTOR = new CodingSessionCompactor(); private static final CodingToolResultMicroCompactor TOOL_RESULT_MICRO_COMPACTOR = new CodingToolResultMicroCompactor(); private static final CodingAgentLoopController LOOP_CONTROLLER = new CodingAgentLoopController(); private final String sessionId; private final AgentSession delegate; private final WorkspaceContext workspaceContext; private final CodingAgentOptions options; private final SessionProcessRegistry processRegistry; private final CodingRuntime runtime; private CodingSessionCheckpoint checkpoint; private CodingSessionCompactResult lastAutoCompactResult; private CodingSessionCompactResult latestCompactResult; private Exception lastAutoCompactError; private final List pendingAutoCompactResults = new ArrayList(); private final List pendingAutoCompactErrors = new ArrayList(); private final List pendingLoopDecisions = new ArrayList(); private int consecutiveAutoCompactFailures; private boolean autoCompactCircuitBreakerOpen; public CodingSession(AgentSession delegate, WorkspaceContext workspaceContext, CodingAgentOptions options, SessionProcessRegistry processRegistry) { this(UUID.randomUUID().toString(), delegate, workspaceContext, options, processRegistry, null); } public CodingSession(String sessionId, AgentSession delegate, WorkspaceContext workspaceContext, CodingAgentOptions options, SessionProcessRegistry processRegistry) { this(sessionId, delegate, workspaceContext, options, processRegistry, null); } public CodingSession(AgentSession delegate, WorkspaceContext workspaceContext, CodingAgentOptions options, SessionProcessRegistry processRegistry, CodingRuntime runtime) { this(UUID.randomUUID().toString(), delegate, workspaceContext, options, processRegistry, runtime); } public CodingSession(String sessionId, AgentSession delegate, WorkspaceContext workspaceContext, CodingAgentOptions options, SessionProcessRegistry processRegistry, CodingRuntime runtime) { this.sessionId = sessionId; this.delegate = delegate; this.workspaceContext = workspaceContext; this.options = options; this.processRegistry = processRegistry; this.runtime = runtime; } public CodingAgentResult run(String input) throws Exception { return run(CodingAgentRequest.builder().input(input).build()); } public CodingAgentResult run(CodingAgentRequest request) throws Exception { clearPendingLoopArtifacts(); return LOOP_CONTROLLER.run(this, request); } public CodingAgentResult runStream(CodingAgentRequest request, AgentListener listener) throws Exception { clearPendingLoopArtifacts(); return LOOP_CONTROLLER.runStream(this, request, listener); } public String getSessionId() { return sessionId; } public AgentSession getDelegate() { return delegate; } public WorkspaceContext getWorkspaceContext() { return workspaceContext; } public CodingAgentOptions getOptions() { return options; } public CodingRuntime getRuntime() { return runtime; } public CodingDelegateResult delegate(CodingDelegateRequest request) throws Exception { if (runtime == null) { throw new IllegalStateException("coding runtime is unavailable for this session"); } return runtime.delegate(this, request); } public CodingSessionSnapshot snapshot() { MemorySnapshot snapshot = exportMemory(); List items = snapshot == null || snapshot.getItems() == null ? Collections.emptyList() : snapshot.getItems(); List processes = listProcessInfos(); List processSnapshots = exportProcessSnapshots(); CodingSessionCheckpoint effectiveCheckpoint = resolveCheckpoint(snapshot, processSnapshots, items.size(), false); String renderedSummary = CodingSessionCheckpointFormatter.render(effectiveCheckpoint); return CodingSessionSnapshot.builder() .sessionId(sessionId) .workspaceRoot(workspaceContext == null ? null : workspaceContext.getRootPath()) .memoryItemCount(items == null ? 0 : items.size()) .summary(renderedSummary) .checkpointGoal(effectiveCheckpoint == null ? null : effectiveCheckpoint.getGoal()) .checkpointGeneratedAtEpochMs(effectiveCheckpoint == null ? 0L : effectiveCheckpoint.getGeneratedAtEpochMs()) .checkpointSplitTurn(effectiveCheckpoint != null && effectiveCheckpoint.isSplitTurn()) .processCount(processes.size()) .activeProcessCount(processRegistry == null ? 0 : processRegistry.activeCount()) .restoredProcessCount(processRegistry == null ? 0 : processRegistry.restoredCount()) .estimatedContextTokens(COMPACTOR.estimateContextTokens(items, renderedSummary)) .lastCompactMode(resolveCompactMode(latestCompactResult)) .lastCompactBeforeItemCount(latestCompactResult == null ? 0 : latestCompactResult.getBeforeItemCount()) .lastCompactAfterItemCount(latestCompactResult == null ? 0 : latestCompactResult.getAfterItemCount()) .lastCompactTokensBefore(latestCompactResult == null ? 0 : latestCompactResult.getEstimatedTokensBefore()) .lastCompactTokensAfter(latestCompactResult == null ? 0 : latestCompactResult.getEstimatedTokensAfter()) .lastCompactStrategy(latestCompactResult == null ? null : latestCompactResult.getStrategy()) .lastCompactSummary(latestCompactResult == null ? null : latestCompactResult.getSummary()) .autoCompactFailureCount(consecutiveAutoCompactFailures) .autoCompactCircuitBreakerOpen(autoCompactCircuitBreakerOpen) .processes(processes) .build(); } public CodingSessionState exportState() { MemorySnapshot snapshot = exportMemory(); List items = snapshot == null || snapshot.getItems() == null ? Collections.emptyList() : snapshot.getItems(); List processSnapshots = exportProcessSnapshots(); return CodingSessionState.builder() .sessionId(sessionId) .workspaceRoot(workspaceContext == null ? null : workspaceContext.getRootPath()) .memorySnapshot(snapshot) .processCount(processSnapshots.size()) .checkpoint(resolveCheckpoint(snapshot, processSnapshots, items.size(), false)) .latestCompactResult(copyCompactResult(latestCompactResult)) .autoCompactFailureCount(consecutiveAutoCompactFailures) .autoCompactCircuitBreakerOpen(autoCompactCircuitBreakerOpen) .processSnapshots(processSnapshots) .build(); } public void restore(CodingSessionState state) { if (state == null) { return; } AgentMemory memory = resolveMemory(); if (!(memory instanceof InMemoryAgentMemory)) { throw new IllegalStateException("restore is only supported for InMemoryAgentMemory"); } ((InMemoryAgentMemory) memory).restore(state.getMemorySnapshot()); if (processRegistry != null) { processRegistry.restoreSnapshots(state.getProcessSnapshots()); } clearPendingLoopArtifacts(); checkpoint = copyCheckpoint(state.getCheckpoint()); latestCompactResult = copyCompactResult(state.getLatestCompactResult()); if (checkpoint == null) { checkpoint = latestCompactResult == null ? null : copyCheckpoint(latestCompactResult.getCheckpoint()); } if (checkpoint == null) { checkpoint = resolveCheckpoint(state.getMemorySnapshot(), state.getProcessSnapshots(), 0, false); } consecutiveAutoCompactFailures = Math.max(0, state.getAutoCompactFailureCount()); autoCompactCircuitBreakerOpen = state.isAutoCompactCircuitBreakerOpen(); } public CodingSessionCompactResult compact() { return compact(null); } public CodingSessionCompactResult compact(String summary) { InMemoryAgentMemory inMemoryMemory = requireInMemoryMemory(); try { CodingSessionCompactResult result = COMPACTOR.compact( sessionId, delegate == null ? null : delegate.getContext(), inMemoryMemory, options, summary, exportProcessSnapshots(), checkpoint, true ); checkpoint = result == null ? null : result.getCheckpoint(); latestCompactResult = copyCompactResult(result); clearPendingLoopArtifacts(); resetAutoCompactFailureTracking(); return result; } catch (Exception ex) { throw propagateCompactException(ex); } } public List listProcesses() { return listProcessInfos(); } public BashProcessInfo processStatus(String processId) { requireProcessRegistry(); return processRegistry.status(processId); } public BashProcessLogChunk processLogs(String processId, Long offset, Integer limit) { requireProcessRegistry(); return processRegistry.logs(processId, offset, limit); } public int writeProcess(String processId, String input) throws IOException { requireProcessRegistry(); return processRegistry.write(processId, input); } public BashProcessInfo stopProcess(String processId) { requireProcessRegistry(); return processRegistry.stop(processId); } public CodingSessionCompactResult drainLastAutoCompactResult() { CodingSessionCompactResult result = lastAutoCompactResult; lastAutoCompactResult = null; if (!pendingAutoCompactResults.isEmpty()) { pendingAutoCompactResults.remove(pendingAutoCompactResults.size() - 1); } return result; } public Exception drainLastAutoCompactError() { Exception error = lastAutoCompactError; lastAutoCompactError = null; if (!pendingAutoCompactErrors.isEmpty()) { pendingAutoCompactErrors.remove(pendingAutoCompactErrors.size() - 1); } return error; } public void clearAutoCompactState() { lastAutoCompactResult = null; lastAutoCompactError = null; pendingAutoCompactResults.clear(); pendingAutoCompactErrors.clear(); } public CodingSessionCompactResult getLastAutoCompactResult() { return copyCompactResult(lastAutoCompactResult); } public Exception getLastAutoCompactError() { return lastAutoCompactError; } public List drainAutoCompactResults() { if (pendingAutoCompactResults.isEmpty()) { return Collections.emptyList(); } List drained = new ArrayList(); for (CodingSessionCompactResult result : pendingAutoCompactResults) { drained.add(copyCompactResult(result)); } pendingAutoCompactResults.clear(); lastAutoCompactResult = null; return drained; } public List drainAutoCompactErrors() { if (pendingAutoCompactErrors.isEmpty()) { return Collections.emptyList(); } List drained = new ArrayList(pendingAutoCompactErrors); pendingAutoCompactErrors.clear(); lastAutoCompactError = null; return drained; } public void recordLoopDecision(CodingLoopDecision decision) { if (decision != null) { pendingLoopDecisions.add(decision.toBuilder().build()); } } public List drainLoopDecisions() { if (pendingLoopDecisions.isEmpty()) { return Collections.emptyList(); } List drained = new ArrayList(); for (CodingLoopDecision decision : pendingLoopDecisions) { drained.add(decision == null ? null : decision.toBuilder().build()); } pendingLoopDecisions.clear(); return drained; } public void clearPendingLoopArtifacts() { clearAutoCompactState(); pendingLoopDecisions.clear(); } private void maybeAutoCompactAfterTurn() { lastAutoCompactResult = null; lastAutoCompactError = null; if (options == null || !options.isAutoCompactEnabled()) { return; } InMemoryAgentMemory inMemoryMemory; try { inMemoryMemory = requireInMemoryMemory(); } catch (Exception ex) { lastAutoCompactError = ex; pendingAutoCompactErrors.add(ex); return; } if (autoCompactCircuitBreakerOpen) { lastAutoCompactError = new IllegalStateException( "Automatic compaction is paused after " + consecutiveAutoCompactFailures + " consecutive failures. Run a manual compact or reduce context to reset." ); pendingAutoCompactErrors.add(lastAutoCompactError); return; } try { MemorySnapshot snapshot = inMemoryMemory.snapshot(); CodingToolResultMicroCompactResult microCompactResult = TOOL_RESULT_MICRO_COMPACTOR.compact( snapshot, options, resolveAutoCompactTargetTokens() ); if (microCompactResult != null && microCompactResult.getAfterTokens() <= resolveAutoCompactTargetTokens()) { inMemoryMemory.restore(MemorySnapshot.from( microCompactResult.getItems(), snapshot == null ? null : snapshot.getSummary() )); lastAutoCompactResult = buildMicroCompactResult(snapshot, microCompactResult); latestCompactResult = copyCompactResult(lastAutoCompactResult); pendingAutoCompactResults.add(copyCompactResult(lastAutoCompactResult)); resetAutoCompactFailureTracking(); return; } lastAutoCompactResult = COMPACTOR.compact( sessionId, delegate == null ? null : delegate.getContext(), inMemoryMemory, options, null, exportProcessSnapshots(), checkpoint, false ); checkpoint = lastAutoCompactResult == null ? checkpoint : lastAutoCompactResult.getCheckpoint(); latestCompactResult = copyCompactResult(lastAutoCompactResult); if (lastAutoCompactResult != null) { pendingAutoCompactResults.add(copyCompactResult(lastAutoCompactResult)); } resetAutoCompactFailureTracking(); } catch (Exception ex) { recordAutoCompactFailure(ex); } } private InMemoryAgentMemory requireInMemoryMemory() { AgentMemory memory = resolveMemory(); if (!(memory instanceof InMemoryAgentMemory)) { throw new IllegalStateException("compact is only supported for InMemoryAgentMemory"); } return (InMemoryAgentMemory) memory; } private void requireProcessRegistry() { if (processRegistry == null) { throw new IllegalStateException("process registry is unavailable"); } } private List listProcessInfos() { if (processRegistry == null) { return Collections.emptyList(); } return new ArrayList(processRegistry.list()); } private List exportProcessSnapshots() { if (processRegistry == null) { return Collections.emptyList(); } return processRegistry.exportSnapshots(); } @Override public void close() { if (processRegistry != null) { processRegistry.close(); } } private AgentMemory resolveMemory() { return delegate == null || delegate.getContext() == null ? null : delegate.getContext().getMemory(); } private MemorySnapshot exportMemory() { AgentMemory memory = resolveMemory(); if (memory == null) { return MemorySnapshot.from(Collections.emptyList(), null); } if (memory instanceof InMemoryAgentMemory) { return ((InMemoryAgentMemory) memory).snapshot(); } return MemorySnapshot.from(memory.getItems(), memory.getSummary()); } private CodingSessionCheckpoint resolveCheckpoint(MemorySnapshot snapshot, List processSnapshots, int sourceItemCount, boolean splitTurn) { if (checkpoint != null) { checkpoint = checkpoint.toBuilder() .processSnapshots(copyProcesses(processSnapshots)) .sourceItemCount(sourceItemCount) .splitTurn(splitTurn) .build(); return checkpoint; } String summary = snapshot == null ? null : snapshot.getSummary(); checkpoint = CodingSessionCheckpointFormatter.create(summary, processSnapshots, sourceItemCount, splitTurn); return checkpoint; } private List copyProcesses(List processSnapshots) { if (processSnapshots == null || processSnapshots.isEmpty()) { return Collections.emptyList(); } List copies = new ArrayList(); for (StoredProcessSnapshot snapshot : processSnapshots) { if (snapshot != null) { copies.add(snapshot.toBuilder().build()); } } return copies; } private RuntimeException propagateCompactException(Exception ex) { if (ex instanceof RuntimeException) { return (RuntimeException) ex; } return new IllegalStateException("Failed to compact coding session", ex); } private String resolveCompactMode(CodingSessionCompactResult result) { if (result == null) { return null; } return result.isAutomatic() ? "auto" : "manual"; } private int resolveAutoCompactTargetTokens() { if (options == null) { return 0; } return Math.max(0, options.getCompactContextWindowTokens() - options.getCompactReserveTokens()); } private void resetAutoCompactFailureTracking() { consecutiveAutoCompactFailures = 0; autoCompactCircuitBreakerOpen = false; } private void recordAutoCompactFailure(Exception ex) { consecutiveAutoCompactFailures += 1; int threshold = options == null ? 0 : Math.max(0, options.getAutoCompactMaxConsecutiveFailures()); if (threshold > 0 && consecutiveAutoCompactFailures >= threshold) { autoCompactCircuitBreakerOpen = true; lastAutoCompactError = new IllegalStateException( "Automatic compaction failed " + consecutiveAutoCompactFailures + " consecutive times; circuit breaker opened.", ex ); } else { lastAutoCompactError = ex; } pendingAutoCompactErrors.add(lastAutoCompactError); } private CodingSessionCompactResult buildMicroCompactResult(MemorySnapshot snapshot, CodingToolResultMicroCompactResult microCompactResult) { List rawItems = snapshot == null || snapshot.getItems() == null ? Collections.emptyList() : snapshot.getItems(); return CodingSessionCompactResult.builder() .sessionId(sessionId) .beforeItemCount(rawItems.size()) .afterItemCount(microCompactResult.getItems() == null ? 0 : microCompactResult.getItems().size()) .summary(microCompactResult.getSummary()) .automatic(true) .splitTurn(false) .estimatedTokensBefore(microCompactResult.getBeforeTokens()) .estimatedTokensAfter(microCompactResult.getAfterTokens()) .strategy("tool-result-micro") .compactedToolResultCount(microCompactResult.getCompactedToolResultCount()) .checkpoint(checkpoint == null ? null : checkpoint.toBuilder().build()) .build(); } private CodingSessionCompactResult copyCompactResult(CodingSessionCompactResult result) { if (result == null) { return null; } return result.toBuilder() .checkpoint(copyCheckpoint(result.getCheckpoint())) .build(); } private CodingSessionCheckpoint copyCheckpoint(CodingSessionCheckpoint source) { if (source == null) { return null; } return source.toBuilder() .constraints(source.getConstraints() == null ? new ArrayList() : new ArrayList(source.getConstraints())) .doneItems(source.getDoneItems() == null ? new ArrayList() : new ArrayList(source.getDoneItems())) .inProgressItems(source.getInProgressItems() == null ? new ArrayList() : new ArrayList(source.getInProgressItems())) .blockedItems(source.getBlockedItems() == null ? new ArrayList() : new ArrayList(source.getBlockedItems())) .keyDecisions(source.getKeyDecisions() == null ? new ArrayList() : new ArrayList(source.getKeyDecisions())) .nextSteps(source.getNextSteps() == null ? new ArrayList() : new ArrayList(source.getNextSteps())) .criticalContext(source.getCriticalContext() == null ? new ArrayList() : new ArrayList(source.getCriticalContext())) .processSnapshots(copyProcesses(source.getProcessSnapshots())) .build(); } public CodingAgentResult runSingleTurn(CodingAgentRequest request, String hiddenInstructions) throws Exception { AgentResult result = CodingSessionScope.runWithSession(this, new CodingSessionScope.SessionCallable() { @Override public AgentResult call() throws Exception { AgentSession executionSession = resolveExecutionSession(hiddenInstructions); Object input = request == null ? null : request.getInput(); return executionSession.run(AgentRequest.builder().input(input).build()); } }); maybeAutoCompactAfterTurn(); return CodingAgentResult.from(sessionId, result); } public CodingAgentResult runSingleTurnStream(CodingAgentRequest request, AgentListener listener, String hiddenInstructions) throws Exception { AgentResult result = CodingSessionScope.runWithSession(this, new CodingSessionScope.SessionCallable() { @Override public AgentResult call() throws Exception { AgentSession executionSession = resolveExecutionSession(hiddenInstructions); Object input = request == null ? null : request.getInput(); return executionSession.runStreamResult(AgentRequest.builder().input(input).build(), listener); } }); maybeAutoCompactAfterTurn(); return CodingAgentResult.from(sessionId, result); } private AgentSession resolveExecutionSession(String hiddenInstructions) { if (delegate == null || delegate.getContext() == null || isBlank(hiddenInstructions)) { return delegate; } return new AgentSession( delegate.getRuntime(), delegate.getContext().toBuilder() .instructions(mergeText(delegate.getContext().getInstructions(), hiddenInstructions)) .build() ); } private String mergeText(String base, String extra) { if (isBlank(base)) { return extra; } if (isBlank(extra)) { return base; } return base + "\n\n" + extra; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionCheckpoint.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class CodingSessionCheckpoint { private String goal; @Builder.Default private List constraints = new ArrayList(); @Builder.Default private List doneItems = new ArrayList(); @Builder.Default private List inProgressItems = new ArrayList(); @Builder.Default private List blockedItems = new ArrayList(); @Builder.Default private List keyDecisions = new ArrayList(); @Builder.Default private List nextSteps = new ArrayList(); @Builder.Default private List criticalContext = new ArrayList(); @Builder.Default private List processSnapshots = new ArrayList(); private long generatedAtEpochMs; private int sourceItemCount; private boolean splitTurn; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionCheckpointFormatter.java ================================================ package io.github.lnyocly.ai4j.coding; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class CodingSessionCheckpointFormatter { private CodingSessionCheckpointFormatter() { } public static CodingSessionCheckpoint parse(String summary) { CodingSessionCheckpoint checkpoint = CodingSessionCheckpoint.builder().build(); if (isBlank(summary)) { return checkpoint; } CodingSessionCheckpoint structured = parseStructured(summary); if (structured != null) { return structured; } String[] lines = summary.replace("\r", "").split("\n"); Section section = null; ProgressSection progressSection = null; StringBuilder goal = new StringBuilder(); for (String rawLine : lines) { String line = rawLine == null ? "" : rawLine.trim(); if (line.isEmpty()) { continue; } if ("## Goal".equals(line)) { section = Section.GOAL; progressSection = null; continue; } if ("## Constraints & Preferences".equals(line)) { section = Section.CONSTRAINTS; progressSection = null; continue; } if ("## Progress".equals(line)) { section = Section.PROGRESS; progressSection = null; continue; } if ("### Done".equals(line)) { section = Section.PROGRESS; progressSection = ProgressSection.DONE; continue; } if ("### In Progress".equals(line)) { section = Section.PROGRESS; progressSection = ProgressSection.IN_PROGRESS; continue; } if ("### Blocked".equals(line)) { section = Section.PROGRESS; progressSection = ProgressSection.BLOCKED; continue; } if ("## Key Decisions".equals(line)) { section = Section.KEY_DECISIONS; progressSection = null; continue; } if ("## Next Steps".equals(line)) { section = Section.NEXT_STEPS; progressSection = null; continue; } if ("## Critical Context".equals(line)) { section = Section.CRITICAL_CONTEXT; progressSection = null; continue; } if ("## Process Snapshots".equals(line)) { section = Section.PROCESS_SNAPSHOTS; progressSection = null; continue; } if (section == null) { continue; } switch (section) { case GOAL: if (goal.length() > 0) { goal.append('\n'); } goal.append(stripListPrefix(line)); break; case CONSTRAINTS: addIfPresent(checkpoint.getConstraints(), stripListPrefix(line)); break; case PROGRESS: addProgressLine(checkpoint, progressSection, line); break; case KEY_DECISIONS: addIfPresent(checkpoint.getKeyDecisions(), stripListPrefix(line)); break; case NEXT_STEPS: addIfPresent(checkpoint.getNextSteps(), stripNumberPrefix(stripListPrefix(line))); break; case CRITICAL_CONTEXT: addIfPresent(checkpoint.getCriticalContext(), stripListPrefix(line)); break; case PROCESS_SNAPSHOTS: // 进程快照以结构化 state 为准,文本渲染仅做人类可读展示。 break; default: break; } } if (goal.length() > 0) { checkpoint.setGoal(goal.toString().trim()); } return checkpoint; } public static String renderStructuredJson(CodingSessionCheckpoint checkpoint) { return toStructuredObject(checkpoint).toJSONString(); } public static CodingSessionCheckpoint create(String summary, List processSnapshots, int sourceItemCount, boolean splitTurn) { CodingSessionCheckpoint checkpoint = parse(summary); checkpoint.setProcessSnapshots(copyProcesses(processSnapshots)); checkpoint.setSourceItemCount(sourceItemCount); checkpoint.setSplitTurn(splitTurn); checkpoint.setGeneratedAtEpochMs(System.currentTimeMillis()); if (isBlank(checkpoint.getGoal())) { checkpoint.setGoal("Continue the current coding session."); } return checkpoint; } public static String render(CodingSessionCheckpoint checkpoint) { if (checkpoint == null) { return ""; } StringBuilder builder = new StringBuilder(); builder.append("## Goal\n"); builder.append(defaultText(checkpoint.getGoal(), "Continue the current coding session.")).append('\n'); builder.append("## Constraints & Preferences\n"); appendBulletSection(builder, checkpoint.getConstraints(), "(none)"); builder.append("## Progress\n"); builder.append("### Done\n"); appendProgressSection(builder, checkpoint.getDoneItems(), "x"); builder.append("### In Progress\n"); appendProgressSection(builder, checkpoint.getInProgressItems(), " "); builder.append("### Blocked\n"); appendBulletSection(builder, checkpoint.getBlockedItems(), "(none)"); builder.append("## Key Decisions\n"); appendBulletSection(builder, checkpoint.getKeyDecisions(), "(none)"); builder.append("## Next Steps\n"); appendNumberSection(builder, checkpoint.getNextSteps(), "Resume from the latest workspace state."); builder.append("## Critical Context\n"); appendBulletSection(builder, checkpoint.getCriticalContext(), "(none)"); if (checkpoint.getProcessSnapshots() != null && !checkpoint.getProcessSnapshots().isEmpty()) { builder.append("## Process Snapshots\n"); for (StoredProcessSnapshot snapshot : checkpoint.getProcessSnapshots()) { builder.append("- ").append(renderProcessSnapshot(snapshot)).append('\n'); } } return builder.toString().trim(); } private static CodingSessionCheckpoint parseStructured(String summary) { String json = extractJsonObject(summary); if (isBlank(json)) { return null; } try { JSONObject object = JSON.parseObject(json); if (object == null || object.isEmpty()) { return null; } return fromStructuredObject(object); } catch (Exception ignore) { return null; } } private static CodingSessionCheckpoint fromStructuredObject(JSONObject object) { if (object == null) { return null; } JSONObject progress = object.getJSONObject("progress"); CodingSessionCheckpoint checkpoint = CodingSessionCheckpoint.builder() .goal(safeTrim(object.getString("goal"))) .constraints(readStringList(object.getJSONArray("constraints"))) .doneItems(readStringList(progress == null ? object.getJSONArray("doneItems") : progress.getJSONArray("done"))) .inProgressItems(readStringList(progress == null ? object.getJSONArray("inProgressItems") : progress.getJSONArray("inProgress"))) .blockedItems(readStringList(progress == null ? object.getJSONArray("blockedItems") : progress.getJSONArray("blocked"))) .keyDecisions(readStringList(object.getJSONArray("keyDecisions"))) .nextSteps(readStringList(object.getJSONArray("nextSteps"))) .criticalContext(readStringList(object.getJSONArray("criticalContext"))) .build(); if (checkpoint.getGoal() == null && checkpoint.getConstraints().isEmpty() && checkpoint.getDoneItems().isEmpty() && checkpoint.getInProgressItems().isEmpty() && checkpoint.getBlockedItems().isEmpty() && checkpoint.getKeyDecisions().isEmpty() && checkpoint.getNextSteps().isEmpty() && checkpoint.getCriticalContext().isEmpty()) { return null; } return checkpoint; } private static JSONObject toStructuredObject(CodingSessionCheckpoint checkpoint) { JSONObject object = new JSONObject(); CodingSessionCheckpoint effective = checkpoint == null ? CodingSessionCheckpoint.builder().build() : checkpoint; object.put("goal", defaultText(effective.getGoal(), "Continue the current coding session.")); object.put("constraints", new JSONArray(normalize(effective.getConstraints()))); JSONObject progress = new JSONObject(); progress.put("done", new JSONArray(normalize(effective.getDoneItems()))); progress.put("inProgress", new JSONArray(normalize(effective.getInProgressItems()))); progress.put("blocked", new JSONArray(normalize(effective.getBlockedItems()))); object.put("progress", progress); object.put("keyDecisions", new JSONArray(normalize(effective.getKeyDecisions()))); object.put("nextSteps", new JSONArray(normalize(effective.getNextSteps()))); object.put("criticalContext", new JSONArray(normalize(effective.getCriticalContext()))); return object; } private static void addProgressLine(CodingSessionCheckpoint checkpoint, ProgressSection progressSection, String line) { String normalized = stripCheckboxPrefix(stripListPrefix(line)); if (isBlank(normalized)) { return; } if (progressSection == ProgressSection.DONE) { checkpoint.getDoneItems().add(normalized); return; } if (progressSection == ProgressSection.IN_PROGRESS) { checkpoint.getInProgressItems().add(normalized); return; } if (progressSection == ProgressSection.BLOCKED) { checkpoint.getBlockedItems().add(normalized); } } private static void appendBulletSection(StringBuilder builder, List values, String emptyValue) { List normalized = normalize(values); if (normalized.isEmpty()) { builder.append("- ").append(emptyValue).append('\n'); return; } for (String value : normalized) { builder.append("- ").append(value).append('\n'); } } private static void appendProgressSection(StringBuilder builder, List values, String marker) { List normalized = normalize(values); if (normalized.isEmpty()) { builder.append("- [").append(marker).append("] ").append("(none)").append('\n'); return; } for (String value : normalized) { builder.append("- [").append(marker).append("] ").append(value).append('\n'); } } private static void appendNumberSection(StringBuilder builder, List values, String emptyValue) { List normalized = normalize(values); if (normalized.isEmpty()) { builder.append("1. ").append(emptyValue).append('\n'); return; } for (int i = 0; i < normalized.size(); i++) { builder.append(i + 1).append(". ").append(normalized.get(i)).append('\n'); } } private static String renderProcessSnapshot(StoredProcessSnapshot snapshot) { if (snapshot == null) { return "(unknown)"; } StringBuilder builder = new StringBuilder(); builder.append(defaultText(snapshot.getProcessId(), "(unknown)")).append(" | "); builder.append(snapshot.isControlAvailable() ? "live" : "metadata-only").append(" | "); builder.append("status=").append(snapshot.getStatus()).append(" | "); builder.append("cwd=").append(defaultText(snapshot.getWorkingDirectory(), "(unknown)")).append(" | "); builder.append("command=").append(defaultText(snapshot.getCommand(), "(none)")); if (snapshot.getLastLogOffset() > 0) { builder.append(" | lastLogOffset=").append(snapshot.getLastLogOffset()); } if (!isBlank(snapshot.getLastLogPreview())) { builder.append(" | preview=").append(clip(snapshot.getLastLogPreview(), 160)); } return builder.toString(); } private static List normalize(List values) { if (values == null || values.isEmpty()) { return Collections.emptyList(); } List result = new ArrayList(); for (String value : values) { if (!isBlank(value) && !"(none)".equalsIgnoreCase(value.trim())) { result.add(value.trim()); } } return result; } private static void addIfPresent(List target, String value) { if (target == null || isBlank(value) || "(none)".equalsIgnoreCase(value.trim())) { return; } target.add(value.trim()); } private static List copyProcesses(List processSnapshots) { if (processSnapshots == null || processSnapshots.isEmpty()) { return new ArrayList(); } List copies = new ArrayList(); for (StoredProcessSnapshot snapshot : processSnapshots) { if (snapshot != null) { copies.add(snapshot.toBuilder().build()); } } return copies; } private static String stripListPrefix(String value) { if (isBlank(value)) { return ""; } String trimmed = value.trim(); if (trimmed.startsWith("- ")) { return trimmed.substring(2).trim(); } return trimmed; } private static String stripCheckboxPrefix(String value) { if (isBlank(value)) { return ""; } String trimmed = value.trim(); if (trimmed.startsWith("[x] ") || trimmed.startsWith("[X] ") || trimmed.startsWith("[ ] ")) { return trimmed.substring(4).trim(); } return trimmed; } private static String stripNumberPrefix(String value) { if (isBlank(value)) { return ""; } int index = value.indexOf('.'); if (index > 0) { boolean digitsOnly = true; for (int i = 0; i < index; i++) { if (!Character.isDigit(value.charAt(i))) { digitsOnly = false; break; } } if (digitsOnly && index + 1 < value.length()) { return value.substring(index + 1).trim(); } } return value.trim(); } private static List readStringList(JSONArray values) { if (values == null || values.isEmpty()) { return new ArrayList(); } List result = new ArrayList(); for (int i = 0; i < values.size(); i++) { String value = safeTrim(values.getString(i)); if (!isBlank(value) && !"(none)".equalsIgnoreCase(value)) { result.add(value); } } return result; } private static String extractJsonObject(String summary) { if (isBlank(summary)) { return null; } String trimmed = stripCodeFence(summary.trim()); int start = trimmed.indexOf('{'); int end = trimmed.lastIndexOf('}'); if (start < 0 || end <= start) { return null; } return trimmed.substring(start, end + 1); } private static String stripCodeFence(String text) { if (isBlank(text) || !text.startsWith("```")) { return text; } int firstNewline = text.indexOf('\n'); if (firstNewline < 0) { return text; } String body = text.substring(firstNewline + 1); int lastFence = body.lastIndexOf("```"); if (lastFence >= 0) { body = body.substring(0, lastFence); } return body.trim(); } private static String safeTrim(String value) { return isBlank(value) ? null : value.trim(); } private static String defaultText(String value, String fallback) { return isBlank(value) ? fallback : value.trim(); } private static String clip(String value, int maxChars) { if (value == null) { return ""; } String normalized = value.replace('\r', ' ').replace('\n', ' ').trim(); if (normalized.length() <= maxChars) { return normalized; } return normalized.substring(0, maxChars) + "..."; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private enum Section { GOAL, CONSTRAINTS, PROGRESS, KEY_DECISIONS, NEXT_STEPS, CRITICAL_CONTEXT, PROCESS_SNAPSHOTS } private enum ProgressSection { DONE, IN_PROGRESS, BLOCKED } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionCompactResult.java ================================================ package io.github.lnyocly.ai4j.coding; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingSessionCompactResult { private String sessionId; private int beforeItemCount; private int afterItemCount; private String summary; private boolean automatic; private boolean splitTurn; private int estimatedTokensBefore; private int estimatedTokensAfter; private String strategy; private int compactedToolResultCount; private int deltaItemCount; private boolean checkpointReused; private boolean fallbackSummary; private CodingSessionCheckpoint checkpoint; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionScope.java ================================================ package io.github.lnyocly.ai4j.coding; public final class CodingSessionScope { private static final ThreadLocal CURRENT = new ThreadLocal(); private CodingSessionScope() { } public interface SessionCallable { T call() throws Exception; } public interface SessionRunnable { void run() throws Exception; } public static CodingSession currentSession() { return CURRENT.get(); } public static T runWithSession(CodingSession session, SessionCallable callable) throws Exception { if (callable == null) { throw new IllegalArgumentException("callable is required"); } CodingSession previous = CURRENT.get(); CURRENT.set(session); try { return callable.call(); } finally { restore(previous); } } public static void runWithSession(CodingSession session, SessionRunnable runnable) throws Exception { if (runnable == null) { throw new IllegalArgumentException("runnable is required"); } runWithSession(session, new SessionCallable() { @Override public Void call() throws Exception { runnable.run(); return null; } }); } private static void restore(CodingSession previous) { if (previous == null) { CURRENT.remove(); return; } CURRENT.set(previous); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionSnapshot.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import lombok.Builder; import lombok.Data; import java.util.ArrayList; import java.util.List; @Data @Builder public class CodingSessionSnapshot { private String sessionId; private String workspaceRoot; private int memoryItemCount; private String summary; private String checkpointGoal; private long checkpointGeneratedAtEpochMs; private boolean checkpointSplitTurn; private int processCount; private int activeProcessCount; private int restoredProcessCount; private int estimatedContextTokens; private String lastCompactMode; private int lastCompactBeforeItemCount; private int lastCompactAfterItemCount; private int lastCompactTokensBefore; private int lastCompactTokensAfter; private String lastCompactStrategy; private String lastCompactSummary; private int autoCompactFailureCount; private boolean autoCompactCircuitBreakerOpen; @Builder.Default private List processes = new ArrayList(); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionState.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CodingSessionState { private String sessionId; private String workspaceRoot; private MemorySnapshot memorySnapshot; private int processCount; private CodingSessionCheckpoint checkpoint; private CodingSessionCompactResult latestCompactResult; private int autoCompactFailureCount; private boolean autoCompactCircuitBreakerOpen; @Builder.Default private List processSnapshots = new ArrayList(); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingCompactionPreparation.java ================================================ package io.github.lnyocly.ai4j.coding.compact; import lombok.Builder; import lombok.Data; import java.util.List; @Data @Builder public class CodingCompactionPreparation { private List rawItems; private List itemsToSummarize; private List turnPrefixItems; private List keptItems; private String previousSummary; private boolean splitTurn; private int firstKeptItemIndex; private int estimatedTokensBefore; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingSessionCompactor.java ================================================ package io.github.lnyocly.ai4j.coding.compact; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpointFormatter; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; public class CodingSessionCompactor { private static final int MAX_SUMMARIZATION_PROMPT_TOO_LONG_RETRIES = 3; private static final double PROMPT_TOO_LONG_RETRY_DROP_RATIO = 0.25d; private static final int FALLBACK_RECENT_CONTEXT_ITEMS = 6; private static final String COMPACTION_FALLBACK_MARKER = "**Compaction fallback**"; private static final String SESSION_MEMORY_FALLBACK_MARKER = "**Session-memory fallback**"; private static final String SUMMARIZATION_SYSTEM_PROMPT = "You create context checkpoint summaries for coding sessions. " + "Return only the structured JSON object requested by the user prompt."; private static final String SUMMARIZATION_PROMPT = "The messages above are a conversation to summarize.\n" + "Create a structured context checkpoint summary that another LLM will use to continue the work.\n" + "Return ONLY a JSON object with this exact schema:\n" + "{\n" + " \"goal\": \"string\",\n" + " \"constraints\": [\"string\"],\n" + " \"progress\": {\n" + " \"done\": [\"string\"],\n" + " \"inProgress\": [\"string\"],\n" + " \"blocked\": [\"string\"]\n" + " },\n" + " \"keyDecisions\": [\"string\"],\n" + " \"nextSteps\": [\"string\"],\n" + " \"criticalContext\": [\"string\"]\n" + "}\n" + "Keep fields concise. Preserve exact file paths, function names, commands, and error messages."; private static final String UPDATE_SUMMARIZATION_PROMPT = "The messages above are NEW conversation messages to incorporate into the existing checkpoint provided in tags.\n" + "Update the existing structured checkpoint with new information.\n" + "RULES:\n" + "- PRESERVE all existing information from the previous summary\n" + "- ADD new progress, decisions, and context from the new messages\n" + "- UPDATE the Progress section: move items from \"In Progress\" to \"Done\" when completed\n" + "- UPDATE \"Next Steps\" based on what was accomplished\n" + "- PRESERVE exact file paths, function names, and error messages\n" + "- If something is no longer relevant, you may remove it\n" + "Return ONLY the updated JSON object using the same schema as the initial checkpoint.\n" + "Keep fields concise. Preserve exact file paths, function names, commands, and error messages."; private static final String TURN_PREFIX_SUMMARIZATION_PROMPT = "The messages above are the EARLY PART of an in-progress turn that was too large to keep in full.\n" + "Summarize only the critical context from this partial turn so the later kept messages can still be understood.\n" + "Focus on:\n" + "- the user request for this turn\n" + "- key assistant decisions already made\n" + "- tool calls/results that changed the workspace or revealed critical facts\n" + "- exact file paths, function names, commands, and errors\n" + "Return ONLY the same checkpoint JSON schema, keeping fields compact."; public CodingCompactionPreparation prepare(MemorySnapshot snapshot, CodingAgentOptions options, boolean force) { List rawItems = snapshot == null || snapshot.getItems() == null ? Collections.emptyList() : new ArrayList(snapshot.getItems()); String previousSummary = snapshot == null ? null : snapshot.getSummary(); int estimatedTokensBefore = estimateContextTokens(rawItems, previousSummary); if (!force && !shouldCompact(estimatedTokensBefore, options)) { return null; } if (rawItems.isEmpty()) { return CodingCompactionPreparation.builder() .rawItems(rawItems) .itemsToSummarize(Collections.emptyList()) .turnPrefixItems(Collections.emptyList()) .keptItems(Collections.emptyList()) .previousSummary(previousSummary) .splitTurn(false) .firstKeptItemIndex(0) .estimatedTokensBefore(estimatedTokensBefore) .build(); } CutPoint cutPoint = findCutPoint(rawItems, options.getCompactKeepRecentTokens()); int firstKeptItemIndex = cutPoint.firstKeptItemIndex; int historyEnd = cutPoint.splitTurn ? cutPoint.turnStartItemIndex : firstKeptItemIndex; List itemsToSummarize = copySlice(rawItems, 0, historyEnd); List turnPrefixItems = cutPoint.splitTurn ? copySlice(rawItems, cutPoint.turnStartItemIndex, firstKeptItemIndex) : Collections.emptyList(); List keptItems = copySlice(rawItems, firstKeptItemIndex, rawItems.size()); if (force && itemsToSummarize.isEmpty() && turnPrefixItems.isEmpty()) { itemsToSummarize = new ArrayList(rawItems); keptItems = Collections.emptyList(); firstKeptItemIndex = rawItems.size(); cutPoint = new CutPoint(firstKeptItemIndex, -1, false); } return CodingCompactionPreparation.builder() .rawItems(rawItems) .itemsToSummarize(itemsToSummarize) .turnPrefixItems(turnPrefixItems) .keptItems(keptItems) .previousSummary(previousSummary) .splitTurn(cutPoint.splitTurn) .firstKeptItemIndex(firstKeptItemIndex) .estimatedTokensBefore(estimatedTokensBefore) .build(); } public CodingSessionCompactResult compact(String sessionId, AgentContext context, InMemoryAgentMemory memory, CodingAgentOptions options, String customInstructions, List processSnapshots, CodingSessionCheckpoint previousCheckpoint, boolean force) throws Exception { MemorySnapshot snapshot = memory.snapshot(); CodingCompactionPreparation preparation = prepare(snapshot, options, force); if (preparation == null) { return null; } CodingSessionCheckpoint existingCheckpoint = copyCheckpoint(previousCheckpoint); if (!hasCheckpointContent(existingCheckpoint) && !isBlank(preparation.getPreviousSummary())) { existingCheckpoint = CodingSessionCheckpointFormatter.parse(preparation.getPreviousSummary()); } boolean checkpointReused = hasCheckpointContent(existingCheckpoint) || !isBlank(preparation.getPreviousSummary()); int deltaItemCount = safeSize(preparation.getItemsToSummarize()) + safeSize(preparation.getTurnPrefixItems()); CodingSessionCheckpoint checkpoint = buildCheckpoint( context, preparation, options, customInstructions, existingCheckpoint ); boolean aggressiveCompactionApplied = false; List keptItems = preparation.getKeptItems() == null ? Collections.emptyList() : preparation.getKeptItems(); String renderedSummary = CodingSessionCheckpointFormatter.render(checkpoint); int afterTokens = estimateContextTokens(keptItems, renderedSummary); if (needsAggressiveCompaction(afterTokens, preparation.getEstimatedTokensBefore(), options) && preparation.getRawItems() != null && !preparation.getRawItems().isEmpty() && !keptItems.isEmpty()) { aggressiveCompactionApplied = true; checkpoint = summarize( context, preparation.getRawItems(), options, appendInstructions(customInstructions, "Aggressively compact the full conversation into a single checkpoint."), existingCheckpoint, false ); if (checkpoint == null) { checkpoint = buildFallbackCheckpoint(preparation.getRawItems(), existingCheckpoint); } keptItems = Collections.emptyList(); renderedSummary = CodingSessionCheckpointFormatter.render(checkpoint); afterTokens = estimateContextTokens(keptItems, renderedSummary); } checkpoint = attachCheckpointMetadata( checkpoint, processSnapshots, preparation.getRawItems() == null ? 0 : preparation.getRawItems().size(), preparation.isSplitTurn() ); renderedSummary = CodingSessionCheckpointFormatter.render(checkpoint); memory.restore(MemorySnapshot.from(keptItems, renderedSummary)); afterTokens = estimateContextTokens(keptItems, renderedSummary); return CodingSessionCompactResult.builder() .sessionId(sessionId) .beforeItemCount(preparation.getRawItems() == null ? 0 : preparation.getRawItems().size()) .afterItemCount(keptItems.size()) .summary(renderedSummary) .automatic(!force) .splitTurn(preparation.isSplitTurn()) .estimatedTokensBefore(preparation.getEstimatedTokensBefore()) .estimatedTokensAfter(afterTokens) .strategy(resolveCheckpointStrategy(aggressiveCompactionApplied, checkpointReused)) .deltaItemCount(deltaItemCount) .checkpointReused(checkpointReused) .fallbackSummary(detectFallbackSummary(checkpoint)) .checkpoint(checkpoint) .build(); } public int estimateContextTokens(List rawItems, String summary) { int tokens = isBlank(summary) ? 0 : estimateTextTokens(summary); if (rawItems != null) { for (Object rawItem : rawItems) { tokens += estimateItemTokens(rawItem); } } return tokens; } private boolean shouldCompact(int estimatedTokens, CodingAgentOptions options) { if (options == null || !options.isAutoCompactEnabled()) { return false; } return estimatedTokens > options.getCompactContextWindowTokens() - options.getCompactReserveTokens(); } private boolean needsAggressiveCompaction(int afterTokens, int beforeTokens, CodingAgentOptions options) { if (afterTokens >= beforeTokens) { return true; } if (options == null) { return false; } return afterTokens > options.getCompactContextWindowTokens() - options.getCompactReserveTokens(); } private CutPoint findCutPoint(List rawItems, int keepRecentTokens) { List cutPoints = new ArrayList(); for (int i = 0; i < rawItems.size(); i++) { if (isValidCutPoint(rawItems.get(i))) { cutPoints.add(i); } } if (cutPoints.isEmpty()) { return new CutPoint(0, -1, false); } int accumulatedTokens = 0; int cutIndex = cutPoints.get(0); for (int i = rawItems.size() - 1; i >= 0; i--) { accumulatedTokens += estimateItemTokens(rawItems.get(i)); if (accumulatedTokens >= keepRecentTokens) { cutIndex = findNearestCutPointAtOrAfter(cutPoints, i); break; } } boolean cutAtUserMessage = isUserMessage(rawItems.get(cutIndex)); int turnStartItemIndex = cutAtUserMessage ? -1 : findTurnStartIndex(rawItems, cutIndex); return new CutPoint(cutIndex, turnStartItemIndex, !cutAtUserMessage && turnStartItemIndex >= 0); } private int findNearestCutPointAtOrAfter(List cutPoints, int index) { for (Integer cutPoint : cutPoints) { if (cutPoint >= index) { return cutPoint; } } return cutPoints.get(cutPoints.size() - 1); } private int findTurnStartIndex(List rawItems, int index) { for (int i = index; i >= 0; i--) { if (isUserMessage(rawItems.get(i))) { return i; } } return -1; } private boolean isValidCutPoint(Object item) { JSONObject object = toJSONObject(item); return "message".equals(object.getString("type")); } private boolean isUserMessage(Object item) { JSONObject object = toJSONObject(item); return "message".equals(object.getString("type")) && "user".equals(object.getString("role")); } private int estimateItemTokens(Object item) { JSONObject object = toJSONObject(item); String type = object.getString("type"); if ("function_call_output".equals(type)) { return estimateTextTokens(object.getString("output")); } if ("message".equals(type)) { JSONArray content = object.getJSONArray("content"); if (content == null) { return estimateTextTokens(object.toJSONString()); } int chars = 0; for (int i = 0; i < content.size(); i++) { JSONObject part = content.getJSONObject(i); if (part == null) { continue; } String partType = part.getString("type"); if ("input_text".equals(partType) || "output_text".equals(partType)) { chars += safeText(part.getString("text")).length(); } else if ("input_image".equals(partType)) { chars += 4800; } else { chars += part.toJSONString().length(); } } return estimateChars(chars); } return estimateTextTokens(object.toJSONString()); } private CodingSessionCheckpoint buildCheckpoint(AgentContext context, CodingCompactionPreparation preparation, CodingAgentOptions options, String customInstructions, CodingSessionCheckpoint previousCheckpoint) throws Exception { CodingSessionCheckpoint historyCheckpoint = copyCheckpoint(previousCheckpoint); if (!hasCheckpointContent(historyCheckpoint) && !isBlank(preparation.getPreviousSummary())) { historyCheckpoint = CodingSessionCheckpointFormatter.parse(preparation.getPreviousSummary()); } if (preparation.getItemsToSummarize() != null && !preparation.getItemsToSummarize().isEmpty()) { historyCheckpoint = summarize( context, preparation.getItemsToSummarize(), options, customInstructions, historyCheckpoint, false ); } if (preparation.getTurnPrefixItems() != null && !preparation.getTurnPrefixItems().isEmpty()) { String turnPrefixInstructions = appendInstructions( customInstructions, "These messages are the early part of an in-progress turn that precedes newer kept messages." ); historyCheckpoint = summarize( context, preparation.getTurnPrefixItems(), options, turnPrefixInstructions, hasCheckpointContent(historyCheckpoint) ? historyCheckpoint : null, !hasCheckpointContent(historyCheckpoint) ); } if (!hasCheckpointContent(historyCheckpoint)) { historyCheckpoint = buildFallbackCheckpoint(preparation.getRawItems(), previousCheckpoint); } return historyCheckpoint; } private CodingSessionCheckpoint summarize(AgentContext context, List items, CodingAgentOptions options, String customInstructions, CodingSessionCheckpoint previousCheckpoint, boolean turnPrefix) throws Exception { if (items == null || items.isEmpty()) { return copyCheckpoint(previousCheckpoint); } AgentModelClient modelClient = context == null ? null : context.getModelClient(); if (modelClient == null) { return buildFallbackCheckpoint(items, previousCheckpoint); } List attemptItems = new ArrayList(items); int retryCount = 0; int droppedItemCount = 0; while (true) { String conversationText = serializeConversation(attemptItems); if (isBlank(conversationText)) { return annotatePromptTooLongRetry( buildFallbackCheckpoint(items, previousCheckpoint), retryCount, droppedItemCount, retryCount > 0 ); } AgentPrompt summarizationPrompt = AgentPrompt.builder() .model(context.getModel()) .systemPrompt(SUMMARIZATION_SYSTEM_PROMPT) .items(Collections.singletonList(AgentInputItem.userMessage( buildSummarizationPromptText(conversationText, customInstructions, previousCheckpoint, turnPrefix) ))) .maxOutputTokens(options == null ? null : options.getCompactSummaryMaxOutputTokens()) .store(Boolean.FALSE) .build(); AgentModelResult result; try { result = modelClient.create(summarizationPrompt); } catch (Exception ex) { if (!isPromptTooLongError(ex)) { if (hasCheckpointContent(previousCheckpoint)) { return buildFallbackCheckpoint(items, previousCheckpoint); } throw ex; } PromptTooLongRetrySlice retrySlice = truncateForPromptTooLongRetry(attemptItems); if (retryCount >= MAX_SUMMARIZATION_PROMPT_TOO_LONG_RETRIES || retrySlice == null) { return annotatePromptTooLongRetry( buildFallbackCheckpoint(items, previousCheckpoint), retryCount + 1, droppedItemCount, true ); } retryCount += 1; droppedItemCount += retrySlice.droppedItemCount; attemptItems = retrySlice.items; continue; } String outputText = result == null ? null : result.getOutputText(); CodingSessionCheckpoint parsed = isBlank(outputText) ? buildFallbackCheckpoint(items, previousCheckpoint) : CodingSessionCheckpointFormatter.parse(outputText); if (!hasCheckpointContent(parsed)) { parsed = buildFallbackCheckpoint(items, previousCheckpoint); } return annotatePromptTooLongRetry(parsed, retryCount, droppedItemCount, false); } } private String buildSummarizationPromptText(String conversationText, String customInstructions, CodingSessionCheckpoint previousCheckpoint, boolean turnPrefix) { StringBuilder prompt = new StringBuilder(); prompt.append("\n").append(conversationText).append("\n\n"); if (hasCheckpointContent(previousCheckpoint)) { prompt.append("\n\n") .append(CodingSessionCheckpointFormatter.renderStructuredJson(previousCheckpoint)) .append("\n\n"); } prompt.append("\n"); prompt.append(turnPrefix ? TURN_PREFIX_SUMMARIZATION_PROMPT : (hasCheckpointContent(previousCheckpoint) ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT)); if (!isBlank(customInstructions)) { prompt.append("\nAdditional focus: ").append(customInstructions.trim()); } return prompt.toString(); } private String serializeConversation(List items) { StringBuilder builder = new StringBuilder(); if (items == null) { return ""; } for (Object item : items) { String line = serializeItem(item); if (!isBlank(line)) { if (builder.length() > 0) { builder.append("\n\n"); } builder.append(line); } } return builder.toString(); } private String serializeItem(Object item) { JSONObject object = toJSONObject(item); String type = object.getString("type"); if ("function_call_output".equals(type)) { return "[Tool Result]: " + truncateForSummary(object.getString("output")); } if (!"message".equals(type)) { return "[Context]: " + truncateForSummary(object.toJSONString()); } String role = object.getString("role"); String text = extractMessageText(object.getJSONArray("content")); if ("user".equals(role)) { return "[User]: " + truncateForSummary(text); } if ("assistant".equals(role)) { return "[Assistant]: " + truncateForSummary(text); } if ("system".equals(role)) { return "[System]: " + truncateForSummary(text); } return "[" + safeText(role) + "]: " + truncateForSummary(text); } private String extractMessageText(JSONArray content) { if (content == null || content.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (int i = 0; i < content.size(); i++) { JSONObject part = content.getJSONObject(i); if (part == null) { continue; } String partType = part.getString("type"); if ("input_text".equals(partType) || "output_text".equals(partType)) { if (builder.length() > 0) { builder.append(' '); } builder.append(safeText(part.getString("text"))); } else if ("input_image".equals(partType)) { if (builder.length() > 0) { builder.append(' '); } builder.append("[image]"); } else { if (builder.length() > 0) { builder.append(' '); } builder.append(part.toJSONString()); } } return builder.toString().trim(); } private String truncateForSummary(String text) { String value = safeText(text); int maxChars = 2000; if (value.length() <= maxChars) { return value; } int truncatedChars = value.length() - maxChars; return value.substring(0, maxChars) + "\n\n[... " + truncatedChars + " more characters truncated]"; } private CodingSessionCheckpoint buildFallbackCheckpoint(List items, CodingSessionCheckpoint previousCheckpoint) { if (hasCheckpointContent(previousCheckpoint)) { return buildSessionMemoryFallbackCheckpoint(items, previousCheckpoint); } CodingSessionCheckpoint checkpoint = copyCheckpoint(previousCheckpoint); if (checkpoint == null) { checkpoint = CodingSessionCheckpoint.builder().build(); } if (isBlank(checkpoint.getGoal())) { checkpoint.setGoal("Continue the current coding session."); } if (hasCheckpointContent(previousCheckpoint)) { addUnique(checkpoint.getDoneItems(), "Previous compacted context retained."); addUnique(checkpoint.getCriticalContext(), "Previous summary exists and should be preserved."); } else { addUnique(checkpoint.getDoneItems(), "Conversation summarized from existing context."); } addUnique(checkpoint.getInProgressItems(), "Resume from the latest workspace state."); addUnique(checkpoint.getKeyDecisions(), COMPACTION_FALLBACK_MARKER + ": Used local summary because the model summary was unavailable."); addUnique(checkpoint.getNextSteps(), "Inspect the latest files and continue from the kept recent context."); mergeRecentContextIntoCheckpoint(checkpoint, items, false); return checkpoint; } private CodingSessionCheckpoint buildSessionMemoryFallbackCheckpoint(List items, CodingSessionCheckpoint previousCheckpoint) { CodingSessionCheckpoint checkpoint = copyCheckpoint(previousCheckpoint); if (checkpoint == null) { checkpoint = CodingSessionCheckpoint.builder().build(); } if (isBlank(checkpoint.getGoal())) { checkpoint.setGoal(resolveLatestUserGoal(items)); } if (isBlank(checkpoint.getGoal())) { checkpoint.setGoal("Continue the current coding session."); } addUnique(checkpoint.getDoneItems(), "Previous compacted context retained."); addUnique(checkpoint.getInProgressItems(), "Continue from the latest kept context and recent delta."); addUnique(checkpoint.getKeyDecisions(), SESSION_MEMORY_FALLBACK_MARKER + ": Reused the existing checkpoint and merged recent delta locally because the model summary was unavailable."); addUnique(checkpoint.getNextSteps(), "Continue from the latest kept context, recent delta, and current workspace state."); mergeRecentContextIntoCheckpoint(checkpoint, items, true); String latestUserGoal = resolveLatestUserGoal(items); if (!isBlank(latestUserGoal)) { addUnique(checkpoint.getCriticalContext(), "Latest user delta: " + latestUserGoal); } return checkpoint; } private void mergeRecentContextIntoCheckpoint(CodingSessionCheckpoint checkpoint, List items, boolean sessionMemoryFallback) { if (checkpoint == null || items == null || items.isEmpty()) { return; } int start = Math.max(0, items.size() - FALLBACK_RECENT_CONTEXT_ITEMS); for (int i = start; i < items.size(); i++) { Object item = items.get(i); String line = serializeItem(item); if (!isBlank(line)) { addUnique(checkpoint.getCriticalContext(), line.replace('\n', ' ')); } mergeFallbackSignals(checkpoint, item, sessionMemoryFallback); } } private void mergeFallbackSignals(CodingSessionCheckpoint checkpoint, Object item, boolean sessionMemoryFallback) { if (checkpoint == null || item == null) { return; } JSONObject object = toJSONObject(item); String type = object.getString("type"); if ("function_call_output".equals(type)) { String output = singleLine(object.getString("output")); if (isBlank(output)) { return; } String lower = output.toLowerCase(Locale.ROOT); if (lower.contains("[approval-rejected]")) { addUnique(checkpoint.getBlockedItems(), "Approval was rejected in recent delta: " + clip(output, 220)); } else if (lower.contains("tool_error:") || lower.contains("tool error")) { addUnique(checkpoint.getBlockedItems(), "Tool error preserved from recent delta: " + clip(output, 220)); } else if (sessionMemoryFallback) { addUnique(checkpoint.getCriticalContext(), "Recent tool delta: " + clip(output, 220)); } return; } if (!"message".equals(type)) { return; } String role = object.getString("role"); String text = singleLine(extractMessageText(object.getJSONArray("content"))); if (isBlank(text)) { return; } if ("assistant".equals(role) && sessionMemoryFallback) { addUnique(checkpoint.getCriticalContext(), "Recent assistant delta: " + clip(text, 220)); } if ("system".equals(role) && sessionMemoryFallback) { addUnique(checkpoint.getCriticalContext(), "Recent system delta: " + clip(text, 220)); } } private CodingSessionCheckpoint annotatePromptTooLongRetry(CodingSessionCheckpoint checkpoint, int retryCount, int droppedItemCount, boolean fallbackAfterRetry) { if (retryCount <= 0 && !fallbackAfterRetry) { return checkpoint; } CodingSessionCheckpoint effective = copyCheckpoint(checkpoint); if (effective == null) { effective = CodingSessionCheckpoint.builder().build(); } StringBuilder keyDecision = new StringBuilder(); keyDecision.append("**Compaction retry**: Summary prompt exceeded model context"); if (fallbackAfterRetry) { keyDecision.append(" and fell back to the local checkpoint after ") .append(Math.max(1, retryCount)) .append(" retry attempt"); } else { keyDecision.append("; dropped ").append(Math.max(1, droppedItemCount)) .append(" oldest item"); if (droppedItemCount != 1) { keyDecision.append("s"); } keyDecision.append(" across ").append(retryCount).append(" retry attempt"); } if (retryCount != 1) { keyDecision.append("s"); } keyDecision.append("."); addUnique(effective.getKeyDecisions(), keyDecision.toString()); addUnique(effective.getCriticalContext(), "Oldest summarized context may be partially omitted because the compaction summary request exceeded model context."); return effective; } private String resolveLatestUserGoal(List items) { if (items == null || items.isEmpty()) { return null; } for (int i = items.size() - 1; i >= 0; i--) { JSONObject object = toJSONObject(items.get(i)); if (!"message".equals(object.getString("type")) || !"user".equals(object.getString("role"))) { continue; } String text = singleLine(extractMessageText(object.getJSONArray("content"))); if (!isBlank(text)) { return clip(text, 220); } } return null; } private PromptTooLongRetrySlice truncateForPromptTooLongRetry(List items) { if (items == null || items.size() <= 1) { return null; } int totalTokens = 0; for (Object item : items) { totalTokens += estimateItemTokens(item); } int targetTokensToDrop = Math.max(1, (int) Math.ceil(totalTokens * PROMPT_TOO_LONG_RETRY_DROP_RATIO)); int accumulatedTokens = 0; int candidateDropIndex = 1; for (int i = 0; i < items.size() - 1; i++) { accumulatedTokens += estimateItemTokens(items.get(i)); candidateDropIndex = i + 1; if (accumulatedTokens >= targetTokensToDrop) { break; } } int adjustedDropIndex = adjustPromptTooLongRetryDropIndex(items, candidateDropIndex); adjustedDropIndex = Math.min(items.size() - 1, Math.max(1, adjustedDropIndex)); return new PromptTooLongRetrySlice(copySlice(items, adjustedDropIndex, items.size()), adjustedDropIndex); } private int adjustPromptTooLongRetryDropIndex(List items, int candidateDropIndex) { if (items == null || items.isEmpty()) { return candidateDropIndex; } List cutPoints = new ArrayList(); for (int i = 1; i < items.size(); i++) { if (isValidCutPoint(items.get(i))) { cutPoints.add(i); } } if (cutPoints.isEmpty()) { return candidateDropIndex; } for (Integer cutPoint : cutPoints) { if (cutPoint >= candidateDropIndex) { return cutPoint; } } return candidateDropIndex; } private boolean isPromptTooLongError(Throwable throwable) { Throwable current = throwable; while (current != null) { String message = safeText(current.getMessage()).toLowerCase(Locale.ROOT); if (message.contains("prompt too long") || message.contains("prompt_too_long") || message.contains("context length") || message.contains("maximum context") || message.contains("maximum context length") || message.contains("too many tokens") || message.contains("token limit") || message.contains("context window") || message.contains("request too large") || message.contains("payload too large") || message.contains("request entity too large") || message.contains("input is too long") || message.contains("status code: 413") || message.contains("error code: 413")) { return true; } current = current.getCause(); } return false; } private CodingSessionCheckpoint attachCheckpointMetadata(CodingSessionCheckpoint checkpoint, List processSnapshots, int sourceItemCount, boolean splitTurn) { CodingSessionCheckpoint effective = copyCheckpoint(checkpoint); if (effective == null) { effective = CodingSessionCheckpoint.builder().build(); } if (isBlank(effective.getGoal())) { effective.setGoal("Continue the current coding session."); } effective.setProcessSnapshots(copyProcesses(processSnapshots)); effective.setSourceItemCount(sourceItemCount); effective.setSplitTurn(splitTurn); effective.setGeneratedAtEpochMs(System.currentTimeMillis()); return effective; } private CodingSessionCheckpoint copyCheckpoint(CodingSessionCheckpoint checkpoint) { if (checkpoint == null) { return null; } return CodingSessionCheckpoint.builder() .goal(checkpoint.getGoal()) .constraints(copyStrings(checkpoint.getConstraints())) .doneItems(copyStrings(checkpoint.getDoneItems())) .inProgressItems(copyStrings(checkpoint.getInProgressItems())) .blockedItems(copyStrings(checkpoint.getBlockedItems())) .keyDecisions(copyStrings(checkpoint.getKeyDecisions())) .nextSteps(copyStrings(checkpoint.getNextSteps())) .criticalContext(copyStrings(checkpoint.getCriticalContext())) .processSnapshots(copyProcesses(checkpoint.getProcessSnapshots())) .generatedAtEpochMs(checkpoint.getGeneratedAtEpochMs()) .sourceItemCount(checkpoint.getSourceItemCount()) .splitTurn(checkpoint.isSplitTurn()) .build(); } private List copyStrings(List values) { if (values == null || values.isEmpty()) { return new ArrayList(); } return new ArrayList(values); } private List copyProcesses(List processSnapshots) { if (processSnapshots == null || processSnapshots.isEmpty()) { return new ArrayList(); } List copies = new ArrayList(); for (StoredProcessSnapshot snapshot : processSnapshots) { if (snapshot != null) { copies.add(snapshot.toBuilder().build()); } } return copies; } private boolean hasCheckpointContent(CodingSessionCheckpoint checkpoint) { if (checkpoint == null) { return false; } return !isBlank(checkpoint.getGoal()) || !copyStrings(checkpoint.getConstraints()).isEmpty() || !copyStrings(checkpoint.getDoneItems()).isEmpty() || !copyStrings(checkpoint.getInProgressItems()).isEmpty() || !copyStrings(checkpoint.getBlockedItems()).isEmpty() || !copyStrings(checkpoint.getKeyDecisions()).isEmpty() || !copyStrings(checkpoint.getNextSteps()).isEmpty() || !copyStrings(checkpoint.getCriticalContext()).isEmpty(); } private boolean detectFallbackSummary(CodingSessionCheckpoint checkpoint) { if (checkpoint == null || checkpoint.getKeyDecisions() == null) { return false; } for (String keyDecision : checkpoint.getKeyDecisions()) { String normalized = safeText(keyDecision); if (normalized.contains(COMPACTION_FALLBACK_MARKER) || normalized.contains(SESSION_MEMORY_FALLBACK_MARKER)) { return true; } } return false; } private void addUnique(List target, String value) { if (target == null || isBlank(value)) { return; } String normalized = value.trim(); for (String existing : target) { if (normalized.equals(existing == null ? null : existing.trim())) { return; } } target.add(normalized); } private JSONObject toJSONObject(Object item) { if (item == null) { return new JSONObject(); } if (item instanceof JSONObject) { return (JSONObject) item; } if (item instanceof Map) { JSONObject object = new JSONObject(); for (Map.Entry entry : ((Map) item).entrySet()) { if (entry.getKey() != null) { object.put(String.valueOf(entry.getKey()), entry.getValue()); } } return object; } return JSON.parseObject(JSON.toJSONString(item)); } private List copySlice(List rawItems, int from, int to) { if (rawItems == null || rawItems.isEmpty() || from >= to) { return Collections.emptyList(); } return new ArrayList(rawItems.subList(Math.max(0, from), Math.min(rawItems.size(), to))); } private int estimateTextTokens(String text) { return estimateChars(safeText(text).length()); } private String resolveCheckpointStrategy(boolean aggressiveCompactionApplied, boolean checkpointReused) { if (aggressiveCompactionApplied) { return checkpointReused ? "aggressive-checkpoint-delta" : "aggressive-checkpoint"; } return checkpointReused ? "checkpoint-delta" : "checkpoint"; } private int safeSize(List values) { return values == null ? 0 : values.size(); } private int estimateChars(int chars) { return (chars + 3) / 4; } private String safeText(String value) { return value == null ? "" : value; } private String singleLine(String value) { return safeText(value).replace('\r', ' ').replace('\n', ' ').trim(); } private String clip(String value, int maxChars) { String text = safeText(value).trim(); if (text.length() <= maxChars) { return text; } return text.substring(0, Math.max(0, maxChars - 3)) + "..."; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String appendInstructions(String base, String extra) { if (isBlank(base)) { return extra; } if (isBlank(extra)) { return base; } return base.trim() + " " + extra.trim(); } private static final class CutPoint { private final int firstKeptItemIndex; private final int turnStartItemIndex; private final boolean splitTurn; private CutPoint(int firstKeptItemIndex, int turnStartItemIndex, boolean splitTurn) { this.firstKeptItemIndex = firstKeptItemIndex; this.turnStartItemIndex = turnStartItemIndex; this.splitTurn = splitTurn; } } private static final class PromptTooLongRetrySlice { private final List items; private final int droppedItemCount; private PromptTooLongRetrySlice(List items, int droppedItemCount) { this.items = items; this.droppedItemCount = droppedItemCount; } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingToolResultMicroCompactResult.java ================================================ package io.github.lnyocly.ai4j.coding.compact; import lombok.Builder; import lombok.Data; import java.util.List; @Data @Builder public class CodingToolResultMicroCompactResult { private List items; private int beforeTokens; private int afterTokens; private int compactedToolResultCount; private String summary; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingToolResultMicroCompactor.java ================================================ package io.github.lnyocly.ai4j.coding.compact; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; public class CodingToolResultMicroCompactor { private static final String COMPACTED_PREFIX = "[tool result compacted to save context]"; private static final int PREVIEW_CHARS = 240; public CodingToolResultMicroCompactResult compact(MemorySnapshot snapshot, CodingAgentOptions options, int targetTokens) { if (snapshot == null || options == null || !options.isToolResultMicroCompactEnabled()) { return null; } List rawItems = snapshot.getItems() == null ? new ArrayList() : new ArrayList(snapshot.getItems()); if (rawItems.isEmpty()) { return null; } int beforeTokens = estimateContextTokens(rawItems, snapshot.getSummary()); if (beforeTokens <= targetTokens) { return null; } List toolResultIndexes = collectToolResultIndexes(rawItems); int keepRecent = Math.max(0, options.getToolResultMicroCompactKeepRecent()); if (toolResultIndexes.size() <= keepRecent) { return null; } Set protectedIndexes = new HashSet(); for (int i = Math.max(0, toolResultIndexes.size() - keepRecent); i < toolResultIndexes.size(); i++) { protectedIndexes.add(toolResultIndexes.get(i)); } int maxToolResultTokens = Math.max(1, options.getToolResultMicroCompactMaxTokens()); List compactedItems = new ArrayList(rawItems.size()); int compactedCount = 0; for (int i = 0; i < rawItems.size(); i++) { Object item = rawItems.get(i); if (shouldCompactToolResult(item, i, protectedIndexes, maxToolResultTokens)) { compactedItems.add(compactToolResult(item)); compactedCount += 1; } else { compactedItems.add(copyItem(item)); } } if (compactedCount == 0) { return null; } int afterTokens = estimateContextTokens(compactedItems, snapshot.getSummary()); return CodingToolResultMicroCompactResult.builder() .items(compactedItems) .beforeTokens(beforeTokens) .afterTokens(afterTokens) .compactedToolResultCount(compactedCount) .summary(buildSummary(compactedCount, beforeTokens, afterTokens)) .build(); } private List collectToolResultIndexes(List rawItems) { List indexes = new ArrayList(); for (int i = 0; i < rawItems.size(); i++) { JSONObject object = toJSONObject(rawItems.get(i)); if ("function_call_output".equals(object.getString("type"))) { indexes.add(i); } } return indexes; } private boolean shouldCompactToolResult(Object item, int index, Set protectedIndexes, int maxToolResultTokens) { if (protectedIndexes.contains(index)) { return false; } JSONObject object = toJSONObject(item); if (!"function_call_output".equals(object.getString("type"))) { return false; } String output = safeText(object.getString("output")); if (output.isEmpty() || output.startsWith(COMPACTED_PREFIX)) { return false; } return estimateTextTokens(output) > maxToolResultTokens; } private Object compactToolResult(Object item) { JSONObject object = toJSONObject(copyItem(item)); String callId = safeText(object.getString("call_id")); String output = safeText(object.getString("output")); String preview = output.replace('\r', ' ').replace('\n', ' ').trim(); if (preview.length() > PREVIEW_CHARS) { preview = preview.substring(0, PREVIEW_CHARS).trim() + "..."; } StringBuilder compacted = new StringBuilder(COMPACTED_PREFIX); if (!callId.isEmpty()) { compacted.append(" call_id=").append(callId).append("."); } if (!preview.isEmpty()) { compacted.append(" Preview: ").append(preview); } object.put("output", compacted.toString()); return object; } private String buildSummary(int compactedCount, int beforeTokens, int afterTokens) { StringBuilder summary = new StringBuilder(); summary.append("Micro-compacted ").append(compactedCount).append(" older tool result"); if (compactedCount != 1) { summary.append("s"); } summary.append(" to reduce context pressure ("); summary.append(beforeTokens).append("->").append(afterTokens).append(" tokens)."); return summary.toString(); } private int estimateContextTokens(List rawItems, String summary) { int tokens = isBlank(summary) ? 0 : estimateTextTokens(summary); if (rawItems != null) { for (Object rawItem : rawItems) { tokens += estimateItemTokens(rawItem); } } return tokens; } private int estimateItemTokens(Object item) { JSONObject object = toJSONObject(item); String type = object.getString("type"); if ("function_call_output".equals(type)) { return estimateTextTokens(object.getString("output")); } if ("message".equals(type)) { JSONArray content = object.getJSONArray("content"); if (content == null) { return estimateTextTokens(object.toJSONString()); } int chars = 0; for (int i = 0; i < content.size(); i++) { JSONObject part = content.getJSONObject(i); if (part == null) { continue; } String partType = part.getString("type"); if ("input_text".equals(partType) || "output_text".equals(partType)) { chars += safeText(part.getString("text")).length(); } else if ("input_image".equals(partType)) { chars += 4800; } else { chars += part.toJSONString().length(); } } return estimateChars(chars); } return estimateTextTokens(object.toJSONString()); } private int estimateTextTokens(String text) { return estimateChars(safeText(text).length()); } private int estimateChars(int chars) { return (chars + 3) / 4; } private Object copyItem(Object item) { if (item == null) { return null; } return JSON.parse(JSON.toJSONString(item)); } private JSONObject toJSONObject(Object item) { if (item == null) { return new JSONObject(); } if (item instanceof JSONObject) { return (JSONObject) item; } if (item instanceof Map) { JSONObject object = new JSONObject(); for (Map.Entry entry : ((Map) item).entrySet()) { if (entry.getKey() != null) { object.put(String.valueOf(entry.getKey()), entry.getValue()); } } return object; } return JSON.parseObject(JSON.toJSONString(item)); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String safeText(String value) { return value == null ? "" : value; } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/BuiltInCodingAgentDefinitions.java ================================================ package io.github.lnyocly.ai4j.coding.definition; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import java.util.Arrays; import java.util.List; public final class BuiltInCodingAgentDefinitions { public static final String GENERAL_PURPOSE = "general-purpose"; public static final String EXPLORE = "explore"; public static final String PLAN = "plan"; public static final String VERIFICATION = "verification"; private static final List DEFINITIONS = Arrays.asList( CodingAgentDefinition.builder() .name(GENERAL_PURPOSE) .toolName("delegate_general_purpose") .description("General coding worker with read, write, patch, and shell access.") .instructions("You are the default coding worker. Inspect the workspace, make focused edits when needed, and finish the assigned coding task directly.") .allowedToolNames(CodingToolNames.allBuiltIn()) .sessionMode(CodingSessionMode.FORK) .isolationMode(CodingIsolationMode.WRITE_ENABLED) .memoryScope(CodingMemoryScope.FORK) .background(false) .build(), CodingAgentDefinition.builder() .name(EXPLORE) .toolName("delegate_explore") .description("Read-only codebase explorer for answering concrete repository questions.") .instructions("You are a read-only exploration worker. Search, read files, and inspect the workspace, but do not modify files.") .allowedToolNames(CodingToolNames.readOnlyBuiltIn()) .sessionMode(CodingSessionMode.FORK) .isolationMode(CodingIsolationMode.READ_ONLY) .memoryScope(CodingMemoryScope.FORK) .background(false) .build(), CodingAgentDefinition.builder() .name(PLAN) .toolName("delegate_plan") .description("Read-only planner for design, decomposition, and implementation planning.") .instructions("You are a planning worker. Read the codebase, identify constraints, and produce a concrete implementation plan without editing files.") .allowedToolNames(CodingToolNames.readOnlyBuiltIn()) .sessionMode(CodingSessionMode.FORK) .isolationMode(CodingIsolationMode.READ_ONLY) .memoryScope(CodingMemoryScope.FORK) .background(false) .build(), CodingAgentDefinition.builder() .name(VERIFICATION) .toolName("delegate_verification") .description("Read-only verification worker for checks, builds, and validation.") .instructions("You are a verification worker. Use read-only inspection and shell validation to verify changes, summarize risks, and report findings clearly.") .allowedToolNames(CodingToolNames.readOnlyBuiltIn()) .sessionMode(CodingSessionMode.FORK) .isolationMode(CodingIsolationMode.READ_ONLY) .memoryScope(CodingMemoryScope.FORK) .background(true) .build() ); private BuiltInCodingAgentDefinitions() { } public static List list() { return DEFINITIONS; } public static CodingAgentDefinitionRegistry registry() { return new StaticCodingAgentDefinitionRegistry(DEFINITIONS); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingAgentDefinition.java ================================================ package io.github.lnyocly.ai4j.coding.definition; import lombok.Builder; import lombok.Data; import java.util.Set; @Data @Builder(toBuilder = true) public class CodingAgentDefinition { private String name; private String description; private String toolName; private String model; private String instructions; private String systemPrompt; private Set allowedToolNames; @Builder.Default private CodingSessionMode sessionMode = CodingSessionMode.FORK; @Builder.Default private CodingIsolationMode isolationMode = CodingIsolationMode.INHERIT; @Builder.Default private CodingMemoryScope memoryScope = CodingMemoryScope.INHERIT; @Builder.Default private CodingApprovalMode approvalMode = CodingApprovalMode.INHERIT; @Builder.Default private boolean background = false; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingAgentDefinitionRegistry.java ================================================ package io.github.lnyocly.ai4j.coding.definition; import java.util.List; public interface CodingAgentDefinitionRegistry { CodingAgentDefinition getDefinition(String nameOrToolName); List listDefinitions(); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingApprovalMode.java ================================================ package io.github.lnyocly.ai4j.coding.definition; public enum CodingApprovalMode { INHERIT, AUTO, MANUAL } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingIsolationMode.java ================================================ package io.github.lnyocly.ai4j.coding.definition; public enum CodingIsolationMode { INHERIT, READ_ONLY, WRITE_ENABLED } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingMemoryScope.java ================================================ package io.github.lnyocly.ai4j.coding.definition; public enum CodingMemoryScope { INHERIT, FRESH, FORK, SHARED } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingSessionMode.java ================================================ package io.github.lnyocly.ai4j.coding.definition; public enum CodingSessionMode { NEW, FORK, SHARED } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CompositeCodingAgentDefinitionRegistry.java ================================================ package io.github.lnyocly.ai4j.coding.definition; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Locale; public class CompositeCodingAgentDefinitionRegistry implements CodingAgentDefinitionRegistry { private final List registries; public CompositeCodingAgentDefinitionRegistry(CodingAgentDefinitionRegistry... registries) { this(registries == null ? Collections.emptyList() : Arrays.asList(registries)); } public CompositeCodingAgentDefinitionRegistry(List registries) { if (registries == null || registries.isEmpty()) { this.registries = Collections.emptyList(); return; } List ordered = new ArrayList(); for (CodingAgentDefinitionRegistry registry : registries) { if (registry != null) { ordered.add(registry); } } this.registries = ordered.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(ordered); } @Override public CodingAgentDefinition getDefinition(String nameOrToolName) { if (isBlank(nameOrToolName)) { return null; } for (int i = registries.size() - 1; i >= 0; i--) { CodingAgentDefinitionRegistry registry = registries.get(i); CodingAgentDefinition definition = registry == null ? null : registry.getDefinition(nameOrToolName); if (definition != null) { return definition; } } return null; } @Override public List listDefinitions() { if (registries.isEmpty()) { return Collections.emptyList(); } List ordered = new ArrayList(); for (CodingAgentDefinitionRegistry registry : registries) { if (registry == null || registry.listDefinitions() == null) { continue; } for (CodingAgentDefinition definition : registry.listDefinitions()) { mergeDefinition(ordered, definition); } } return ordered.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(ordered); } private void mergeDefinition(List ordered, CodingAgentDefinition candidate) { if (ordered == null || candidate == null || isBlank(candidate.getName())) { return; } String candidateName = normalize(candidate.getName()); String candidateToolName = normalize(candidate.getToolName()); for (Iterator iterator = ordered.iterator(); iterator.hasNext(); ) { CodingAgentDefinition existing = iterator.next(); if (existing == null) { iterator.remove(); continue; } if (sameKey(candidateName, existing.getName()) || sameKey(candidateToolName, existing.getToolName()) || sameKey(candidateName, existing.getToolName()) || sameKey(candidateToolName, existing.getName())) { iterator.remove(); } } ordered.add(candidate); } private boolean sameKey(String left, String right) { String normalizedRight = normalize(right); return left != null && normalizedRight != null && left.equals(normalizedRight); } private String normalize(String value) { return isBlank(value) ? null : value.trim().toLowerCase(Locale.ROOT); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/StaticCodingAgentDefinitionRegistry.java ================================================ package io.github.lnyocly.ai4j.coding.definition; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; public class StaticCodingAgentDefinitionRegistry implements CodingAgentDefinitionRegistry { private final Map definitions; private final List orderedDefinitions; public StaticCodingAgentDefinitionRegistry(List definitions) { if (definitions == null || definitions.isEmpty()) { this.definitions = Collections.emptyMap(); this.orderedDefinitions = Collections.emptyList(); return; } Map map = new LinkedHashMap(); List ordered = new ArrayList(); for (CodingAgentDefinition definition : definitions) { if (definition == null || isBlank(definition.getName())) { continue; } CodingAgentDefinition normalized = definition.toBuilder().build(); register(map, normalized.getName(), normalized); if (!isBlank(normalized.getToolName())) { register(map, normalized.getToolName(), normalized); } ordered.add(normalized); } this.definitions = Collections.unmodifiableMap(map); this.orderedDefinitions = Collections.unmodifiableList(ordered); } @Override public CodingAgentDefinition getDefinition(String nameOrToolName) { if (isBlank(nameOrToolName)) { return null; } return definitions.get(normalize(nameOrToolName)); } @Override public List listDefinitions() { return orderedDefinitions; } private void register(Map map, String key, CodingAgentDefinition definition) { String normalized = normalize(key); if (map.containsKey(normalized)) { throw new IllegalArgumentException("Duplicate coding agent definition: " + key); } map.put(normalized, definition); } private String normalize(String value) { return value == null ? null : value.trim().toLowerCase(Locale.ROOT); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateRequest.java ================================================ package io.github.lnyocly.ai4j.coding.delegate; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingDelegateRequest { private String definitionName; private String input; private String context; private String childSessionId; private Boolean background; private CodingSessionMode sessionMode; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateResult.java ================================================ package io.github.lnyocly.ai4j.coding.delegate; import io.github.lnyocly.ai4j.coding.task.CodingTaskStatus; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingDelegateResult { private String taskId; private String definitionName; private String parentSessionId; private String childSessionId; private boolean background; private CodingTaskStatus status; private String outputText; private String error; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateToolExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.delegate; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionScope; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import io.github.lnyocly.ai4j.coding.runtime.CodingRuntime; import java.util.Locale; public class CodingDelegateToolExecutor implements ToolExecutor { private final CodingRuntime runtime; private final CodingAgentDefinitionRegistry definitionRegistry; public CodingDelegateToolExecutor(CodingRuntime runtime, CodingAgentDefinitionRegistry definitionRegistry) { this.runtime = runtime; this.definitionRegistry = definitionRegistry; } @Override public String execute(AgentToolCall call) throws Exception { if (call == null || isBlank(call.getName())) { throw new IllegalArgumentException("delegate tool call is invalid"); } if (runtime == null) { throw new IllegalStateException("coding runtime is required for delegate tools"); } CodingSession session = CodingSessionScope.currentSession(); if (session == null) { throw new IllegalStateException("delegate tools require an active coding session"); } CodingAgentDefinition definition = requireDefinition(call.getName()); JSONObject arguments = parseArguments(call.getArguments()); CodingDelegateRequest request = CodingDelegateRequest.builder() .definitionName(definition.getName()) .input(firstNonBlank(arguments.getString("task"), arguments.getString("input"))) .context(arguments.getString("context")) .childSessionId(arguments.getString("childSessionId")) .background(parseBoolean(arguments, "background")) .sessionMode(parseSessionMode(arguments.getString("sessionMode"))) .build(); CodingDelegateResult result = runtime.delegate(session, request); JSONObject payload = new JSONObject(); payload.put("definitionName", result == null ? definition.getName() : result.getDefinitionName()); payload.put("toolName", definition.getToolName()); payload.put("taskId", result == null ? null : result.getTaskId()); payload.put("parentSessionId", result == null ? session.getSessionId() : result.getParentSessionId()); payload.put("childSessionId", result == null ? null : result.getChildSessionId()); payload.put("background", result != null && result.isBackground()); payload.put("status", result == null || result.getStatus() == null ? null : result.getStatus().name().toLowerCase(Locale.ROOT)); payload.put("output", result == null ? null : result.getOutputText()); payload.put("error", result == null ? null : result.getError()); return payload.toJSONString(); } private CodingAgentDefinition requireDefinition(String toolName) { CodingAgentDefinition definition = definitionRegistry == null ? null : definitionRegistry.getDefinition(toolName); if (definition == null) { throw new IllegalArgumentException("Unknown delegate tool: " + toolName); } return definition; } private JSONObject parseArguments(String raw) { if (isBlank(raw)) { return new JSONObject(); } try { JSONObject object = JSON.parseObject(raw); return object == null ? new JSONObject() : object; } catch (Exception ignored) { JSONObject object = new JSONObject(); object.put("task", raw); return object; } } private Boolean parseBoolean(JSONObject arguments, String key) { if (arguments == null || key == null || !arguments.containsKey(key)) { return null; } return arguments.getBoolean(key); } private CodingSessionMode parseSessionMode(String raw) { if (isBlank(raw)) { return null; } try { return CodingSessionMode.valueOf(raw.trim().toUpperCase(Locale.ROOT)); } catch (Exception ignored) { return null; } } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateToolRegistry.java ================================================ package io.github.lnyocly.ai4j.coding.delegate; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class CodingDelegateToolRegistry implements AgentToolRegistry { private final List tools; public CodingDelegateToolRegistry(CodingAgentDefinitionRegistry definitionRegistry) { List definitions = definitionRegistry == null ? Collections.emptyList() : definitionRegistry.listDefinitions(); if (definitions == null || definitions.isEmpty()) { this.tools = Collections.emptyList(); return; } List resolved = new ArrayList(); for (CodingAgentDefinition definition : definitions) { Tool tool = createTool(definition); if (tool != null) { resolved.add(tool); } } this.tools = Collections.unmodifiableList(resolved); } @Override public List getTools() { return new ArrayList(tools); } private Tool createTool(CodingAgentDefinition definition) { if (definition == null || isBlank(definition.getToolName())) { return null; } Map properties = new LinkedHashMap(); properties.put("task", property("string", "Task to delegate to this coding worker.")); properties.put("context", property("string", "Optional extra context for the delegated task.")); properties.put("background", property("boolean", "Whether to run the delegated task in the background.")); properties.put("sessionMode", property("string", "Optional session mode override: new or fork.")); properties.put("childSessionId", property("string", "Optional child session id override.")); Tool.Function.Parameter parameter = new Tool.Function.Parameter("object", properties, Arrays.asList("task")); Tool.Function function = new Tool.Function( definition.getToolName(), resolveDescription(definition), parameter ); Tool tool = new Tool(); tool.setType("function"); tool.setFunction(function); return tool; } private String resolveDescription(CodingAgentDefinition definition) { String description = definition.getDescription(); if (!isBlank(description)) { return description; } return "Delegate a coding task to worker " + definition.getName(); } private Tool.Function.Property property(String type, String description) { Tool.Function.Property property = new Tool.Function.Property(); property.setType(type); property.setDescription(description); return property; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopController.java ================================================ package io.github.lnyocly.ai4j.coding.loop; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.coding.CodingAgentRequest; import io.github.lnyocly.ai4j.coding.CodingAgentResult; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.regex.Pattern; public class CodingAgentLoopController { private static final String TOOL_ERROR_PREFIX = "TOOL_ERROR:"; private static final String APPROVAL_REJECTED_PREFIX = "[approval-rejected]"; private static final Pattern QUESTION_PATTERN = Pattern.compile( "(\\?|?|\\b(could you|can you|would you like|do you want|which|what|please confirm|need your input|need approval)\\b|请确认|请提供|你希望|是否需要)", Pattern.CASE_INSENSITIVE ); private static final Pattern COMPLETION_PATTERN = Pattern.compile( "^(done|completed|finished|implemented|updated|fixed|resolved|summary:|here('| i)s|已完成|完成了|已经|已实现|已修复|已更新|总结)", Pattern.CASE_INSENSITIVE ); private static final Pattern CONTINUE_PATTERN = Pattern.compile( "\\b(next|continue|continuing|proceed|then I will|I'll next|still need to|remaining work)\\b|继续|下一步|接下来|仍需", Pattern.CASE_INSENSITIVE ); public CodingAgentResult run(CodingSession session, CodingAgentRequest request) throws Exception { return execute(session, request, null, false); } public CodingAgentResult runStream(CodingSession session, CodingAgentRequest request, AgentListener listener) throws Exception { return execute(session, request, listener, true); } private CodingAgentResult execute(CodingSession session, CodingAgentRequest request, AgentListener listener, boolean stream) throws Exception { if (session == null) { throw new IllegalArgumentException("session is required"); } CodingLoopPolicy policy = CodingLoopPolicy.from(session.getOptions()); List aggregatedCalls = new ArrayList(); List aggregatedResults = new ArrayList(); CodingAgentResult lastResult = null; int totalSteps = 0; int turns = 0; int autoFollowUps = 0; String continuationPrompt = null; while (true) { throwIfInterrupted(); if (hasPositiveLimit(policy.getMaxTotalTurns()) && turns >= policy.getMaxTotalTurns()) { CodingLoopDecision forcedStop = stopDecision(turns, CodingStopReason.MAX_TOTAL_TURNS_REACHED, "Stopped after reaching the total turn limit."); session.recordLoopDecision(forcedStop); return aggregate(session, lastResult, aggregatedCalls, aggregatedResults, totalSteps, turns, autoFollowUps, forcedStop); } AgentListener effectiveListener = listener == null ? null : new StepOffsetAgentListener(listener, totalSteps); CodingAgentResult turnResult = stream ? session.runSingleTurnStream(turnRequest(request, continuationPrompt), effectiveListener, continuationPrompt) : session.runSingleTurn(turnRequest(request, continuationPrompt), continuationPrompt); turns += 1; totalSteps += turnResult == null ? 0 : turnResult.getSteps(); if (turnResult != null && turnResult.getToolCalls() != null) { aggregatedCalls.addAll(turnResult.getToolCalls()); } if (turnResult != null && turnResult.getToolResults() != null) { aggregatedResults.addAll(turnResult.getToolResults()); } lastResult = turnResult; CodingLoopDecision decision = decide( policy, turnResult, session.getLastAutoCompactResult(), session.getLastAutoCompactError(), turns, autoFollowUps ); session.recordLoopDecision(decision); if (!decision.isContinueLoop()) { return aggregate(session, lastResult, aggregatedCalls, aggregatedResults, totalSteps, turns, autoFollowUps, decision); } autoFollowUps += 1; continuationPrompt = decision.getContinuationPrompt(); request = null; } } private CodingAgentRequest turnRequest(CodingAgentRequest request, String continuationPrompt) { if (continuationPrompt == null || continuationPrompt.trim().isEmpty()) { return request; } return CodingAgentRequest.builder().build(); } private CodingLoopDecision decide(CodingLoopPolicy policy, CodingAgentResult result, CodingSessionCompactResult compactResult, Exception compactError, int turnNumber, int autoFollowUpsSoFar) { String outputText = result == null || result.getOutputText() == null ? "" : result.getOutputText().trim(); boolean compactApplied = compactResult != null; boolean approvalBlocked = hasApprovalBlockedResult(result); boolean toolError = hasToolError(result); boolean explicitQuestion = policy.isStopOnExplicitQuestion() && looksLikeQuestion(outputText); boolean candidateContinue = shouldContinue(policy, result, compactApplied, outputText); if (approvalBlocked && policy.isStopOnApprovalBlock()) { return stopDecision(turnNumber, CodingStopReason.BLOCKED_BY_APPROVAL, "Stopped because tool approval was rejected.") .toBuilder() .compactApplied(compactApplied) .build(); } if (explicitQuestion) { return stopDecision(turnNumber, CodingStopReason.NEEDS_USER_INPUT, "Stopped because the assistant asked for user input.") .toBuilder() .compactApplied(compactApplied) .build(); } if (toolError && !looksLikeCompleted(outputText)) { return stopDecision(turnNumber, CodingStopReason.BLOCKED_BY_TOOL_ERROR, "Stopped because a tool failed and the task could not continue safely.") .toBuilder() .compactApplied(compactApplied) .build(); } if (compactError != null && candidateContinue) { return stopDecision(turnNumber, CodingStopReason.ERROR, "Stopped because automatic compaction failed before continuation.") .toBuilder() .compactApplied(compactApplied) .build(); } if (!candidateContinue || !policy.isAutoContinueEnabled()) { return stopDecision(turnNumber, CodingStopReason.COMPLETED, "Stopped after the assistant completed the current task turn.") .toBuilder() .compactApplied(compactApplied) .build(); } if (hasPositiveLimit(policy.getMaxAutoFollowUps()) && autoFollowUpsSoFar >= policy.getMaxAutoFollowUps()) { return stopDecision(turnNumber, CodingStopReason.MAX_AUTO_FOLLOWUPS_REACHED, "Stopped after reaching the auto-follow-up limit.") .toBuilder() .compactApplied(compactApplied) .build(); } if (hasPositiveLimit(policy.getMaxTotalTurns()) && turnNumber >= policy.getMaxTotalTurns()) { return stopDecision(turnNumber, CodingStopReason.MAX_TOTAL_TURNS_REACHED, "Stopped after reaching the total turn limit.") .toBuilder() .compactApplied(compactApplied) .build(); } String continueReason = resolveContinueReason(result, compactApplied, outputText); CodingLoopDecision seedDecision = CodingLoopDecision.builder() .turnNumber(turnNumber) .continueLoop(true) .continueReason(continueReason) .summary(buildContinueSummary(turnNumber, continueReason, compactApplied)) .compactApplied(compactApplied) .build(); return seedDecision.toBuilder() .continuationPrompt(CodingContinuationPrompt.build(seedDecision, result, compactResult, turnNumber + 1)) .build(); } private boolean shouldContinue(CodingLoopPolicy policy, CodingAgentResult result, boolean compactApplied, String outputText) { if (!policy.isAutoContinueEnabled()) { return false; } if (looksLikeCompleted(outputText)) { return false; } if (result != null && result.getToolCalls() != null && !result.getToolCalls().isEmpty()) { return outputText.isEmpty() || looksTransitional(outputText); } if (compactApplied && policy.isContinueAfterCompact()) { return outputText.isEmpty() || looksTransitional(outputText); } return outputText.isEmpty() || looksTransitional(outputText); } private String resolveContinueReason(CodingAgentResult result, boolean compactApplied, String outputText) { if (compactApplied) { return CodingLoopDecision.CONTINUE_AFTER_COMPACTION; } if (result != null && result.getToolCalls() != null && !result.getToolCalls().isEmpty()) { return CodingLoopDecision.CONTINUE_AFTER_TOOL_WORK; } if (outputText.isEmpty() || looksTransitional(outputText)) { return CodingLoopDecision.CONTINUE_AUTONOMOUS_WORK; } return CodingLoopDecision.CONTINUE_AFTER_TOOL_WORK; } private String buildContinueSummary(int turnNumber, String continueReason, boolean compactApplied) { StringBuilder summary = new StringBuilder(); summary.append("Auto-continue after turn ").append(turnNumber); if (continueReason != null && !continueReason.trim().isEmpty()) { summary.append(" (").append(continueReason).append(")"); } if (compactApplied) { summary.append(" with compacted context"); } summary.append("."); return summary.toString(); } private CodingLoopDecision stopDecision(int turnNumber, CodingStopReason stopReason, String summary) { return CodingLoopDecision.builder() .turnNumber(turnNumber) .continueLoop(false) .stopReason(stopReason) .summary(summary) .build(); } private CodingAgentResult aggregate(CodingSession session, CodingAgentResult lastResult, List aggregatedCalls, List aggregatedResults, int totalSteps, int turns, int autoFollowUps, CodingLoopDecision decision) { return CodingAgentResult.builder() .sessionId(session == null ? null : session.getSessionId()) .outputText(lastResult == null ? null : lastResult.getOutputText()) .rawResponse(lastResult == null ? null : lastResult.getRawResponse()) .toolCalls(aggregatedCalls.isEmpty() ? Collections.emptyList() : aggregatedCalls) .toolResults(aggregatedResults.isEmpty() ? Collections.emptyList() : aggregatedResults) .steps(totalSteps) .turns(turns) .stopReason(decision == null ? null : decision.getStopReason()) .autoContinued(autoFollowUps > 0) .autoFollowUpCount(autoFollowUps) .lastCompactApplied(decision != null && decision.isCompactApplied()) .build(); } private boolean hasApprovalBlockedResult(CodingAgentResult result) { if (result == null || result.getToolResults() == null) { return false; } for (AgentToolResult toolResult : result.getToolResults()) { if (toolResult != null && toolResult.getOutput() != null && toolResult.getOutput().startsWith(APPROVAL_REJECTED_PREFIX)) { return true; } } return false; } private boolean hasToolError(CodingAgentResult result) { if (result == null || result.getToolResults() == null) { return false; } for (AgentToolResult toolResult : result.getToolResults()) { if (toolResult != null && toolResult.getOutput() != null && toolResult.getOutput().startsWith(TOOL_ERROR_PREFIX)) { return true; } } return false; } private boolean looksLikeQuestion(String text) { return text != null && !text.trim().isEmpty() && QUESTION_PATTERN.matcher(text).find(); } private boolean looksLikeCompleted(String text) { return text != null && !text.trim().isEmpty() && COMPLETION_PATTERN.matcher(text.trim()).find(); } private boolean looksTransitional(String text) { if (text == null || text.trim().isEmpty()) { return false; } String normalized = text.trim().toLowerCase(Locale.ROOT); return CONTINUE_PATTERN.matcher(normalized).find(); } private boolean hasPositiveLimit(int limit) { return limit > 0; } private void throwIfInterrupted() throws InterruptedException { if (Thread.currentThread().isInterrupted()) { throw new InterruptedException("Coding agent loop interrupted"); } } private static final class StepOffsetAgentListener implements AgentListener { private final AgentListener delegate; private final int stepOffset; private StepOffsetAgentListener(AgentListener delegate, int stepOffset) { this.delegate = delegate; this.stepOffset = Math.max(0, stepOffset); } @Override public void onEvent(AgentEvent event) { if (delegate == null) { return; } if (event == null) { delegate.onEvent(null); return; } Integer originalStep = event.getStep(); int adjustedStep = originalStep == null ? stepOffset : originalStep + stepOffset; delegate.onEvent(AgentEvent.builder() .type(event.getType()) .step(adjustedStep) .message(event.getMessage()) .payload(event.getPayload()) .build()); } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingContinuationPrompt.java ================================================ package io.github.lnyocly.ai4j.coding.loop; import io.github.lnyocly.ai4j.coding.CodingAgentResult; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import java.util.List; public final class CodingContinuationPrompt { private CodingContinuationPrompt() { } public static String build(CodingLoopDecision decision, CodingAgentResult previousResult, CodingSessionCompactResult compactResult, int nextTurnNumber) { StringBuilder prompt = new StringBuilder(); prompt.append("Internal continuation. This is not a new user message. "); prompt.append("Continue the same coding task using the existing workspace and session context.\n"); prompt.append("Do not restate prior work or ask the user to repeat information already present.\n"); prompt.append("If the task is already complete, respond with a concise completion summary and stop.\n"); prompt.append("If user clarification or approval is required, state that clearly and stop.\n"); prompt.append("Otherwise continue directly with the next concrete step, using tools when useful.\n"); if (decision != null && decision.getContinueReason() != null) { prompt.append("Continuation reason: ").append(decision.getContinueReason()).append(".\n"); } if (decision != null && decision.isCompactApplied()) { prompt.append("A context compaction was just applied. Re-anchor yourself from the retained checkpoint and recent messages.\n"); appendCompactReanchor(prompt, compactResult); } if (previousResult != null && previousResult.getOutputText() != null && !previousResult.getOutputText().trim().isEmpty()) { prompt.append("Latest assistant output:\n"); prompt.append(previousResult.getOutputText().trim()).append("\n"); } prompt.append("Continuation turn #").append(nextTurnNumber).append("."); return prompt.toString(); } private static void appendCompactReanchor(StringBuilder prompt, CodingSessionCompactResult compactResult) { if (prompt == null || compactResult == null) { return; } if (!isBlank(compactResult.getStrategy())) { prompt.append("Compaction strategy: ").append(compactResult.getStrategy()).append(".\n"); } if (compactResult.isCheckpointReused()) { prompt.append("The existing checkpoint was updated with new delta context.\n"); } CodingSessionCheckpoint checkpoint = compactResult.getCheckpoint(); if (checkpoint != null) { if (!isBlank(checkpoint.getGoal())) { prompt.append("Checkpoint goal: ").append(clip(checkpoint.getGoal(), 220)).append("\n"); } if (checkpoint.isSplitTurn()) { prompt.append("This checkpoint came from a split-turn compaction. Use the kept recent messages as the latest turn tail.\n"); } appendList(prompt, "Checkpoint constraints", checkpoint.getConstraints(), 3); appendList(prompt, "Checkpoint blocked items", checkpoint.getBlockedItems(), 2); appendList(prompt, "Checkpoint next steps", checkpoint.getNextSteps(), 3); appendList(prompt, "Checkpoint critical context", checkpoint.getCriticalContext(), 3); appendList(prompt, "Checkpoint in-progress items", checkpoint.getInProgressItems(), 2); appendProcesses(prompt, checkpoint.getProcessSnapshots(), 2); return; } if (!isBlank(compactResult.getSummary())) { prompt.append("Compact summary excerpt: ") .append(clip(singleLine(compactResult.getSummary()), 280)) .append("\n"); } } private static void appendList(StringBuilder prompt, String label, List values, int maxItems) { if (prompt == null || values == null || values.isEmpty() || maxItems <= 0) { return; } prompt.append(label).append(":\n"); int written = 0; for (String value : values) { if (isBlank(value)) { continue; } prompt.append("- ").append(clip(value.trim(), 220)).append("\n"); written += 1; if (written >= maxItems) { break; } } } private static void appendProcesses(StringBuilder prompt, List snapshots, int maxItems) { if (prompt == null || snapshots == null || snapshots.isEmpty() || maxItems <= 0) { return; } prompt.append("Checkpoint process snapshots:\n"); int written = 0; for (StoredProcessSnapshot snapshot : snapshots) { if (snapshot == null || isBlank(snapshot.getProcessId())) { continue; } StringBuilder line = new StringBuilder(); line.append("- ").append(snapshot.getProcessId()); if (snapshot.getStatus() != null) { line.append(" [").append(snapshot.getStatus()).append("]"); } if (!isBlank(snapshot.getCommand())) { line.append(" ").append(clip(snapshot.getCommand().trim(), 140)); } if (snapshot.isRestored()) { line.append(" (restored snapshot)"); } prompt.append(line).append("\n"); written += 1; if (written >= maxItems) { break; } } } private static String singleLine(String value) { return isBlank(value) ? "" : value.replace('\r', ' ').replace('\n', ' ').trim(); } private static String clip(String value, int maxChars) { if (isBlank(value) || value.length() <= maxChars) { return value; } return value.substring(0, Math.max(0, maxChars - 3)) + "..."; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingLoopDecision.java ================================================ package io.github.lnyocly.ai4j.coding.loop; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingLoopDecision { public static final String CONTINUE_AFTER_TOOL_WORK = "CONTINUE_AFTER_TOOL_WORK"; public static final String CONTINUE_AFTER_COMPACTION = "CONTINUE_AFTER_COMPACTION"; public static final String CONTINUE_AUTONOMOUS_WORK = "CONTINUE_AUTONOMOUS_WORK"; private int turnNumber; private boolean continueLoop; private String continueReason; private CodingStopReason stopReason; private String summary; private String continuationPrompt; private boolean compactApplied; public boolean isBlocked() { return stopReason == CodingStopReason.BLOCKED_BY_APPROVAL || stopReason == CodingStopReason.BLOCKED_BY_TOOL_ERROR; } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingLoopPolicy.java ================================================ package io.github.lnyocly.ai4j.coding.loop; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingLoopPolicy { @Builder.Default private boolean autoContinueEnabled = true; @Builder.Default private int maxAutoFollowUps = 2; @Builder.Default private int maxTotalTurns = 6; @Builder.Default private boolean continueAfterCompact = true; @Builder.Default private boolean stopOnApprovalBlock = true; @Builder.Default private boolean stopOnExplicitQuestion = true; public static CodingLoopPolicy from(CodingAgentOptions options) { if (options == null) { return CodingLoopPolicy.builder().build(); } return CodingLoopPolicy.builder() .autoContinueEnabled(options.isAutoContinueEnabled()) .maxAutoFollowUps(options.getMaxAutoFollowUps()) .maxTotalTurns(options.getMaxTotalTurns()) .continueAfterCompact(options.isContinueAfterCompact()) .stopOnApprovalBlock(options.isStopOnApprovalBlock()) .stopOnExplicitQuestion(options.isStopOnExplicitQuestion()) .build(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingStopReason.java ================================================ package io.github.lnyocly.ai4j.coding.loop; public enum CodingStopReason { COMPLETED, NEEDS_USER_INPUT, BLOCKED_BY_APPROVAL, BLOCKED_BY_TOOL_ERROR, MAX_AUTO_FOLLOWUPS_REACHED, MAX_TOTAL_TURNS_REACHED, INTERRUPTED, ERROR } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/patch/ApplyPatchFileChange.java ================================================ package io.github.lnyocly.ai4j.coding.patch; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ApplyPatchFileChange { private String path; private String operation; private int linesAdded; private int linesRemoved; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/patch/ApplyPatchResult.java ================================================ package io.github.lnyocly.ai4j.coding.patch; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ApplyPatchResult { private int filesChanged; private int operationsApplied; private List changedFiles; private List fileChanges; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/policy/CodingToolContextPolicy.java ================================================ package io.github.lnyocly.ai4j.coding.policy; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import lombok.Builder; import lombok.Data; import java.util.Set; @Data @Builder(toBuilder = true) public class CodingToolContextPolicy { private AgentToolRegistry toolRegistry; private ToolExecutor toolExecutor; private Set allowedToolNames; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/policy/CodingToolPolicyResolver.java ================================================ package io.github.lnyocly.ai4j.coding.policy; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; public class CodingToolPolicyResolver { public CodingToolContextPolicy resolve(AgentToolRegistry baseRegistry, ToolExecutor baseExecutor, CodingAgentDefinition definition) { Set allowedToolNames = normalize(definition == null ? null : definition.getAllowedToolNames()); if (allowedToolNames.isEmpty()) { return CodingToolContextPolicy.builder() .toolRegistry(baseRegistry == null ? StaticToolRegistry.empty() : baseRegistry) .toolExecutor(baseExecutor) .allowedToolNames(Collections.emptySet()) .build(); } return CodingToolContextPolicy.builder() .toolRegistry(filterRegistry(baseRegistry, allowedToolNames)) .toolExecutor(wrapExecutor(baseExecutor, allowedToolNames)) .allowedToolNames(allowedToolNames) .build(); } private AgentToolRegistry filterRegistry(AgentToolRegistry baseRegistry, Set allowedToolNames) { if (baseRegistry == null) { return StaticToolRegistry.empty(); } List filtered = new ArrayList(); List tools = baseRegistry.getTools(); if (tools != null) { for (Object tool : tools) { if (supports(tool, allowedToolNames)) { filtered.add(tool); } } } return new StaticToolRegistry(filtered); } private boolean supports(Object tool, Set allowedToolNames) { String toolName = extractToolName(tool); return toolName != null && allowedToolNames.contains(normalize(toolName)); } private String extractToolName(Object tool) { if (!(tool instanceof Tool)) { return null; } Tool.Function function = ((Tool) tool).getFunction(); return function == null ? null : function.getName(); } private ToolExecutor wrapExecutor(final ToolExecutor baseExecutor, final Set allowedToolNames) { return new ToolExecutor() { @Override public String execute(AgentToolCall call) throws Exception { String toolName = call == null ? null : call.getName(); if (toolName == null || !allowedToolNames.contains(normalize(toolName))) { throw new IllegalArgumentException("Tool is not allowed in this delegated coding session: " + toolName); } if (baseExecutor == null) { throw new IllegalStateException("No tool executor available for delegated coding session"); } return baseExecutor.execute(call); } }; } private Set normalize(Set toolNames) { if (toolNames == null || toolNames.isEmpty()) { return Collections.emptySet(); } Set normalized = new LinkedHashSet(); for (String toolName : toolNames) { String value = normalize(toolName); if (value != null) { normalized.add(value); } } return normalized.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(normalized); } private String normalize(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed.toLowerCase(Locale.ROOT); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/BashProcessInfo.java ================================================ package io.github.lnyocly.ai4j.coding.process; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class BashProcessInfo { private String processId; private String command; private String workingDirectory; private BashProcessStatus status; private Long pid; private Integer exitCode; private long startedAt; private Long endedAt; private boolean restored; private boolean controlAvailable; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/BashProcessLogChunk.java ================================================ package io.github.lnyocly.ai4j.coding.process; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class BashProcessLogChunk { private String processId; private long offset; private long nextOffset; private boolean truncated; private String content; private BashProcessStatus status; private Integer exitCode; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/BashProcessStatus.java ================================================ package io.github.lnyocly.ai4j.coding.process; public enum BashProcessStatus { RUNNING, EXITED, STOPPED } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/SessionProcessRegistry.java ================================================ package io.github.lnyocly.ai4j.coding.process; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.shell.ShellCommandSupport; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.charset.Charset; import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; public class SessionProcessRegistry implements AutoCloseable { private final WorkspaceContext workspaceContext; private final CodingAgentOptions options; private final Map processes = new ConcurrentHashMap(); private final Map restoredSnapshots = new ConcurrentHashMap(); public SessionProcessRegistry(WorkspaceContext workspaceContext, CodingAgentOptions options) { this.workspaceContext = workspaceContext; this.options = options; } public BashProcessInfo start(String command, String cwd) throws IOException { if (isBlank(command)) { throw new IllegalArgumentException("command is required"); } Path workingDirectory = workspaceContext.resolveWorkspacePath(cwd); ProcessBuilder processBuilder = new ProcessBuilder(ShellCommandSupport.buildShellCommand(command)); processBuilder.directory(workingDirectory.toFile()); Process process = processBuilder.start(); Charset shellCharset = ShellCommandSupport.resolveShellCharset(); String processId = "proc_" + UUID.randomUUID().toString().replace("-", ""); ManagedProcess managed = new ManagedProcess(processId, command, workingDirectory.toString(), process, options.getMaxProcessOutputChars(), shellCharset); restoredSnapshots.remove(processId); processes.put(processId, managed); managed.startReaders(); managed.startWatcher(); return managed.snapshot(); } public BashProcessInfo status(String processId) { ManagedProcess managed = processes.get(processId); if (managed != null) { return managed.snapshot(); } StoredProcessSnapshot restored = restoredSnapshots.get(processId); if (restored != null) { return toProcessInfo(restored); } throw new IllegalArgumentException("Unknown processId: " + processId); } public List list() { List result = new ArrayList(); for (ManagedProcess managed : processes.values()) { result.add(managed.snapshot()); } for (StoredProcessSnapshot restored : restoredSnapshots.values()) { result.add(toProcessInfo(restored)); } result.sort(Comparator.comparingLong(BashProcessInfo::getStartedAt)); return result; } public List exportSnapshots() { Map snapshots = new LinkedHashMap(); for (StoredProcessSnapshot restored : restoredSnapshots.values()) { if (restored != null && !isBlank(restored.getProcessId())) { snapshots.put(restored.getProcessId(), restored.toBuilder().build()); } } int previewChars = Math.max(256, options.getDefaultBashLogChars()); for (ManagedProcess managed : processes.values()) { StoredProcessSnapshot snapshot = managed.metadataSnapshot(previewChars); snapshots.put(snapshot.getProcessId(), snapshot); } List result = new ArrayList(snapshots.values()); result.sort(Comparator.comparingLong(StoredProcessSnapshot::getStartedAt)); return result; } public void restoreSnapshots(List snapshots) { restoredSnapshots.clear(); if (snapshots == null) { return; } for (StoredProcessSnapshot snapshot : snapshots) { if (snapshot == null || isBlank(snapshot.getProcessId()) || processes.containsKey(snapshot.getProcessId())) { continue; } restoredSnapshots.put(snapshot.getProcessId(), snapshot.toBuilder() .restored(true) .controlAvailable(false) .build()); } } public int activeCount() { int count = 0; for (ManagedProcess managed : processes.values()) { if (managed.status == BashProcessStatus.RUNNING) { count++; } } return count; } public int restoredCount() { return restoredSnapshots.size(); } public BashProcessLogChunk logs(String processId, Long offset, Integer limit) { ManagedProcess managed = processes.get(processId); if (managed != null) { long effectiveOffset = offset == null || offset < 0 ? 0L : offset.longValue(); int effectiveLimit = limit == null || limit <= 0 ? options.getDefaultBashLogChars() : limit.intValue(); return managed.readLogs(effectiveOffset, effectiveLimit); } StoredProcessSnapshot restored = restoredSnapshots.get(processId); if (restored != null) { return restoredLogs(restored, offset, limit); } throw new IllegalArgumentException("Unknown processId: " + processId); } public int write(String processId, String input) throws IOException { if (input == null) { input = ""; } ManagedProcess managed = getLiveProcess(processId); OutputStream outputStream = managed.process.getOutputStream(); byte[] bytes = input.getBytes(managed.charset); outputStream.write(bytes); outputStream.flush(); return bytes.length; } public BashProcessInfo stop(String processId) { ManagedProcess managed = getLiveProcess(processId); managed.stop(options.getProcessStopGraceMs()); return managed.snapshot(); } @Override public void close() { for (ManagedProcess managed : processes.values()) { managed.stop(options.getProcessStopGraceMs()); } } private ManagedProcess getLiveProcess(String processId) { ManagedProcess managed = processes.get(processId); if (managed != null) { return managed; } if (restoredSnapshots.containsKey(processId)) { throw new IllegalStateException("Process " + processId + " was restored as metadata only; live control is unavailable"); } throw new IllegalArgumentException("Unknown processId: " + processId); } private BashProcessInfo toProcessInfo(StoredProcessSnapshot snapshot) { return BashProcessInfo.builder() .processId(snapshot.getProcessId()) .command(snapshot.getCommand()) .workingDirectory(snapshot.getWorkingDirectory()) .status(snapshot.getStatus()) .pid(snapshot.getPid()) .exitCode(snapshot.getExitCode()) .startedAt(snapshot.getStartedAt()) .endedAt(snapshot.getEndedAt()) .restored(true) .controlAvailable(false) .build(); } private BashProcessLogChunk restoredLogs(StoredProcessSnapshot snapshot, Long offset, Integer limit) { String preview = snapshot.getLastLogPreview() == null ? "" : snapshot.getLastLogPreview(); long previewOffset = Math.max(0L, snapshot.getLastLogOffset() - preview.length()); long requestedOffset = offset == null || offset < 0 ? previewOffset : Math.max(previewOffset, offset.longValue()); int from = (int) Math.max(0L, requestedOffset - previewOffset); int maxChars = limit == null || limit <= 0 ? preview.length() : Math.max(1, limit.intValue()); int to = Math.min(preview.length(), from + maxChars); return BashProcessLogChunk.builder() .processId(snapshot.getProcessId()) .offset(requestedOffset) .nextOffset(previewOffset + to) .truncated(offset != null && offset.longValue() < previewOffset) .content(preview.substring(Math.min(from, preview.length()), Math.min(to, preview.length()))) .status(snapshot.getStatus()) .exitCode(snapshot.getExitCode()) .build(); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static class ManagedProcess { private final String processId; private final String command; private final String workingDirectory; private final Process process; private final ProcessOutputBuffer outputBuffer; private final long startedAt; private final Long pid; private final Charset charset; private volatile BashProcessStatus status; private volatile Integer exitCode; private volatile Long endedAt; private ManagedProcess(String processId, String command, String workingDirectory, Process process, int maxOutputChars, Charset charset) { this.processId = processId; this.command = command; this.workingDirectory = workingDirectory; this.process = process; this.outputBuffer = new ProcessOutputBuffer(maxOutputChars); this.startedAt = System.currentTimeMillis(); this.pid = safePid(process); this.charset = charset; this.status = BashProcessStatus.RUNNING; } private void startReaders() { Thread stdoutThread = new Thread(new StreamCollector(process.getInputStream(), outputBuffer, "[stdout] ", charset), processId + "-stdout"); Thread stderrThread = new Thread(new StreamCollector(process.getErrorStream(), outputBuffer, "[stderr] ", charset), processId + "-stderr"); stdoutThread.setDaemon(true); stderrThread.setDaemon(true); stdoutThread.start(); stderrThread.start(); } private void startWatcher() { Thread watcher = new Thread(() -> { try { int code = process.waitFor(); exitCode = code; if (status == BashProcessStatus.RUNNING) { status = BashProcessStatus.EXITED; } endedAt = System.currentTimeMillis(); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } }, processId + "-watcher"); watcher.setDaemon(true); watcher.start(); } private BashProcessLogChunk readLogs(long offset, int limit) { ProcessOutputBuffer.Chunk chunk = outputBuffer.read(offset, limit); return BashProcessLogChunk.builder() .processId(processId) .offset(chunk.getOffset()) .nextOffset(chunk.getNextOffset()) .truncated(chunk.isTruncated()) .content(chunk.getContent()) .status(status) .exitCode(exitCode) .build(); } private BashProcessInfo snapshot() { return BashProcessInfo.builder() .processId(processId) .command(command) .workingDirectory(workingDirectory) .status(status) .pid(pid) .exitCode(exitCode) .startedAt(startedAt) .endedAt(endedAt) .restored(false) .controlAvailable(true) .build(); } private StoredProcessSnapshot metadataSnapshot(int previewChars) { return StoredProcessSnapshot.builder() .processId(processId) .command(command) .workingDirectory(workingDirectory) .status(status) .pid(pid) .exitCode(exitCode) .startedAt(startedAt) .endedAt(endedAt) .lastLogOffset(outputBuffer.nextOffset()) .lastLogPreview(outputBuffer.tail(previewChars)) .restored(false) .controlAvailable(true) .build(); } private void stop(long graceMs) { if (!process.isAlive()) { if (status == BashProcessStatus.RUNNING) { status = BashProcessStatus.EXITED; endedAt = System.currentTimeMillis(); try { exitCode = process.exitValue(); } catch (IllegalThreadStateException ignored) { } } return; } process.destroy(); try { if (!process.waitFor(graceMs, TimeUnit.MILLISECONDS)) { process.destroyForcibly(); process.waitFor(graceMs, TimeUnit.MILLISECONDS); } } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } status = BashProcessStatus.STOPPED; endedAt = System.currentTimeMillis(); try { exitCode = process.exitValue(); } catch (IllegalThreadStateException ignored) { exitCode = -1; } } private static Long safePid(Process process) { try { return (Long) process.getClass().getMethod("pid").invoke(process); } catch (Exception ignored) { return null; } } } private static class StreamCollector implements Runnable { private final InputStream inputStream; private final ProcessOutputBuffer outputBuffer; private final String prefix; private final Charset charset; private StreamCollector(InputStream inputStream, ProcessOutputBuffer outputBuffer, String prefix, Charset charset) { this.inputStream = inputStream; this.outputBuffer = outputBuffer; this.prefix = prefix; this.charset = charset; } @Override public void run() { try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) { String line; while ((line = reader.readLine()) != null) { outputBuffer.append(prefix + line + "\n"); } } catch (IOException ignored) { } } } private static class ProcessOutputBuffer { private final int maxChars; private final StringBuilder buffer = new StringBuilder(); private long startOffset; private ProcessOutputBuffer(int maxChars) { this.maxChars = Math.max(1024, maxChars); } private synchronized void append(String value) { if (value == null || value.isEmpty()) { return; } buffer.append(value); trim(); } private synchronized Chunk read(long offset, int limit) { long safeOffset = Math.max(offset, startOffset); int from = (int) (safeOffset - startOffset); int safeLimit = Math.max(1, limit); int to = Math.min(buffer.length(), from + safeLimit); String content = buffer.substring(Math.min(from, buffer.length()), Math.min(to, buffer.length())); long nextOffset = startOffset + to; return new Chunk(safeOffset, nextOffset, offset < startOffset, content); } private synchronized long nextOffset() { return startOffset + buffer.length(); } private synchronized String tail(int maxChars) { int safeMax = Math.max(1, maxChars); if (buffer.length() <= safeMax) { return buffer.toString(); } return buffer.substring(buffer.length() - safeMax); } private void trim() { if (buffer.length() <= maxChars) { return; } int overflow = buffer.length() - maxChars; buffer.delete(0, overflow); startOffset += overflow; } private static class Chunk { private final long offset; private final long nextOffset; private final boolean truncated; private final String content; private Chunk(long offset, long nextOffset, boolean truncated, String content) { this.offset = offset; this.nextOffset = nextOffset; this.truncated = truncated; this.content = content; } private long getOffset() { return offset; } private long getNextOffset() { return nextOffset; } private boolean isTruncated() { return truncated; } private String getContent() { return content; } } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/StoredProcessSnapshot.java ================================================ package io.github.lnyocly.ai4j.coding.process; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class StoredProcessSnapshot { private String processId; private String command; private String workingDirectory; private BashProcessStatus status; private Long pid; private Integer exitCode; private long startedAt; private Long endedAt; private long lastLogOffset; private String lastLogPreview; private boolean restored; private boolean controlAvailable; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/prompt/CodingContextPromptAssembler.java ================================================ package io.github.lnyocly.ai4j.coding.prompt; import io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor; import io.github.lnyocly.ai4j.coding.shell.ShellCommandSupport; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.skill.SkillDescriptor; import io.github.lnyocly.ai4j.skill.Skills; import java.util.ArrayList; import java.util.List; public final class CodingContextPromptAssembler { private CodingContextPromptAssembler() { } public static String mergeSystemPrompt(String basePrompt, WorkspaceContext workspaceContext) { String workspacePrompt = buildWorkspacePrompt(workspaceContext); if (isBlank(basePrompt)) { return workspacePrompt; } if (isBlank(workspacePrompt)) { return basePrompt; } return basePrompt + "\n\n" + workspacePrompt; } private static String buildWorkspacePrompt(WorkspaceContext workspaceContext) { if (workspaceContext == null) { return null; } StringBuilder builder = new StringBuilder(); builder.append("You are a coding agent operating inside a local workspace.\n"); builder.append("Workspace root: ").append(workspaceContext.getRoot().toString()).append("\n"); if (!isBlank(workspaceContext.getDescription())) { builder.append("Workspace description: ").append(workspaceContext.getDescription()).append("\n"); } builder.append("Available built-in tools: bash, read_file, write_file, apply_patch.\n"); builder.append("Use bash for search, git, build, test, and process management. Use read_file before making changes. Use write_file for full-file create/overwrite/append operations, especially for new files. Use apply_patch for structured diffs.\n"); builder.append("Tool-call rules: only call a tool when you have a complete payload. ") .append("For bash, always send a JSON object like {\"action\":\"exec\",\"command\":\"...\"} and never omit command for exec/start. ") .append("Use bash action=exec only for non-interactive commands that will exit by themselves. If a command may wait for stdin, open a REPL, start a server, tail logs, or keep running, use bash action=start and then bash action=logs/status/write/stop. ") .append("For read_file, include path. For write_file, include path and content, plus optional mode=create|overwrite|append. Relative paths resolve from the workspace root and absolute paths are allowed. For apply_patch, include patch.\n"); builder.append("apply_patch must use the exact grammar: *** Begin Patch, then *** Add File:/*** Update File:/*** Delete File:, and end with *** End Patch.\n"); builder.append(ShellCommandSupport.buildShellUsageGuidance()).append("\n"); if (!workspaceContext.isAllowOutsideWorkspace()) { builder.append("Do not rely on files outside the workspace root unless the user explicitly allows it.\n"); } appendSkillGuidance(builder, workspaceContext.getAvailableSkills()); return builder.toString().trim(); } private static void appendSkillGuidance(StringBuilder builder, List availableSkills) { String skillPrompt = Skills.buildAvailableSkillsPrompt(toSkillDescriptors(availableSkills)); if (isBlank(skillPrompt)) { return; } builder.append(skillPrompt).append("\n"); } private static List toSkillDescriptors(List availableSkills) { if (availableSkills == null || availableSkills.isEmpty()) { return null; } List descriptors = new ArrayList(); for (CodingSkillDescriptor skill : availableSkills) { if (skill == null) { continue; } descriptors.add(SkillDescriptor.builder() .name(skill.getName()) .description(skill.getDescription()) .skillFilePath(skill.getSkillFilePath()) .source(skill.getSource()) .build()); } return descriptors; } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value.trim(); } } return null; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/runtime/CodingRuntime.java ================================================ package io.github.lnyocly.ai4j.coding.runtime; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.task.CodingTask; import java.util.List; public interface CodingRuntime { CodingDelegateResult delegate(CodingSession parentSession, CodingDelegateRequest request) throws Exception; void addListener(CodingRuntimeListener listener); void removeListener(CodingRuntimeListener listener); CodingTask getTask(String taskId); List listTasks(); List listTasksByParentSessionId(String parentSessionId); List listSessionLinks(String parentSessionId); CodingAgentDefinitionRegistry getDefinitionRegistry(); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/runtime/CodingRuntimeListener.java ================================================ package io.github.lnyocly.ai4j.coding.runtime; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.task.CodingTask; public interface CodingRuntimeListener { void onTaskCreated(CodingTask task, CodingSessionLink link); void onTaskUpdated(CodingTask task); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/runtime/DefaultCodingRuntime.java ================================================ package io.github.lnyocly.ai4j.coding.runtime; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.AgentContext; import io.github.lnyocly.ai4j.agent.AgentSession; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.CodingAgentBuilder; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.CodingAgentResult; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionState; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult; import io.github.lnyocly.ai4j.coding.policy.CodingToolContextPolicy; import io.github.lnyocly.ai4j.coding.policy.CodingToolPolicyResolver; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.session.CodingSessionLinkStore; import io.github.lnyocly.ai4j.coding.task.CodingTask; import io.github.lnyocly.ai4j.coding.task.CodingTaskManager; import io.github.lnyocly.ai4j.coding.task.CodingTaskProgress; import io.github.lnyocly.ai4j.coding.task.CodingTaskStatus; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; public class DefaultCodingRuntime implements CodingRuntime { private final WorkspaceContext workspaceContext; private final CodingAgentOptions options; private final AgentToolRegistry customToolRegistry; private final ToolExecutor customToolExecutor; private final CodingAgentDefinitionRegistry definitionRegistry; private final CodingTaskManager taskManager; private final CodingSessionLinkStore sessionLinkStore; private final CodingToolPolicyResolver toolPolicyResolver; private final SubAgentRegistry subAgentRegistry; private final HandoffPolicy handoffPolicy; private final ExecutorService executorService; private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList(); public DefaultCodingRuntime(WorkspaceContext workspaceContext, CodingAgentOptions options, AgentToolRegistry customToolRegistry, ToolExecutor customToolExecutor, CodingAgentDefinitionRegistry definitionRegistry, CodingTaskManager taskManager, CodingSessionLinkStore sessionLinkStore, CodingToolPolicyResolver toolPolicyResolver) { this(workspaceContext, options, customToolRegistry, customToolExecutor, definitionRegistry, taskManager, sessionLinkStore, toolPolicyResolver, null, null); } public DefaultCodingRuntime(WorkspaceContext workspaceContext, CodingAgentOptions options, AgentToolRegistry customToolRegistry, ToolExecutor customToolExecutor, CodingAgentDefinitionRegistry definitionRegistry, CodingTaskManager taskManager, CodingSessionLinkStore sessionLinkStore, CodingToolPolicyResolver toolPolicyResolver, SubAgentRegistry subAgentRegistry, HandoffPolicy handoffPolicy) { this(workspaceContext, options, customToolRegistry, customToolExecutor, definitionRegistry, taskManager, sessionLinkStore, toolPolicyResolver, Executors.newCachedThreadPool(new ThreadFactory() { @Override public Thread newThread(Runnable runnable) { Thread thread = new Thread(runnable, "ai4j-coding-runtime"); thread.setDaemon(true); return thread; } }), subAgentRegistry, handoffPolicy); } public DefaultCodingRuntime(WorkspaceContext workspaceContext, CodingAgentOptions options, AgentToolRegistry customToolRegistry, ToolExecutor customToolExecutor, CodingAgentDefinitionRegistry definitionRegistry, CodingTaskManager taskManager, CodingSessionLinkStore sessionLinkStore, CodingToolPolicyResolver toolPolicyResolver, ExecutorService executorService) { this(workspaceContext, options, customToolRegistry, customToolExecutor, definitionRegistry, taskManager, sessionLinkStore, toolPolicyResolver, executorService, null, null); } public DefaultCodingRuntime(WorkspaceContext workspaceContext, CodingAgentOptions options, AgentToolRegistry customToolRegistry, ToolExecutor customToolExecutor, CodingAgentDefinitionRegistry definitionRegistry, CodingTaskManager taskManager, CodingSessionLinkStore sessionLinkStore, CodingToolPolicyResolver toolPolicyResolver, ExecutorService executorService, SubAgentRegistry subAgentRegistry, HandoffPolicy handoffPolicy) { this.workspaceContext = workspaceContext; this.options = options == null ? CodingAgentOptions.builder().build() : options; this.customToolRegistry = customToolRegistry; this.customToolExecutor = customToolExecutor; this.definitionRegistry = definitionRegistry; this.taskManager = taskManager; this.sessionLinkStore = sessionLinkStore; this.toolPolicyResolver = toolPolicyResolver == null ? new CodingToolPolicyResolver() : toolPolicyResolver; this.subAgentRegistry = subAgentRegistry; this.handoffPolicy = handoffPolicy; this.executorService = executorService; } @Override public CodingDelegateResult delegate(final CodingSession parentSession, final CodingDelegateRequest request) throws Exception { if (parentSession == null) { throw new IllegalArgumentException("parentSession is required"); } CodingAgentDefinition definition = requireDefinition(request); boolean background = request != null && request.getBackground() != null ? request.getBackground().booleanValue() : definition.isBackground(); CodingSessionMode sessionMode = request != null && request.getSessionMode() != null ? request.getSessionMode() : defaultSessionMode(definition); String taskId = UUID.randomUUID().toString(); String childSessionId = firstNonBlank( request == null ? null : request.getChildSessionId(), parentSession.getSessionId() + "-delegate-" + UUID.randomUUID().toString().substring(0, 8) ); long now = System.currentTimeMillis(); CodingSessionState seedState = resolveSeedState(parentSession, sessionMode); CodingTask queuedTask = persistInitialTask(CodingTask.builder() .taskId(taskId) .definitionName(definition.getName()) .parentSessionId(parentSession.getSessionId()) .childSessionId(childSessionId) .input(composeInput(request)) .background(background) .status(CodingTaskStatus.QUEUED) .progress(progress("queued", "Task queued for execution.", 0, now)) .createdAtEpochMs(now) .build()); CodingSessionLink link = saveLink(CodingSessionLink.builder() .linkId(UUID.randomUUID().toString()) .taskId(taskId) .definitionName(definition.getName()) .parentSessionId(parentSession.getSessionId()) .childSessionId(childSessionId) .sessionMode(sessionMode) .background(background) .createdAtEpochMs(now) .build()); notifyTaskCreated(queuedTask, link); if (background) { executorService.submit(new Runnable() { @Override public void run() { runTask(parentSession, request, definition, taskId, childSessionId, seedState); } }); CodingTask task = taskManager.getTask(taskId); return buildDelegateResult(task, null); } return runTask(parentSession, request, definition, taskId, childSessionId, seedState); } @Override public CodingTask getTask(String taskId) { return taskManager == null ? null : taskManager.getTask(taskId); } @Override public void addListener(CodingRuntimeListener listener) { if (listener != null) { listeners.addIfAbsent(listener); } } @Override public void removeListener(CodingRuntimeListener listener) { if (listener != null) { listeners.remove(listener); } } @Override public List listTasks() { return taskManager == null ? Collections.emptyList() : taskManager.listTasks(); } @Override public List listTasksByParentSessionId(String parentSessionId) { return taskManager == null ? Collections.emptyList() : taskManager.listTasksByParentSessionId(parentSessionId); } @Override public List listSessionLinks(String parentSessionId) { return sessionLinkStore == null ? Collections.emptyList() : sessionLinkStore.listLinksByParentSessionId(parentSessionId); } @Override public CodingAgentDefinitionRegistry getDefinitionRegistry() { return definitionRegistry; } private CodingDelegateResult runTask(CodingSession parentSession, CodingDelegateRequest request, CodingAgentDefinition definition, String taskId, String childSessionId, CodingSessionState seedState) { long startedAt = System.currentTimeMillis(); saveTask(updateTask(taskId, CodingTaskStatus.STARTING, progress("starting", "Preparing delegated session.", 10, startedAt), startedAt, 0L, null, null)); CodingSession childSession = null; try { childSession = createChildSession(parentSession, definition, childSessionId, seedState); saveTask(updateTask(taskId, CodingTaskStatus.RUNNING, progress("running", "Delegated session is running.", 50, System.currentTimeMillis()), startedAt, 0L, null, null)); CodingAgentResult result = childSession.run(composeInput(request)); String outputText = resolveDelegateOutputText(result, childSession); long endedAt = System.currentTimeMillis(); CodingTask completed = saveTask(updateTask(taskId, CodingTaskStatus.COMPLETED, progress("completed", "Delegated session completed.", 100, endedAt), startedAt, endedAt, outputText, null)); return buildDelegateResult(completed, null); } catch (Exception ex) { long endedAt = System.currentTimeMillis(); CodingTask failed = saveTask(updateTask(taskId, CodingTaskStatus.FAILED, progress("failed", safeMessage(ex), 100, endedAt), startedAt, endedAt, null, safeMessage(ex))); return buildDelegateResult(failed, ex); } finally { if (childSession != null) { childSession.close(); } } } private CodingSession createChildSession(CodingSession parentSession, CodingAgentDefinition definition, String childSessionId, CodingSessionState seedState) { AgentSession parentAgentSession = parentSession.getDelegate(); if (parentAgentSession == null || parentAgentSession.getContext() == null) { throw new IllegalStateException("parent agent session context is unavailable"); } SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, options); AgentToolRegistry builtInRegistry = CodingAgentBuilder.createBuiltInRegistry(options, definitionRegistry); ToolExecutor builtInExecutor = CodingAgentBuilder.createBuiltInToolExecutor(workspaceContext, options, processRegistry, this, definitionRegistry); AgentToolRegistry mergedRegistry = CodingAgentBuilder.mergeToolRegistry(builtInRegistry, customToolRegistry); ToolExecutor mergedExecutor = CodingAgentBuilder.mergeToolExecutor( builtInRegistry, builtInExecutor, customToolRegistry, customToolExecutor ); mergedRegistry = CodingAgentBuilder.mergeSubAgentToolRegistry(mergedRegistry, subAgentRegistry); mergedExecutor = CodingAgentBuilder.mergeSubAgentToolExecutor(mergedExecutor, subAgentRegistry, handoffPolicy); CodingToolContextPolicy toolPolicy = toolPolicyResolver.resolve(mergedRegistry, mergedExecutor, definition); AgentContext parentContext = parentAgentSession.getContext(); AgentContext childContext = parentContext.toBuilder() .memory(new InMemoryAgentMemory()) .toolRegistry(toolPolicy.getToolRegistry()) .toolExecutor(toolPolicy.getToolExecutor()) .model(firstNonBlank(definition.getModel(), parentContext.getModel())) .instructions(mergeText(parentContext.getInstructions(), definition.getInstructions())) .systemPrompt(mergeText(parentContext.getSystemPrompt(), definition.getSystemPrompt())) .build(); CodingSession childSession = new CodingSession( childSessionId, new AgentSession(parentAgentSession.getRuntime(), childContext), workspaceContext, options, processRegistry, this ); if (seedState != null) { childSession.restore(seedState); } return childSession; } private CodingSessionState resolveSeedState(CodingSession parentSession, CodingSessionMode sessionMode) { if (parentSession == null || sessionMode == null || sessionMode == CodingSessionMode.NEW) { return null; } return parentSession.exportState(); } private CodingAgentDefinition requireDefinition(CodingDelegateRequest request) { String name = request == null ? null : request.getDefinitionName(); CodingAgentDefinition definition = definitionRegistry == null ? null : definitionRegistry.getDefinition(firstNonBlank(name, "general-purpose")); if (definition == null) { throw new IllegalArgumentException("Unknown coding agent definition: " + firstNonBlank(name, "general-purpose")); } return definition; } private CodingSessionMode defaultSessionMode(CodingAgentDefinition definition) { return definition == null || definition.getSessionMode() == null ? CodingSessionMode.FORK : definition.getSessionMode(); } private String composeInput(CodingDelegateRequest request) { if (request == null) { return ""; } String input = trimToNull(request.getInput()); String context = trimToNull(request.getContext()); if (input == null) { return context == null ? "" : context; } if (context == null) { return input; } return input + "\n\nContext:\n" + context; } private CodingTask saveTask(CodingTask task) { if (taskManager == null || task == null) { return task; } CodingTask saved = taskManager.save(task); notifyTaskUpdated(saved); return saved; } private CodingTask persistInitialTask(CodingTask task) { if (taskManager == null || task == null) { return task; } return taskManager.save(task); } private CodingSessionLink saveLink(CodingSessionLink link) { if (sessionLinkStore == null || link == null) { return link; } return sessionLinkStore.save(link); } private CodingTask updateTask(String taskId, CodingTaskStatus status, CodingTaskProgress progress, long startedAt, long endedAt, String outputText, String error) { CodingTask current = taskManager == null ? null : taskManager.getTask(taskId); if (current == null) { throw new IllegalStateException("Coding task not found: " + taskId); } return current.toBuilder() .status(status) .progress(progress) .startedAtEpochMs(startedAt > 0 ? startedAt : current.getStartedAtEpochMs()) .endedAtEpochMs(endedAt > 0 ? endedAt : current.getEndedAtEpochMs()) .outputText(outputText == null ? current.getOutputText() : outputText) .error(error == null ? current.getError() : error) .build(); } private CodingTaskProgress progress(String phase, String message, Integer percent, long now) { return CodingTaskProgress.builder() .phase(phase) .message(message) .percent(percent) .updatedAtEpochMs(now) .build(); } private CodingDelegateResult buildDelegateResult(CodingTask task, Exception error) { return CodingDelegateResult.builder() .taskId(task == null ? null : task.getTaskId()) .definitionName(task == null ? null : task.getDefinitionName()) .parentSessionId(task == null ? null : task.getParentSessionId()) .childSessionId(task == null ? null : task.getChildSessionId()) .background(task != null && task.isBackground()) .status(task == null ? null : task.getStatus()) .outputText(task == null ? null : task.getOutputText()) .error(error == null ? (task == null ? null : task.getError()) : safeMessage(error)) .build(); } private String mergeText(String base, String extra) { if (isBlank(base)) { return extra; } if (isBlank(extra)) { return base; } return base + "\n\n" + extra; } private String resolveDelegateOutputText(CodingAgentResult result, CodingSession childSession) { String direct = trimToNull(result == null ? null : result.getOutputText()); if (direct != null) { return direct; } if (childSession == null) { return null; } try { return resolveLatestAssistantMessage(childSession.exportState() == null ? null : childSession.exportState().getMemorySnapshot()); } catch (Exception ignored) { return null; } } private String resolveLatestAssistantMessage(MemorySnapshot snapshot) { if (snapshot == null || snapshot.getItems() == null || snapshot.getItems().isEmpty()) { return null; } List items = snapshot.getItems(); for (int i = items.size() - 1; i >= 0; i--) { JSONObject object = toJSONObject(items.get(i)); if (!"message".equals(object.getString("type")) || !"assistant".equals(object.getString("role"))) { continue; } String text = trimToNull(extractMessageText(object.getJSONArray("content"))); if (text != null) { return text; } } return null; } private String extractMessageText(JSONArray content) { if (content == null || content.isEmpty()) { return null; } StringBuilder builder = new StringBuilder(); for (int i = 0; i < content.size(); i++) { JSONObject part = content.getJSONObject(i); if (part == null) { continue; } String partType = part.getString("type"); if ("input_text".equals(partType) || "output_text".equals(partType)) { String text = trimToNull(part.getString("text")); if (text != null) { if (builder.length() > 0) { builder.append(' '); } builder.append(text); } } } return builder.toString(); } private JSONObject toJSONObject(Object raw) { if (raw instanceof JSONObject) { return (JSONObject) raw; } if (raw instanceof Map) { return new JSONObject((Map) raw); } if (raw == null) { return new JSONObject(); } try { return JSON.parseObject(JSON.toJSONString(raw)); } catch (Exception ignored) { return new JSONObject(); } } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { String trimmed = trimToNull(value); if (trimmed != null) { return trimmed; } } return null; } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String safeMessage(Throwable throwable) { if (throwable == null) { return null; } Throwable current = throwable; String message = null; while (current != null) { if (!isBlank(current.getMessage())) { message = current.getMessage().trim(); } current = current.getCause(); } return isBlank(message) ? throwable.getClass().getSimpleName() : message; } private void notifyTaskCreated(CodingTask task, CodingSessionLink link) { for (CodingRuntimeListener listener : listeners) { try { listener.onTaskCreated(copyTask(task), copyLink(link)); } catch (Exception ignored) { } } } private void notifyTaskUpdated(CodingTask task) { for (CodingRuntimeListener listener : listeners) { try { listener.onTaskUpdated(copyTask(task)); } catch (Exception ignored) { } } } private CodingTask copyTask(CodingTask task) { return task == null ? null : task.toBuilder().build(); } private CodingSessionLink copyLink(CodingSessionLink link) { return link == null ? null : link.toBuilder().build(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/CodingSessionDescriptor.java ================================================ package io.github.lnyocly.ai4j.coding.session; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class CodingSessionDescriptor { private String sessionId; private String rootSessionId; private String parentSessionId; private String provider; private String protocol; private String model; private String workspace; private String summary; private int memoryItemCount; private int processCount; private int activeProcessCount; private int restoredProcessCount; private long createdAtEpochMs; private long updatedAtEpochMs; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/CodingSessionLink.java ================================================ package io.github.lnyocly.ai4j.coding.session; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingSessionLink { private String linkId; private String taskId; private String definitionName; private String parentSessionId; private String childSessionId; private CodingSessionMode sessionMode; private boolean background; private long createdAtEpochMs; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/CodingSessionLinkStore.java ================================================ package io.github.lnyocly.ai4j.coding.session; import java.util.List; public interface CodingSessionLinkStore { CodingSessionLink save(CodingSessionLink link); List listLinks(); List listLinksByParentSessionId(String parentSessionId); CodingSessionLink findByChildSessionId(String childSessionId); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/InMemoryCodingSessionLinkStore.java ================================================ package io.github.lnyocly.ai4j.coding.session; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class InMemoryCodingSessionLinkStore implements CodingSessionLinkStore { private final Map linksByChildSessionId = new ConcurrentHashMap(); @Override public CodingSessionLink save(CodingSessionLink link) { if (link == null || isBlank(link.getChildSessionId())) { throw new IllegalArgumentException("childSessionId is required"); } CodingSessionLink stored = link.toBuilder().build(); linksByChildSessionId.put(stored.getChildSessionId(), stored); return stored.toBuilder().build(); } @Override public List listLinks() { return sort(linksByChildSessionId.values()); } @Override public List listLinksByParentSessionId(String parentSessionId) { if (isBlank(parentSessionId)) { return Collections.emptyList(); } List items = new ArrayList(); for (CodingSessionLink link : linksByChildSessionId.values()) { if (link != null && parentSessionId.equals(link.getParentSessionId())) { items.add(link.toBuilder().build()); } } sortInPlace(items); return items; } @Override public CodingSessionLink findByChildSessionId(String childSessionId) { CodingSessionLink link = childSessionId == null ? null : linksByChildSessionId.get(childSessionId); return link == null ? null : link.toBuilder().build(); } private List sort(Iterable values) { List items = new ArrayList(); for (CodingSessionLink value : values) { if (value != null) { items.add(value.toBuilder().build()); } } sortInPlace(items); return items; } private void sortInPlace(List items) { Collections.sort(items, new Comparator() { @Override public int compare(CodingSessionLink left, CodingSessionLink right) { long leftTime = left == null ? 0L : left.getCreatedAtEpochMs(); long rightTime = right == null ? 0L : right.getCreatedAtEpochMs(); if (leftTime == rightTime) { String leftId = left == null ? null : left.getChildSessionId(); String rightId = right == null ? null : right.getChildSessionId(); if (leftId == null) { return rightId == null ? 0 : -1; } if (rightId == null) { return 1; } return leftId.compareTo(rightId); } return leftTime < rightTime ? -1 : 1; } }); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/ManagedCodingSession.java ================================================ package io.github.lnyocly.ai4j.coding.session; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.CodingSessionSnapshot; public class ManagedCodingSession implements AutoCloseable { private final CodingSession session; private final String provider; private final String protocol; private final String model; private final String workspace; private final String workspaceDescription; private final String systemPrompt; private final String instructions; private final String rootSessionId; private final String parentSessionId; private final long createdAtEpochMs; private long updatedAtEpochMs; public ManagedCodingSession(CodingSession session, String provider, String protocol, String model, String workspace, String workspaceDescription, String systemPrompt, String instructions, String rootSessionId, String parentSessionId, long createdAtEpochMs, long updatedAtEpochMs) { this.session = session; this.provider = provider; this.protocol = protocol; this.model = model; this.workspace = workspace; this.workspaceDescription = workspaceDescription; this.systemPrompt = systemPrompt; this.instructions = instructions; this.rootSessionId = rootSessionId; this.parentSessionId = parentSessionId; this.createdAtEpochMs = createdAtEpochMs; this.updatedAtEpochMs = updatedAtEpochMs; } public CodingSession getSession() { return session; } public String getSessionId() { return session == null ? null : session.getSessionId(); } public String getProvider() { return provider; } public String getProtocol() { return protocol; } public String getModel() { return model; } public String getWorkspace() { return workspace; } public String getWorkspaceDescription() { return workspaceDescription; } public String getSystemPrompt() { return systemPrompt; } public String getInstructions() { return instructions; } public String getRootSessionId() { return rootSessionId; } public String getParentSessionId() { return parentSessionId; } public long getCreatedAtEpochMs() { return createdAtEpochMs; } public long getUpdatedAtEpochMs() { return updatedAtEpochMs; } public void touch(long updatedAtEpochMs) { this.updatedAtEpochMs = updatedAtEpochMs; } public CodingSessionDescriptor toDescriptor() { CodingSessionSnapshot snapshot = session == null ? null : session.snapshot(); return CodingSessionDescriptor.builder() .sessionId(getSessionId()) .rootSessionId(rootSessionId) .parentSessionId(parentSessionId) .provider(provider) .protocol(protocol) .model(model) .workspace(workspace) .summary(snapshot == null ? null : snapshot.getSummary()) .memoryItemCount(snapshot == null ? 0 : snapshot.getMemoryItemCount()) .processCount(snapshot == null ? 0 : snapshot.getProcessCount()) .activeProcessCount(snapshot == null ? 0 : snapshot.getActiveProcessCount()) .restoredProcessCount(snapshot == null ? 0 : snapshot.getRestoredProcessCount()) .createdAtEpochMs(createdAtEpochMs) .updatedAtEpochMs(updatedAtEpochMs) .build(); } @Override public void close() { if (session != null) { session.close(); } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/SessionEvent.java ================================================ package io.github.lnyocly.ai4j.coding.session; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class SessionEvent { private String eventId; private String sessionId; private SessionEventType type; private long timestamp; private String turnId; private Integer step; private String summary; private Map payload; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/SessionEventType.java ================================================ package io.github.lnyocly.ai4j.coding.session; public enum SessionEventType { SESSION_CREATED, SESSION_SAVED, SESSION_RESUMED, SESSION_FORKED, USER_MESSAGE, ASSISTANT_MESSAGE, TOOL_CALL, TOOL_RESULT, TASK_CREATED, TASK_UPDATED, TEAM_MESSAGE, COMPACT, AUTO_CONTINUE, AUTO_STOP, BLOCKED, PROCESS_STARTED, PROCESS_UPDATED, PROCESS_STOPPED, ERROR } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/LocalShellCommandExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.shell; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.file.Path; import java.util.List; import java.util.concurrent.TimeUnit; public class LocalShellCommandExecutor implements ShellCommandExecutor { private final WorkspaceContext workspaceContext; private final long defaultTimeoutMs; public LocalShellCommandExecutor(WorkspaceContext workspaceContext, long defaultTimeoutMs) { this.workspaceContext = workspaceContext; this.defaultTimeoutMs = defaultTimeoutMs; } @Override public ShellCommandResult execute(ShellCommandRequest request) throws Exception { if (request == null || isBlank(request.getCommand())) { throw new IllegalArgumentException("command is required"); } Path workingDirectory = workspaceContext.resolveWorkspacePath(request.getWorkingDirectory()); long timeoutMs = request.getTimeoutMs() == null || request.getTimeoutMs() <= 0 ? defaultTimeoutMs : request.getTimeoutMs(); ProcessBuilder processBuilder = new ProcessBuilder(buildShellCommand(request.getCommand())); processBuilder.directory(workingDirectory.toFile()); Process process = processBuilder.start(); Charset shellCharset = ShellCommandSupport.resolveShellCharset(); StringBuilder stdout = new StringBuilder(); StringBuilder stderr = new StringBuilder(); Thread stdoutThread = new Thread(new StreamCollector(process.getInputStream(), stdout, shellCharset), "ai4j-coding-stdout"); Thread stderrThread = new Thread(new StreamCollector(process.getErrorStream(), stderr, shellCharset), "ai4j-coding-stderr"); stdoutThread.start(); stderrThread.start(); boolean finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS); int exitCode; if (finished) { exitCode = process.exitValue(); } else { process.destroyForcibly(); process.waitFor(5L, TimeUnit.SECONDS); exitCode = -1; appendTimeoutHint(stderr); } stdoutThread.join(); stderrThread.join(); return ShellCommandResult.builder() .command(request.getCommand()) .workingDirectory(workingDirectory.toString()) .stdout(stdout.toString()) .stderr(stderr.toString()) .exitCode(exitCode) .timedOut(!finished) .build(); } private List buildShellCommand(String command) { return ShellCommandSupport.buildShellCommand(command); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private void appendTimeoutHint(StringBuilder stderr) { if (stderr == null) { return; } if (stderr.length() > 0) { stderr.append('\n'); } stderr.append("Command timed out before exit. If it is interactive or long-running, use bash action=start and then bash action=logs/status/write/stop instead of bash action=exec."); } private static class StreamCollector implements Runnable { private final InputStream inputStream; private final StringBuilder target; private final Charset charset; private StreamCollector(InputStream inputStream, StringBuilder target, Charset charset) { this.inputStream = inputStream; this.target = target; this.charset = charset; } @Override public void run() { try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) { String line; while ((line = reader.readLine()) != null) { if (target.length() > 0) { target.append('\n'); } target.append(line); } } catch (IOException ignored) { } } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.shell; public interface ShellCommandExecutor { ShellCommandResult execute(ShellCommandRequest request) throws Exception; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandRequest.java ================================================ package io.github.lnyocly.ai4j.coding.shell; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ShellCommandRequest { private String command; private String workingDirectory; private Long timeoutMs; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandResult.java ================================================ package io.github.lnyocly.ai4j.coding.shell; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ShellCommandResult { private String command; private String workingDirectory; private String stdout; private String stderr; private int exitCode; private boolean timedOut; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandSupport.java ================================================ package io.github.lnyocly.ai4j.coding.shell; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Locale; public final class ShellCommandSupport { private static final String SHELL_ENCODING_PROPERTY = "ai4j.shell.encoding"; private static final String SHELL_ENCODING_ENV = "AI4J_SHELL_ENCODING"; private ShellCommandSupport() { } public static List buildShellCommand(String command) { return buildShellCommand(command, System.getProperty("os.name", "")); } static List buildShellCommand(String command, String osName) { if (isWindows(osName)) { return Arrays.asList("cmd.exe", "/c", command); } return Arrays.asList("sh", "-lc", command); } public static String buildShellUsageGuidance() { return buildShellUsageGuidance(System.getProperty("os.name", "")); } static String buildShellUsageGuidance(String osName) { if (isWindows(osName)) { return "The bash tool runs through cmd.exe /c on this machine. Use Windows command syntax and redirection; avoid Unix-only shell features such as cat < file or Get-Content. Use action=exec only for commands that finish on their own. For interactive or long-running commands, use action=start and then action=logs/status/write/stop."; } public static Charset resolveShellCharset() { return resolveShellCharset( System.getProperty("os.name", ""), new String[]{ System.getProperty(SHELL_ENCODING_PROPERTY), System.getenv(SHELL_ENCODING_ENV) }, new String[]{ System.getProperty("native.encoding"), System.getProperty("sun.jnu.encoding"), System.getProperty("file.encoding"), Charset.defaultCharset().name() } ); } static Charset resolveShellCharset(String osName, String[] explicitCandidates, String[] systemCandidates) { Charset explicit = firstSupportedCharset(explicitCandidates); if (explicit != null) { return explicit; } if (!isWindows(osName)) { return StandardCharsets.UTF_8; } Charset platform = firstSupportedCharset(systemCandidates); return platform == null ? Charset.defaultCharset() : platform; } private static boolean isWindows(String osName) { return osName != null && osName.toLowerCase(Locale.ROOT).contains("win"); } private static Charset firstSupportedCharset(String[] candidates) { if (candidates == null) { return null; } for (String candidate : candidates) { if (candidate == null || candidate.trim().isEmpty()) { continue; } try { return Charset.forName(candidate.trim()); } catch (Exception ignored) { } } return null; } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/skill/CodingSkillDescriptor.java ================================================ package io.github.lnyocly.ai4j.coding.skill; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class CodingSkillDescriptor { private String name; private String description; private String skillFilePath; private String source; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/skill/CodingSkillDiscovery.java ================================================ package io.github.lnyocly.ai4j.coding.skill; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.skill.SkillDescriptor; import io.github.lnyocly.ai4j.skill.Skills; import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class CodingSkillDiscovery { private CodingSkillDiscovery() { } public static WorkspaceContext enrich(WorkspaceContext workspaceContext) { WorkspaceContext source = workspaceContext == null ? WorkspaceContext.builder().build() : workspaceContext; DiscoveryResult result = discover(source); return source.toBuilder() .allowedReadRoots(result.allowedReadRoots) .availableSkills(result.skills) .build(); } public static DiscoveryResult discover(WorkspaceContext workspaceContext) { WorkspaceContext source = workspaceContext == null ? WorkspaceContext.builder().build() : workspaceContext; Skills.DiscoveryResult result = Skills.discoverDefault(source.getRoot(), source.getSkillDirectories()); return new DiscoveryResult( toCodingDescriptors(result.getSkills()), result.getAllowedReadRoots() ); } private static List toCodingDescriptors(List skills) { if (skills == null || skills.isEmpty()) { return Collections.emptyList(); } List descriptors = new ArrayList(); for (SkillDescriptor skill : skills) { if (skill == null) { continue; } descriptors.add(CodingSkillDescriptor.builder() .name(skill.getName()) .description(skill.getDescription()) .skillFilePath(skill.getSkillFilePath()) .source(skill.getSource()) .build()); } return descriptors; } public static final class DiscoveryResult { private final List skills; private final List allowedReadRoots; public DiscoveryResult(List skills, List allowedReadRoots) { this.skills = skills == null ? Collections.emptyList() : skills; this.allowedReadRoots = allowedReadRoots == null ? Collections.emptyList() : allowedReadRoots; } public List getSkills() { return skills; } public List getAllowedReadRoots() { return allowedReadRoots; } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTask.java ================================================ package io.github.lnyocly.ai4j.coding.task; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingTask { private String taskId; private String definitionName; private String parentSessionId; private String childSessionId; private String input; private boolean background; private CodingTaskStatus status; private CodingTaskProgress progress; private long createdAtEpochMs; private long startedAtEpochMs; private long endedAtEpochMs; private String outputText; private String error; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTaskManager.java ================================================ package io.github.lnyocly.ai4j.coding.task; import java.util.List; public interface CodingTaskManager { CodingTask save(CodingTask task); CodingTask getTask(String taskId); List listTasks(); List listTasksByParentSessionId(String parentSessionId); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTaskProgress.java ================================================ package io.github.lnyocly.ai4j.coding.task; import lombok.Builder; import lombok.Data; @Data @Builder(toBuilder = true) public class CodingTaskProgress { private String phase; private String message; private Integer percent; private long updatedAtEpochMs; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTaskStatus.java ================================================ package io.github.lnyocly.ai4j.coding.task; public enum CodingTaskStatus { QUEUED, STARTING, RUNNING, COMPLETED, FAILED, CANCELLED; public boolean isTerminal() { return this == COMPLETED || this == FAILED || this == CANCELLED; } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/InMemoryCodingTaskManager.java ================================================ package io.github.lnyocly.ai4j.coding.task; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class InMemoryCodingTaskManager implements CodingTaskManager { private final Map tasks = new ConcurrentHashMap(); @Override public CodingTask save(CodingTask task) { if (task == null || isBlank(task.getTaskId())) { throw new IllegalArgumentException("taskId is required"); } CodingTask stored = task.toBuilder().build(); tasks.put(stored.getTaskId(), stored); return stored.toBuilder().build(); } @Override public CodingTask getTask(String taskId) { CodingTask task = taskId == null ? null : tasks.get(taskId); return task == null ? null : task.toBuilder().build(); } @Override public List listTasks() { return sort(tasks.values()); } @Override public List listTasksByParentSessionId(String parentSessionId) { if (isBlank(parentSessionId)) { return Collections.emptyList(); } List matches = new ArrayList(); for (CodingTask task : tasks.values()) { if (task != null && parentSessionId.equals(task.getParentSessionId())) { matches.add(task.toBuilder().build()); } } sortInPlace(matches); return matches; } private List sort(Iterable values) { List items = new ArrayList(); for (CodingTask task : values) { if (task != null) { items.add(task.toBuilder().build()); } } sortInPlace(items); return items; } private void sortInPlace(List items) { Collections.sort(items, new Comparator() { @Override public int compare(CodingTask left, CodingTask right) { long leftTime = left == null ? 0L : left.getCreatedAtEpochMs(); long rightTime = right == null ? 0L : right.getCreatedAtEpochMs(); if (leftTime == rightTime) { String leftId = left == null ? null : left.getTaskId(); String rightId = right == null ? null : right.getTaskId(); if (leftId == null) { return rightId == null ? 0 : -1; } if (rightId == null) { return 1; } return leftId.compareTo(rightId); } return leftTime < rightTime ? -1 : 1; } }); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ApplyPatchToolExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.patch.ApplyPatchFileChange; import io.github.lnyocly.ai4j.coding.patch.ApplyPatchResult; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; public class ApplyPatchToolExecutor implements ToolExecutor { private static final String BEGIN_PATCH = "*** Begin Patch"; private static final String END_PATCH = "*** End Patch"; private static final String ADD_FILE = "*** Add File:"; private static final String ADD_FILE_ALIAS = "*** Add:"; private static final String UPDATE_FILE = "*** Update File:"; private static final String UPDATE_FILE_ALIAS = "*** Update:"; private static final String DELETE_FILE = "*** Delete File:"; private static final String DELETE_FILE_ALIAS = "*** Delete:"; private final WorkspaceContext workspaceContext; public ApplyPatchToolExecutor(WorkspaceContext workspaceContext) { this.workspaceContext = workspaceContext; } @Override public String execute(AgentToolCall call) throws Exception { JSONObject arguments = parseArguments(call == null ? null : call.getArguments()); String patch = arguments.getString("patch"); if (patch == null || patch.trim().isEmpty()) { throw new IllegalArgumentException("patch is required"); } ApplyPatchResult result = apply(patch); return JSON.toJSONString(result); } private ApplyPatchResult apply(String patchText) throws IOException { List lines = normalizeLines(patchText); if (lines.size() < 2 || !BEGIN_PATCH.equals(lines.get(0)) || !END_PATCH.equals(lines.get(lines.size() - 1))) { throw new IllegalArgumentException("Invalid patch envelope"); } int index = 1; int operationsApplied = 0; Set changedFiles = new LinkedHashSet(); List fileChanges = new ArrayList(); while (index < lines.size() - 1) { String line = lines.get(index); PatchDirective directive = parseDirective(line); if (directive == null) { if (line.trim().isEmpty()) { index++; continue; } throw new IllegalArgumentException("Unsupported patch line: " + line); } if ("add".equals(directive.operation)) { String path = directive.path; PatchOperation operation = applyAddFile(lines, index + 1, path); index = operation.nextIndex; operationsApplied++; changedFiles.add(operation.fileChange.getPath()); fileChanges.add(operation.fileChange); continue; } if ("update".equals(directive.operation)) { String path = directive.path; PatchOperation operation = applyUpdateFile(lines, index + 1, path); index = operation.nextIndex; operationsApplied++; changedFiles.add(operation.fileChange.getPath()); fileChanges.add(operation.fileChange); continue; } if ("delete".equals(directive.operation)) { String path = directive.path; PatchOperation operation = applyDeleteFile(path, index + 1); index = operation.nextIndex; operationsApplied++; changedFiles.add(operation.fileChange.getPath()); fileChanges.add(operation.fileChange); continue; } } return ApplyPatchResult.builder() .filesChanged(changedFiles.size()) .operationsApplied(operationsApplied) .changedFiles(new ArrayList(changedFiles)) .fileChanges(fileChanges) .build(); } private PatchOperation applyAddFile(List lines, int startIndex, String path) throws IOException { Path file = workspaceContext.resolveWorkspacePath(path); if (Files.exists(file)) { throw new IllegalArgumentException("File already exists: " + path); } List contentLines = new ArrayList(); int index = startIndex; while (index < lines.size() - 1 && !lines.get(index).startsWith("*** ")) { String line = lines.get(index); if (!line.startsWith("+")) { throw new IllegalArgumentException("Add file lines must start with '+': " + line); } contentLines.add(line.substring(1)); index++; } writeFile(file, joinLines(contentLines)); return new PatchOperation(index, ApplyPatchFileChange.builder() .path(normalizeRelativePath(path)) .operation("add") .linesAdded(contentLines.size()) .linesRemoved(0) .build()); } private PatchOperation applyUpdateFile(List lines, int startIndex, String path) throws IOException { Path file = workspaceContext.resolveWorkspacePath(path); if (!Files.exists(file) || Files.isDirectory(file)) { throw new IllegalArgumentException("File does not exist: " + path); } List body = new ArrayList(); int index = startIndex; while (index < lines.size() - 1 && !lines.get(index).startsWith("*** ")) { body.add(lines.get(index)); index++; } List normalizedBody = normalizeUpdateBody(body); String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); String updated = applyUpdateBody(original, normalizedBody, path); writeFile(file, updated); return new PatchOperation(index, ApplyPatchFileChange.builder() .path(normalizeRelativePath(path)) .operation("update") .linesAdded(countPrefixedLines(normalizedBody, '+')) .linesRemoved(countPrefixedLines(normalizedBody, '-')) .build()); } private PatchOperation applyDeleteFile(String path, int startIndex) throws IOException { Path file = workspaceContext.resolveWorkspacePath(path); if (!Files.exists(file) || Files.isDirectory(file)) { throw new IllegalArgumentException("File does not exist: " + path); } String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); int removed = splitContentLines(original).size(); Files.delete(file); return new PatchOperation(startIndex, ApplyPatchFileChange.builder() .path(normalizeRelativePath(path)) .operation("delete") .linesAdded(0) .linesRemoved(removed) .build()); } private String applyUpdateBody(String original, List body, String path) { List originalLines = splitContentLines(original); List> hunks = parseHunks(body); List output = new ArrayList(); int cursor = 0; for (List hunk : hunks) { List anchor = resolveAnchor(hunk); int matchIndex = findAnchor(originalLines, cursor, anchor); if (matchIndex < 0) { throw new IllegalArgumentException("Failed to locate patch hunk in file: " + path); } appendRange(output, originalLines, cursor, matchIndex); int current = matchIndex; for (String line : hunk) { if (line.startsWith("@@")) { continue; } if (line.isEmpty()) { throw new IllegalArgumentException("Invalid empty patch line in update body"); } char prefix = line.charAt(0); String content = line.substring(1); switch (prefix) { case ' ': ensureMatch(originalLines, current, content, path); output.add(originalLines.get(current)); current++; break; case '-': ensureMatch(originalLines, current, content, path); current++; break; case '+': output.add(content); break; default: throw new IllegalArgumentException("Unsupported update line: " + line); } } cursor = current; } appendRange(output, originalLines, cursor, originalLines.size()); return joinLines(output); } private List> parseHunks(List body) { List> hunks = new ArrayList>(); List current = new ArrayList(); for (String line : body) { if (line.startsWith("@@")) { if (!current.isEmpty()) { hunks.add(current); current = new ArrayList(); } current.add(line); continue; } if (line.startsWith(" ") || line.startsWith("+") || line.startsWith("-")) { current.add(line); continue; } if (line.trim().isEmpty()) { current.add(" "); continue; } throw new IllegalArgumentException("Unsupported update body line: " + line); } if (!current.isEmpty()) { hunks.add(current); } if (hunks.isEmpty()) { throw new IllegalArgumentException("Update file patch must contain at least one hunk"); } return hunks; } private List resolveAnchor(List hunk) { List leading = new ArrayList(); for (String line : hunk) { if (line.startsWith("@@")) { continue; } if (line.startsWith(" ") || line.startsWith("-")) { leading.add(line.substring(1)); } else if (line.startsWith("+")) { break; } } if (!leading.isEmpty()) { return leading; } for (String line : hunk) { if (line.startsWith("@@") || line.startsWith("+")) { continue; } leading.add(line.substring(1)); } return leading; } private int findAnchor(List originalLines, int fromIndex, List anchor) { if (anchor.isEmpty()) { return fromIndex; } int max = originalLines.size() - anchor.size(); for (int i = Math.max(0, fromIndex); i <= max; i++) { boolean matched = true; for (int j = 0; j < anchor.size(); j++) { if (!originalLines.get(i + j).equals(anchor.get(j))) { matched = false; break; } } if (matched) { return i; } } return -1; } private void ensureMatch(List originalLines, int current, String expected, String path) { if (current >= originalLines.size()) { throw new IllegalArgumentException("Patch exceeds file length: " + path); } String actual = originalLines.get(current); if (!actual.equals(expected)) { throw new IllegalArgumentException("Patch context mismatch for file " + path + ": expected '" + expected + "' but found '" + actual + "'"); } } private void appendRange(List output, List originalLines, int from, int to) { for (int i = from; i < to; i++) { output.add(originalLines.get(i)); } } private void writeFile(Path file, String content) throws IOException { Path parent = file.getParent(); if (parent != null) { Files.createDirectories(parent); } Files.write(file, content.getBytes(StandardCharsets.UTF_8)); } private List splitContentLines(String content) { String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); if (normalized.isEmpty()) { return new ArrayList(); } String[] parts = normalized.split("\n", -1); List lines = new ArrayList(); int length = parts.length; if (length > 0 && parts[length - 1].isEmpty()) { length--; } for (int i = 0; i < length; i++) { lines.add(parts[i]); } return lines; } private String joinLines(List lines) { if (lines == null || lines.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); for (int i = 0; i < lines.size(); i++) { if (i > 0) { builder.append('\n'); } builder.append(lines.get(i)); } return builder.toString(); } private String normalizeRelativePath(String path) { Path resolved = workspaceContext.resolveWorkspacePath(path); Path root = workspaceContext.getRoot(); if (resolved.equals(root)) { return "."; } return root.relativize(resolved).toString().replace('\\', '/'); } private JSONObject parseArguments(String rawArguments) { if (rawArguments == null || rawArguments.trim().isEmpty()) { return new JSONObject(); } return JSON.parseObject(rawArguments); } private List normalizeLines(String content) { String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); String[] parts = normalized.split("\n", -1); List lines = new ArrayList(); int length = parts.length; if (length > 0 && parts[length - 1].isEmpty()) { length--; } for (int i = 0; i < length; i++) { lines.add(parts[i]); } return lines; } private int countPrefixedLines(List lines, char prefix) { if (lines == null || lines.isEmpty()) { return 0; } int count = 0; for (String line : lines) { if (line != null && !line.isEmpty() && line.charAt(0) == prefix) { count++; } } return count; } private List normalizeUpdateBody(List body) { if (body == null || body.isEmpty()) { return new ArrayList(); } List normalized = new ArrayList(); boolean sawPatchContent = false; for (String line : body) { if (!sawPatchContent && isUnifiedDiffMetadataLine(line)) { continue; } normalized.add(line); if (line != null && (line.startsWith("@@") || line.startsWith(" ") || line.startsWith("+") || line.startsWith("-"))) { sawPatchContent = true; } } return normalized; } private boolean isUnifiedDiffMetadataLine(String line) { if (line == null) { return false; } return line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("diff --git ") || line.startsWith("index "); } private PatchDirective parseDirective(String line) { String path = directivePath(line, ADD_FILE); if (path != null) { return new PatchDirective("add", path); } path = directivePath(line, ADD_FILE_ALIAS); if (path != null) { return new PatchDirective("add", path); } path = directivePath(line, UPDATE_FILE); if (path != null) { return new PatchDirective("update", path); } path = directivePath(line, UPDATE_FILE_ALIAS); if (path != null) { return new PatchDirective("update", path); } path = directivePath(line, DELETE_FILE); if (path != null) { return new PatchDirective("delete", path); } path = directivePath(line, DELETE_FILE_ALIAS); if (path != null) { return new PatchDirective("delete", path); } return null; } private String directivePath(String line, String directivePrefix) { if (line == null || directivePrefix == null || !line.startsWith(directivePrefix)) { return null; } String rawPath = line.substring(directivePrefix.length()).trim(); String normalized = normalizePatchPath(rawPath); if (normalized.isEmpty()) { throw new IllegalArgumentException("Patch directive is missing a file path: " + line); } return normalized; } private String normalizePatchPath(String rawPath) { String path = rawPath == null ? "" : rawPath.trim(); while ((path.startsWith("/") || path.startsWith("\\")) && !looksLikeAbsolutePath(path)) { path = path.substring(1).trim(); } return path; } private boolean looksLikeAbsolutePath(String path) { if (path == null || path.isEmpty()) { return false; } if (path.startsWith("\\\\") || path.startsWith("//")) { return true; } return path.length() >= 3 && Character.isLetter(path.charAt(0)) && path.charAt(1) == ':' && (path.charAt(2) == '\\' || path.charAt(2) == '/'); } private static final class PatchOperation { private final int nextIndex; private final ApplyPatchFileChange fileChange; private PatchOperation(int nextIndex, ApplyPatchFileChange fileChange) { this.nextIndex = nextIndex; this.fileChange = fileChange; } } private static final class PatchDirective { private final String operation; private final String path; private PatchDirective(String operation, String path) { this.operation = operation; this.path = path; } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/BashToolExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.process.BashProcessInfo; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.shell.LocalShellCommandExecutor; import io.github.lnyocly.ai4j.coding.shell.ShellCommandRequest; import io.github.lnyocly.ai4j.coding.shell.ShellCommandResult; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.util.LinkedHashMap; import java.util.Map; public class BashToolExecutor implements ToolExecutor { private final WorkspaceContext workspaceContext; private final CodingAgentOptions options; private final SessionProcessRegistry processRegistry; private final LocalShellCommandExecutor shellCommandExecutor; public BashToolExecutor(WorkspaceContext workspaceContext, CodingAgentOptions options, SessionProcessRegistry processRegistry) { this.workspaceContext = workspaceContext; this.options = options; this.processRegistry = processRegistry; this.shellCommandExecutor = new LocalShellCommandExecutor(workspaceContext, options.getDefaultCommandTimeoutMs()); } @Override public String execute(AgentToolCall call) throws Exception { JSONObject arguments = parseArguments(call == null ? null : call.getArguments()); String action = arguments.getString("action"); if (action == null || action.trim().isEmpty()) { action = "exec"; } switch (action) { case "exec": return exec(arguments); case "start": return start(arguments); case "status": return status(arguments); case "logs": return logs(arguments); case "write": return write(arguments); case "stop": return stop(arguments); case "list": return list(); default: throw new IllegalArgumentException("Unsupported bash action: " + action); } } private String exec(JSONObject arguments) throws Exception { ShellCommandResult result = shellCommandExecutor.execute(ShellCommandRequest.builder() .command(arguments.getString("command")) .workingDirectory(arguments.getString("cwd")) .timeoutMs(arguments.getLong("timeoutMs")) .build()); return JSON.toJSONString(result); } private String start(JSONObject arguments) throws Exception { BashProcessInfo result = processRegistry.start(arguments.getString("command"), arguments.getString("cwd")); return JSON.toJSONString(result); } private String status(JSONObject arguments) { return JSON.toJSONString(processRegistry.status(arguments.getString("processId"))); } private String logs(JSONObject arguments) { BashProcessLogChunk result = processRegistry.logs( arguments.getString("processId"), arguments.getLong("offset"), arguments.getInteger("limit") ); return JSON.toJSONString(result); } private String write(JSONObject arguments) throws Exception { String processId = arguments.getString("processId"); int bytesWritten = processRegistry.write(processId, arguments.getString("input")); Map result = new LinkedHashMap(); result.put("process", processRegistry.status(processId)); result.put("bytesWritten", bytesWritten); return JSON.toJSONString(result); } private String stop(JSONObject arguments) { return JSON.toJSONString(processRegistry.stop(arguments.getString("processId"))); } private String list() { return JSON.toJSONString(processRegistry.list()); } private JSONObject parseArguments(String rawArguments) { if (rawArguments == null || rawArguments.trim().isEmpty()) { return new JSONObject(); } return JSON.parseObject(rawArguments); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolNames.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import io.github.lnyocly.ai4j.tool.BuiltInTools; import java.util.Set; public final class CodingToolNames { public static final String BASH = BuiltInTools.BASH; public static final String READ_FILE = BuiltInTools.READ_FILE; public static final String WRITE_FILE = BuiltInTools.WRITE_FILE; public static final String APPLY_PATCH = BuiltInTools.APPLY_PATCH; private CodingToolNames() { } public static Set allBuiltIn() { return BuiltInTools.allCodingToolNames(); } public static Set readOnlyBuiltIn() { return BuiltInTools.readOnlyCodingToolNames(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolRegistryFactory.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.tool.BuiltInTools; import java.util.ArrayList; import java.util.List; public final class CodingToolRegistryFactory { private CodingToolRegistryFactory() { } public static AgentToolRegistry createBuiltInRegistry() { List tools = new ArrayList(); tools.addAll(BuiltInTools.codingTools()); return new StaticToolRegistry(tools); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ReadFileToolExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileReadResult; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileService; public class ReadFileToolExecutor implements ToolExecutor { private final WorkspaceFileService workspaceFileService; private final CodingAgentOptions options; public ReadFileToolExecutor(WorkspaceFileService workspaceFileService, CodingAgentOptions options) { this.workspaceFileService = workspaceFileService; this.options = options; } @Override public String execute(AgentToolCall call) throws Exception { JSONObject arguments = parseArguments(call == null ? null : call.getArguments()); WorkspaceFileReadResult result = workspaceFileService.readFile( arguments.getString("path"), arguments.getInteger("startLine"), arguments.getInteger("endLine"), arguments.getInteger("maxChars") == null ? options.getDefaultReadMaxChars() : arguments.getInteger("maxChars") ); return JSON.toJSONString(result); } private JSONObject parseArguments(String rawArguments) { if (rawArguments == null || rawArguments.trim().isEmpty()) { return new JSONObject(); } return JSON.parseObject(rawArguments); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/RoutingToolExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; public class RoutingToolExecutor implements ToolExecutor { private final List routes; private final ToolExecutor fallbackExecutor; public RoutingToolExecutor(List routes, ToolExecutor fallbackExecutor) { if (routes == null) { this.routes = Collections.emptyList(); } else { this.routes = new ArrayList<>(routes); } this.fallbackExecutor = fallbackExecutor; } @Override public String execute(AgentToolCall call) throws Exception { String toolName = call == null ? null : call.getName(); for (Route route : routes) { if (route.supports(toolName)) { return route.getExecutor().execute(call); } } if (fallbackExecutor != null) { return fallbackExecutor.execute(call); } throw new IllegalArgumentException("No tool executor found for tool: " + toolName); } public static Route route(Set toolNames, ToolExecutor executor) { return new Route(toolNames, executor); } public static class Route { private final Set toolNames; private final ToolExecutor executor; public Route(Set toolNames, ToolExecutor executor) { if (toolNames == null) { this.toolNames = Collections.emptySet(); } else { this.toolNames = new HashSet<>(toolNames); } this.executor = executor; } public boolean supports(String toolName) { return toolName != null && toolNames.contains(toolName); } public ToolExecutor getExecutor() { return executor; } } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ToolExecutorDecorator.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; public interface ToolExecutorDecorator { ToolExecutor decorate(String toolName, ToolExecutor delegate); } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/WriteFileToolExecutor.java ================================================ package io.github.lnyocly.ai4j.coding.tool; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.Locale; public class WriteFileToolExecutor implements ToolExecutor { private final WorkspaceContext workspaceContext; public WriteFileToolExecutor(WorkspaceContext workspaceContext) { this.workspaceContext = workspaceContext; } @Override public String execute(AgentToolCall call) throws Exception { JSONObject arguments = parseArguments(call == null ? null : call.getArguments()); String path = safeTrim(arguments.getString("path")); if (isBlank(path)) { throw new IllegalArgumentException("path is required"); } String content = arguments.containsKey("content") && arguments.get("content") != null ? arguments.getString("content") : ""; String mode = firstNonBlank(safeTrim(arguments.getString("mode")), "overwrite").toLowerCase(Locale.ROOT); Path file = resolvePath(path); if (Files.exists(file) && Files.isDirectory(file)) { throw new IllegalArgumentException("Target is a directory: " + path); } boolean existed = Files.exists(file); boolean appended = false; boolean created; byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8); Path parent = file.getParent(); if (parent != null) { Files.createDirectories(parent); } if ("create".equals(mode)) { if (existed) { throw new IllegalArgumentException("File already exists: " + path); } Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE}); created = true; } else if ("overwrite".equals(mode)) { Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE}); created = !existed; } else if ("append".equals(mode)) { Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE}); created = !existed; appended = true; } else { throw new IllegalArgumentException("Unsupported write mode: " + mode); } JSONObject result = new JSONObject(); result.put("path", path); result.put("resolvedPath", file.toString()); result.put("mode", mode); result.put("created", created); result.put("appended", appended); result.put("bytesWritten", bytes.length); return JSON.toJSONString(result); } private Path resolvePath(String path) { Path candidate = Paths.get(path); if (candidate.isAbsolute()) { return candidate.toAbsolutePath().normalize(); } Path root = workspaceContext == null ? Paths.get(".").toAbsolutePath().normalize() : workspaceContext.getRoot(); return root.resolve(path).toAbsolutePath().normalize(); } private JSONObject parseArguments(String rawArguments) { if (rawArguments == null || rawArguments.trim().isEmpty()) { return new JSONObject(); } return JSON.parseObject(rawArguments); } private String safeTrim(String value) { return value == null ? null : value.trim(); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (!isBlank(value)) { return value; } } return null; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/LocalWorkspaceFileService.java ================================================ package io.github.lnyocly.ai4j.coding.workspace; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.stream.Stream; public class LocalWorkspaceFileService implements WorkspaceFileService { private final WorkspaceContext workspaceContext; public LocalWorkspaceFileService(WorkspaceContext workspaceContext) { this.workspaceContext = workspaceContext; } @Override public List listFiles(String path, int maxDepth, int maxEntries) throws IOException { Path start = workspaceContext.resolveReadablePath(path); if (!Files.exists(start)) { return Collections.emptyList(); } if (!Files.isDirectory(start)) { throw new IllegalArgumentException("Path is not a directory: " + path); } int effectiveMaxDepth = maxDepth <= 0 ? 4 : maxDepth; int effectiveMaxEntries = maxEntries <= 0 ? 200 : maxEntries; List entries = new ArrayList<>(); try (Stream stream = Files.walk(start, effectiveMaxDepth)) { Iterator iterator = stream .filter(candidate -> !candidate.equals(start)) .filter(candidate -> !workspaceContext.isExcluded(candidate)) .iterator(); while (iterator.hasNext() && entries.size() < effectiveMaxEntries) { Path candidate = iterator.next(); entries.add(WorkspaceEntry.builder() .path(toRelativePath(candidate)) .directory(Files.isDirectory(candidate)) .size(safeSize(candidate)) .build()); } } return entries; } @Override public WorkspaceFileReadResult readFile(String path, Integer startLine, Integer endLine, Integer maxChars) throws IOException { Path file = workspaceContext.resolveReadablePath(path); if (!Files.exists(file)) { throw new IllegalArgumentException("File does not exist: " + path); } if (Files.isDirectory(file)) { throw new IllegalArgumentException("Path is a directory: " + path); } List lines = Files.readAllLines(file, StandardCharsets.UTF_8); int effectiveStartLine = startLine == null || startLine < 1 ? 1 : startLine; int effectiveEndLine = endLine == null || endLine > lines.size() ? lines.size() : endLine; if (effectiveEndLine < effectiveStartLine) { effectiveEndLine = effectiveStartLine - 1; } StringBuilder contentBuilder = new StringBuilder(); for (int i = effectiveStartLine; i <= effectiveEndLine; i++) { if (i > lines.size()) { break; } if (contentBuilder.length() > 0) { contentBuilder.append('\n'); } contentBuilder.append(lines.get(i - 1)); } int effectiveMaxChars = maxChars == null || maxChars <= 0 ? 12000 : maxChars; String content = contentBuilder.toString(); boolean truncated = false; if (content.length() > effectiveMaxChars) { content = content.substring(0, effectiveMaxChars); truncated = true; } return WorkspaceFileReadResult.builder() .path(toRelativePath(file)) .content(content) .startLine(effectiveStartLine) .endLine(effectiveEndLine) .truncated(truncated) .build(); } @Override public WorkspaceWriteResult writeFile(String path, String content, boolean append) throws IOException { Path file = workspaceContext.resolveWorkspacePath(path); if (Files.exists(file) && Files.isDirectory(file)) { throw new IllegalArgumentException("Path is a directory: " + path); } boolean created = !Files.exists(file); Path parent = file.getParent(); if (parent != null) { Files.createDirectories(parent); } byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8); if (append) { Files.write(file, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } else { Files.write(file, bytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } return WorkspaceWriteResult.builder() .path(toRelativePath(file)) .bytesWritten(bytes.length) .created(created) .appended(append) .build(); } private long safeSize(Path path) { if (Files.isDirectory(path)) { return 0L; } try { return Files.size(path); } catch (IOException ignored) { return 0L; } } private String toRelativePath(Path path) { Path root = workspaceContext.getRoot(); if (path == null) { return ""; } if (!path.startsWith(root)) { return path.toString().replace('\\', '/'); } if (path.equals(root)) { return "."; } return root.relativize(path).toString().replace('\\', '/'); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceContext.java ================================================ package io.github.lnyocly.ai4j.coding.workspace; import io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class WorkspaceContext { @Builder.Default private String rootPath = Paths.get(".").toAbsolutePath().normalize().toString(); @Builder.Default private List excludedPaths = defaultExcludedPaths(); @Builder.Default private boolean allowOutsideWorkspace = false; private String description; @Builder.Default private List skillDirectories = new ArrayList(); @Builder.Default private List allowedReadRoots = new ArrayList(); @Builder.Default private List availableSkills = new ArrayList(); public Path getRoot() { return Paths.get(rootPath).toAbsolutePath().normalize(); } public Path resolveWorkspacePath(String path) { Path root = getRoot(); if (isBlank(path)) { return root; } Path candidate = Paths.get(path); if (!candidate.isAbsolute()) { candidate = root.resolve(path); } candidate = candidate.toAbsolutePath().normalize(); if (!allowOutsideWorkspace && !candidate.startsWith(root)) { throw new IllegalArgumentException("Path escapes workspace root: " + path); } return candidate; } public Path resolveReadablePath(String path) { Path root = getRoot(); if (isBlank(path)) { return root; } Path candidate = Paths.get(path); if (!candidate.isAbsolute()) { candidate = root.resolve(path); } candidate = candidate.toAbsolutePath().normalize(); if (allowOutsideWorkspace || candidate.startsWith(root)) { return candidate; } for (Path allowedRoot : getAllowedReadRootPaths()) { if (candidate.startsWith(allowedRoot)) { return candidate; } } throw new IllegalArgumentException("Path escapes workspace root: " + path); } public List getAllowedReadRootPaths() { if (allowedReadRoots == null || allowedReadRoots.isEmpty()) { return Collections.emptyList(); } List paths = new ArrayList(); for (String allowedReadRoot : allowedReadRoots) { if (isBlank(allowedReadRoot)) { continue; } paths.add(Paths.get(allowedReadRoot).toAbsolutePath().normalize()); } return paths; } public boolean isExcluded(Path absolutePath) { Path root = getRoot(); if (absolutePath == null || !absolutePath.startsWith(root)) { return false; } Path relative = root.relativize(absolutePath); for (Path part : relative) { if (excludedPaths.contains(part.toString())) { return true; } } return false; } private static List defaultExcludedPaths() { return new ArrayList<>(Arrays.asList(".git", "target", "node_modules", ".idea")); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceEntry.java ================================================ package io.github.lnyocly.ai4j.coding.workspace; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class WorkspaceEntry { private String path; private boolean directory; private long size; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceFileReadResult.java ================================================ package io.github.lnyocly.ai4j.coding.workspace; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class WorkspaceFileReadResult { private String path; private String content; private int startLine; private int endLine; private boolean truncated; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceFileService.java ================================================ package io.github.lnyocly.ai4j.coding.workspace; import java.io.IOException; import java.util.List; public interface WorkspaceFileService { List listFiles(String path, int maxDepth, int maxEntries) throws IOException; WorkspaceFileReadResult readFile(String path, Integer startLine, Integer endLine, Integer maxChars) throws IOException; WorkspaceWriteResult writeFile(String path, String content, boolean append) throws IOException; } ================================================ FILE: ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceWriteResult.java ================================================ package io.github.lnyocly.ai4j.coding.workspace; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class WorkspaceWriteResult { private String path; private long bytesWritten; private boolean created; private boolean appended; } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/ApplyPatchToolExecutorTest.java ================================================ package io.github.lnyocly.ai4j.coding; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.coding.tool.ApplyPatchToolExecutor; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class ApplyPatchToolExecutorTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldAddUpdateAndDeleteFiles() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-patch").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .build(); ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext); String addResult = executor.execute(call(patch( "*** Begin Patch", "*** Add File: notes/todo.txt", "+alpha", "+beta", "*** End Patch" ))); JSONObject add = JSON.parseObject(addResult); assertEquals(1, add.getIntValue("filesChanged")); assertEquals("notes/todo.txt", add.getJSONArray("fileChanges").getJSONObject(0).getString("path")); assertEquals("add", add.getJSONArray("fileChanges").getJSONObject(0).getString("operation")); assertEquals(2, add.getJSONArray("fileChanges").getJSONObject(0).getIntValue("linesAdded")); assertTrue(Files.exists(workspaceRoot.resolve("notes/todo.txt"))); assertEquals("alpha\nbeta", new String(Files.readAllBytes(workspaceRoot.resolve("notes/todo.txt")), StandardCharsets.UTF_8)); Files.write(workspaceRoot.resolve("demo.txt"), "first\nsecond\nthird".getBytes(StandardCharsets.UTF_8)); String updateResult = executor.execute(call(patch( "*** Begin Patch", "*** Update File: demo.txt", "@@", " first", "-second", "+updated-second", " third", "*** End Patch" ))); JSONObject update = JSON.parseObject(updateResult); assertEquals(1, update.getIntValue("operationsApplied")); assertEquals("demo.txt", update.getJSONArray("fileChanges").getJSONObject(0).getString("path")); assertEquals("update", update.getJSONArray("fileChanges").getJSONObject(0).getString("operation")); assertEquals(1, update.getJSONArray("fileChanges").getJSONObject(0).getIntValue("linesAdded")); assertEquals(1, update.getJSONArray("fileChanges").getJSONObject(0).getIntValue("linesRemoved")); assertEquals("first\nupdated-second\nthird", new String(Files.readAllBytes(workspaceRoot.resolve("demo.txt")), StandardCharsets.UTF_8)); String deleteResult = executor.execute(call(patch( "*** Begin Patch", "*** Delete File: demo.txt", "*** End Patch" ))); JSONObject delete = JSON.parseObject(deleteResult); assertEquals(1, delete.getIntValue("filesChanged")); assertEquals("demo.txt", delete.getJSONArray("fileChanges").getJSONObject(0).getString("path")); assertEquals("delete", delete.getJSONArray("fileChanges").getJSONObject(0).getString("operation")); assertEquals(3, delete.getJSONArray("fileChanges").getJSONObject(0).getIntValue("linesRemoved")); assertFalse(Files.exists(workspaceRoot.resolve("demo.txt"))); } @Test public void shouldAcceptShortDirectiveAliases() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-patch-short-directives").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .build(); ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext); String addResult = executor.execute(call(patch( "*** Begin Patch", "*** Add: calculator.py", "+print('ok')", "*** End Patch" ))); JSONObject add = JSON.parseObject(addResult); assertEquals(1, add.getIntValue("filesChanged")); assertEquals("calculator.py", add.getJSONArray("fileChanges").getJSONObject(0).getString("path")); assertTrue(Files.exists(workspaceRoot.resolve("calculator.py"))); assertEquals("print('ok')", new String(Files.readAllBytes(workspaceRoot.resolve("calculator.py")), StandardCharsets.UTF_8)); } @Test public void shouldAcceptDirectiveWithoutSpaceAfterColonAndWorkspaceRootPath() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-patch-relaxed-directive").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .build(); ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext); Files.write(workspaceRoot.resolve("sample.txt"), "value=1".getBytes(StandardCharsets.UTF_8)); String updateResult = executor.execute(call(patch( "*** Begin Patch", "*** Update File:/sample.txt", "@@", "-value=1", "+value=2", "*** End Patch" ))); JSONObject update = JSON.parseObject(updateResult); assertEquals(1, update.getIntValue("operationsApplied")); assertEquals("sample.txt", update.getJSONArray("fileChanges").getJSONObject(0).getString("path")); assertEquals("value=2", new String(Files.readAllBytes(workspaceRoot.resolve("sample.txt")), StandardCharsets.UTF_8)); } @Test public void shouldAcceptUnifiedDiffMetadataBeforePatchHunk() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-patch-unified-diff").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .build(); ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext); Files.write(workspaceRoot.resolve("sample.txt"), "value=1".getBytes(StandardCharsets.UTF_8)); String updateResult = executor.execute(call(patch( "*** Begin Patch", "*** Update File:/sample.txt", "--- a/sample.txt", "+++ b/sample.txt", "@@", "-value=1", "+value=2", "*** End Patch" ))); JSONObject update = JSON.parseObject(updateResult); assertEquals(1, update.getIntValue("operationsApplied")); assertEquals("sample.txt", update.getJSONArray("fileChanges").getJSONObject(0).getString("path")); assertEquals(1, update.getJSONArray("fileChanges").getJSONObject(0).getIntValue("linesAdded")); assertEquals(1, update.getJSONArray("fileChanges").getJSONObject(0).getIntValue("linesRemoved")); assertEquals("value=2", new String(Files.readAllBytes(workspaceRoot.resolve("sample.txt")), StandardCharsets.UTF_8)); } @Test(expected = IllegalArgumentException.class) public void shouldRejectEscapingWorkspace() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-patch-escape").toPath(); ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor( WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build() ); executor.execute(call(patch( "*** Begin Patch", "*** Add File: ../escape.txt", "+blocked", "*** End Patch" ))); } private AgentToolCall call(String patch) { JSONObject arguments = new JSONObject(); arguments.put("patch", patch); return AgentToolCall.builder() .name(CodingToolNames.APPLY_PATCH) .arguments(arguments.toJSONString()) .build(); } private String patch(String... lines) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < lines.length; i++) { if (i > 0) { builder.append('\n'); } builder.append(lines[i]); } return builder.toString(); } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/BashToolExecutorTest.java ================================================ package io.github.lnyocly.ai4j.coding; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.tool.BashToolExecutor; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class BashToolExecutorTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldExecuteForegroundCommand() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-bash-exec").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build(); BashToolExecutor executor = new BashToolExecutor( workspaceContext, CodingAgentOptions.builder().build(), new SessionProcessRegistry(workspaceContext, CodingAgentOptions.builder().build()) ); String raw = executor.execute(call(json("action", "exec", "command", "echo hello-ai4j"))); JSONObject result = JSON.parseObject(raw); assertFalse(result.getBooleanValue("timedOut")); assertEquals(0, result.getIntValue("exitCode")); assertTrue(result.getString("stdout").toLowerCase().contains("hello-ai4j")); } @Test public void shouldManageBackgroundProcessLifecycle() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-bash-bg").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build(); CodingAgentOptions options = CodingAgentOptions.builder().build(); SessionProcessRegistry registry = new SessionProcessRegistry(workspaceContext, options); BashToolExecutor executor = new BashToolExecutor(workspaceContext, options, registry); String startRaw = executor.execute(call(json("action", "start", "command", backgroundCommand()))); JSONObject start = JSON.parseObject(startRaw); String processId = start.getString("processId"); assertEquals("RUNNING", start.getString("status")); JSONObject logs = awaitLogs(executor, processId, 3000L); assertTrue(logs.getLongValue("nextOffset") > 0L); String stopRaw = executor.execute(call(json("action", "stop", "processId", processId))); JSONObject stop = JSON.parseObject(stopRaw); assertTrue("STOPPED".equals(stop.getString("status")) || "EXITED".equals(stop.getString("status"))); String listRaw = executor.execute(call(json("action", "list"))); JSONArray list = JSON.parseArray(listRaw); assertEquals(1, list.size()); } @Test public void shouldWriteToBackgroundProcess() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-bash-write").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build(); CodingAgentOptions options = CodingAgentOptions.builder().build(); SessionProcessRegistry registry = new SessionProcessRegistry(workspaceContext, options); BashToolExecutor executor = new BashToolExecutor(workspaceContext, options, registry); String startRaw = executor.execute(call(json("action", "start", "command", stdinEchoCommand()))); String processId = JSON.parseObject(startRaw).getString("processId"); executor.execute(call(json("action", "write", "processId", processId, "input", "hello-stdin\n"))); JSONObject logs = awaitLogs(executor, processId, 3000L); assertTrue(logs.getString("content").toLowerCase().contains("hello-stdin")); executor.execute(call(json("action", "stop", "processId", processId))); } @Test public void shouldExposeRestoredProcessesAsMetadataOnly() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-bash-restored").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build(); CodingAgentOptions options = CodingAgentOptions.builder().build(); SessionProcessRegistry liveRegistry = new SessionProcessRegistry(workspaceContext, options); BashToolExecutor liveExecutor = new BashToolExecutor(workspaceContext, options, liveRegistry); String startRaw = liveExecutor.execute(call(json("action", "start", "command", backgroundCommand()))); String processId = JSON.parseObject(startRaw).getString("processId"); assertTrue(processId != null && !processId.isEmpty()); JSONObject liveLogs = awaitLogs(liveExecutor, processId, 3000L); assertTrue(liveLogs.getString("content").toLowerCase().contains("ready")); liveRegistry.close(); SessionProcessRegistry restoredRegistry = new SessionProcessRegistry(workspaceContext, options); restoredRegistry.restoreSnapshots(liveRegistry.exportSnapshots()); BashToolExecutor restoredExecutor = new BashToolExecutor(workspaceContext, options, restoredRegistry); JSONObject status = JSON.parseObject(restoredExecutor.execute(call(json("action", "status", "processId", processId)))); assertTrue(status.getBooleanValue("restored")); assertFalse(status.getBooleanValue("controlAvailable")); JSONObject logs = JSON.parseObject(restoredExecutor.execute(call(json("action", "logs", "processId", processId)))); assertEquals(processId, logs.getString("processId")); assertTrue(logs.getString("content").toLowerCase().contains("ready")); assertTrue(logs.getLongValue("nextOffset") > 0L); try { restoredExecutor.execute(call(json("action", "stop", "processId", processId))); fail("Expected metadata-only process stop to be rejected"); } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("metadata only")); } } private AgentToolCall call(String arguments) { return AgentToolCall.builder() .name(CodingToolNames.BASH) .arguments(arguments) .build(); } private String json(Object... pairs) { JSONObject object = new JSONObject(); for (int i = 0; i < pairs.length; i += 2) { object.put(String.valueOf(pairs[i]), pairs[i + 1]); } return JSON.toJSONString(object); } private JSONObject awaitLogs(BashToolExecutor executor, String processId, long timeoutMs) throws Exception { long deadline = System.currentTimeMillis() + timeoutMs; JSONObject last = null; while (System.currentTimeMillis() < deadline) { String logsRaw = executor.execute(call(json("action", "logs", "processId", processId, "limit", 8000))); last = JSON.parseObject(logsRaw); String content = last.getString("content"); if (content != null && !content.trim().isEmpty()) { return last; } Thread.sleep(150L); } return last == null ? new JSONObject() : last; } private String backgroundCommand() { if (isWindows()) { return "powershell -NoProfile -Command \"Write-Output ready; Start-Sleep -Seconds 10\""; } return "echo ready && sleep 10"; } private String stdinEchoCommand() { if (isWindows()) { return "more"; } return "cat"; } private boolean isWindows() { return System.getProperty("os.name", "").toLowerCase().contains("win"); } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilderTest.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy; import io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingSessionMode; import io.github.lnyocly.ai4j.coding.definition.StaticCodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.task.CodingTask; import io.github.lnyocly.ai4j.coding.task.CodingTaskManager; import io.github.lnyocly.ai4j.coding.task.CodingTaskStatus; import io.github.lnyocly.ai4j.coding.task.InMemoryCodingTaskManager; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque; import java.util.LinkedHashSet; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class CodingAgentBuilderTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldRunBuiltInCodingToolWithinAgentLoop() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-agent").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding workspace") .build(); QueueModelClient modelClient = new QueueModelClient(); modelClient.enqueue(AgentModelResult.builder() .toolCalls(Arrays.asList(AgentToolCall.builder() .name(CodingToolNames.BASH) .arguments("{\"action\":\"exec\",\"command\":\"echo session-ready\"}") .callId("call-1") .build())) .rawResponse("tool-call") .build()); modelClient.enqueue(AgentModelResult.builder() .outputText("done") .rawResponse("final") .build()); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSession session = agent.newSession(); CodingAgentResult result; try { result = session.run("Run a quick shell check."); } finally { session.close(); } assertNotNull(session.getSessionId()); assertEquals("done", result.getOutputText()); assertEquals(1, result.getToolResults().size()); assertTrue(result.getToolResults().get(0).getOutput().toLowerCase().contains("session-ready")); } @Test public void shouldApplyPatchWithinAgentLoop() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-agent-patch").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit patch workspace") .build(); QueueModelClient modelClient = new QueueModelClient(); modelClient.enqueue(AgentModelResult.builder() .toolCalls(Arrays.asList(AgentToolCall.builder() .name(CodingToolNames.APPLY_PATCH) .arguments("{\"patch\":\"*** Begin Patch\\n*** Add File: notes/patch.txt\\n+created-by-agent\\n*** End Patch\"}") .callId("call-2") .build())) .rawResponse("tool-call") .build()); modelClient.enqueue(AgentModelResult.builder() .outputText("patch-done") .rawResponse("final") .build()); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSession session = agent.newSession(); CodingAgentResult result; try { result = session.run("Create a file by patch."); } finally { session.close(); } assertEquals("patch-done", result.getOutputText()); assertEquals(1, result.getToolResults().size()); assertTrue(Files.exists(workspaceRoot.resolve("notes/patch.txt"))); assertEquals("created-by-agent", new String(Files.readAllBytes(workspaceRoot.resolve("notes/patch.txt")), StandardCharsets.UTF_8)); } @Test public void shouldReturnToolErrorInsteadOfAbortingSession() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-agent-invalid-patch").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit invalid patch workspace") .build(); QueueModelClient modelClient = new QueueModelClient(); modelClient.enqueue(AgentModelResult.builder() .toolCalls(Arrays.asList(AgentToolCall.builder() .name(CodingToolNames.APPLY_PATCH) .arguments("{\"patch\":\"*** Begin Patch\\n*** Unknown: calculator.py\\n*** End Patch\"}") .callId("call-invalid-patch") .build())) .rawResponse("tool-call") .build()); modelClient.enqueue(AgentModelResult.builder() .outputText("tool-error-handled") .rawResponse("final") .build()); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSession session = agent.newSession(); CodingAgentResult result; try { result = session.run("Trigger an invalid patch."); } finally { session.close(); } assertEquals("tool-error-handled", result.getOutputText()); assertEquals(1, result.getToolResults().size()); assertTrue(result.getToolResults().get(0).getOutput().contains("TOOL_ERROR:")); assertTrue(result.getToolResults().get(0).getOutput().contains("Unsupported patch line")); assertTrue(Files.notExists(workspaceRoot.resolve("calculator.py"))); } @Test public void shouldAllowModelToInvokeDelegateTool() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-agent-delegate").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit delegate workspace") .build(); QueueModelClient modelClient = new QueueModelClient(); modelClient.enqueue(AgentModelResult.builder() .toolCalls(Arrays.asList(AgentToolCall.builder() .name("delegate_plan") .arguments("{\"task\":\"Draft a short implementation plan\",\"context\":\"Focus on tests first\"}") .callId("delegate-call-1") .build())) .rawResponse("delegate-tool-call") .build()); modelClient.enqueue(AgentModelResult.builder() .outputText("delegate plan ready") .rawResponse("delegate-child") .build()); modelClient.enqueue(AgentModelResult.builder() .outputText("root-complete") .rawResponse("root-final") .build()); CodingTaskManager taskManager = new InMemoryCodingTaskManager(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(CodingAgentOptions.builder() .autoContinueEnabled(false) .build()) .taskManager(taskManager) .build(); CodingSession session = agent.newSession(); CodingAgentResult result; try { result = session.run("Delegate planning to a worker."); } finally { session.close(); } assertNotNull(result.getOutputText()); assertTrue(!result.getOutputText().trim().isEmpty()); assertEquals(1, result.getToolResults().size()); String toolOutput = result.getToolResults().get(0).getOutput(); assertTrue(toolOutput, toolOutput.contains("\"definitionName\":\"plan\"")); assertTrue(toolOutput, toolOutput.contains("\"status\":\"completed\"")); assertTrue(toolOutput, toolOutput.contains("\"output\":\"delegate plan ready\"")); assertEquals(1, taskManager.listTasksByParentSessionId(session.getSessionId()).size()); CodingTask task = taskManager.listTasksByParentSessionId(session.getSessionId()).get(0); assertEquals(CodingTaskStatus.COMPLETED, task.getStatus()); assertEquals("delegate plan ready", task.getOutputText()); } @Test public void shouldAllowModelToInvokeGenericSubAgentToolWithinCodingSession() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-agent-subagent").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit subagent workspace") .build(); QueueModelClient reviewModelClient = new QueueModelClient(); reviewModelClient.enqueue(AgentModelResult.builder() .outputText("review-ready") .rawResponse("review-final") .build()); QueueModelClient rootModelClient = new QueueModelClient(); rootModelClient.enqueue(AgentModelResult.builder() .toolCalls(Arrays.asList(AgentToolCall.builder() .name("subagent_review") .arguments("{\"task\":\"Review the proposed patch\",\"context\":\"Focus on correctness risks\"}") .callId("subagent-call-1") .build())) .rawResponse("subagent-tool-call") .build()); rootModelClient.enqueue(AgentModelResult.builder() .outputText("root-finished") .rawResponse("root-final") .build()); CodingAgent agent = CodingAgents.builder() .modelClient(rootModelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .subAgent(SubAgentDefinition.builder() .name("review") .toolName("subagent_review") .description("Review coding changes and report risks.") .agent(Agents.react() .modelClient(reviewModelClient) .model("glm-4.5-flash") .build()) .build()) .handoffPolicy(HandoffPolicy.builder().maxDepth(1).build()) .build(); CodingAgentResult result; try (CodingSession session = agent.newSession()) { result = session.run("Ask the reviewer for a focused review."); } assertEquals("root-finished", result.getOutputText()); assertEquals(1, result.getToolResults().size()); String toolOutput = result.getToolResults().get(0).getOutput(); assertTrue(toolOutput, toolOutput.contains("\"subagent\":\"review\"")); assertTrue(toolOutput, toolOutput.contains("\"toolName\":\"subagent_review\"")); assertTrue(toolOutput, toolOutput.contains("\"output\":\"review-ready\"")); } @Test public void shouldAllowDelegatedCodingWorkerToInvokeConfiguredSubAgent() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-agent-delegate-subagent").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit delegate subagent workspace") .build(); QueueModelClient reviewModelClient = new QueueModelClient(); reviewModelClient.enqueue(AgentModelResult.builder() .outputText("review-ready") .rawResponse("review-final") .build()); QueueModelClient rootAndDelegateModelClient = new QueueModelClient(); rootAndDelegateModelClient.enqueue(AgentModelResult.builder() .toolCalls(Arrays.asList(AgentToolCall.builder() .name("delegate_review_worker") .arguments("{\"task\":\"Delegate a review step\",\"context\":\"Use the review specialist\"}") .callId("delegate-review-call-1") .build())) .rawResponse("delegate-worker-call") .build()); rootAndDelegateModelClient.enqueue(AgentModelResult.builder() .toolCalls(Arrays.asList(AgentToolCall.builder() .name("subagent_review") .arguments("{\"task\":\"Review the delegated change\"}") .callId("delegate-subagent-call-1") .build())) .rawResponse("delegate-subagent-call") .build()); rootAndDelegateModelClient.enqueue(AgentModelResult.builder() .outputText("delegate-worker-finished") .rawResponse("delegate-worker-final") .build()); rootAndDelegateModelClient.enqueue(AgentModelResult.builder() .outputText("root-finished") .rawResponse("root-final") .build()); CodingTaskManager taskManager = new InMemoryCodingTaskManager(); StaticCodingAgentDefinitionRegistry definitionRegistry = new StaticCodingAgentDefinitionRegistry( Arrays.asList(CodingAgentDefinition.builder() .name("review-worker") .toolName("delegate_review_worker") .description("Delegated worker that can call the review subagent.") .allowedToolNames(new LinkedHashSet(Arrays.asList("subagent_review"))) .sessionMode(CodingSessionMode.FORK) .build()) ); CodingAgent agent = CodingAgents.builder() .modelClient(rootAndDelegateModelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .definitionRegistry(definitionRegistry) .taskManager(taskManager) .subAgent(SubAgentDefinition.builder() .name("review") .toolName("subagent_review") .description("Review coding changes and report risks.") .agent(Agents.react() .modelClient(reviewModelClient) .model("glm-4.5-flash") .build()) .build()) .handoffPolicy(HandoffPolicy.builder().maxDepth(2).build()) .build(); CodingAgentResult result; try (CodingSession session = agent.newSession()) { result = session.run("Delegate review work to a specialized worker."); assertEquals("root-finished", result.getOutputText()); assertEquals(1, result.getToolResults().size()); String toolOutput = result.getToolResults().get(0).getOutput(); assertTrue(toolOutput, toolOutput.contains("\"definitionName\":\"review-worker\"")); assertTrue(toolOutput, toolOutput.contains("\"status\":\"completed\"")); assertTrue(toolOutput, toolOutput.contains("\"output\":\"delegate-worker-finished\"")); assertEquals(1, taskManager.listTasksByParentSessionId(session.getSessionId()).size()); CodingTask task = taskManager.listTasksByParentSessionId(session.getSessionId()).get(0); assertEquals(CodingTaskStatus.COMPLETED, task.getStatus()); assertEquals("delegate-worker-finished", task.getOutputText()); } } private static class QueueModelClient implements AgentModelClient { private final Deque results = new ArrayDeque<>(); private void enqueue(AgentModelResult result) { results.addLast(result); } @Override public AgentModelResult create(AgentPrompt prompt) { return results.removeFirst(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = results.removeFirst(); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); } return result; } } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingRuntimeTest.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.definition.BuiltInCodingAgentDefinitions; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition; import io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest; import io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult; import io.github.lnyocly.ai4j.coding.policy.CodingToolContextPolicy; import io.github.lnyocly.ai4j.coding.policy.CodingToolPolicyResolver; import io.github.lnyocly.ai4j.coding.session.CodingSessionLink; import io.github.lnyocly.ai4j.coding.session.CodingSessionLinkStore; import io.github.lnyocly.ai4j.coding.session.InMemoryCodingSessionLinkStore; import io.github.lnyocly.ai4j.coding.task.CodingTask; import io.github.lnyocly.ai4j.coding.task.CodingTaskManager; import io.github.lnyocly.ai4j.coding.task.CodingTaskStatus; import io.github.lnyocly.ai4j.coding.task.InMemoryCodingTaskManager; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.tool.CodingToolRegistryFactory; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class CodingRuntimeTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldResolveBuiltInDefinitionsByNameAndToolName() { CodingAgentDefinitionRegistry registry = BuiltInCodingAgentDefinitions.registry(); assertEquals(4, registry.listDefinitions().size()); assertNotNull(registry.getDefinition(BuiltInCodingAgentDefinitions.GENERAL_PURPOSE)); assertNotNull(registry.getDefinition("delegate_explore")); assertTrue(registry.getDefinition("explore").getAllowedToolNames().contains(CodingToolNames.READ_FILE)); assertFalse(registry.getDefinition("explore").getAllowedToolNames().contains(CodingToolNames.WRITE_FILE)); } @Test public void shouldExposeDelegateToolsInBuiltInRegistry() { AgentToolRegistry registry = CodingAgentBuilder.createBuiltInRegistry( CodingAgentOptions.builder().build(), BuiltInCodingAgentDefinitions.registry() ); List toolNames = collectToolNames(registry); assertTrue(toolNames.contains("delegate_general_purpose")); assertTrue(toolNames.contains("delegate_explore")); assertTrue(toolNames.contains("delegate_plan")); assertTrue(toolNames.contains("delegate_verification")); } @Test public void shouldFilterToolsForReadOnlyDefinitions() throws Exception { AgentToolRegistry registry = CodingToolRegistryFactory.createBuiltInRegistry(); ToolExecutor executor = new ToolExecutor() { @Override public String execute(AgentToolCall call) { return call == null ? null : call.getName(); } }; CodingAgentDefinition definition = BuiltInCodingAgentDefinitions.registry().getDefinition("explore"); CodingToolContextPolicy policy = new CodingToolPolicyResolver().resolve(registry, executor, definition); assertEquals(2, policy.getToolRegistry().getTools().size()); assertEquals(CodingToolNames.READ_FILE, policy.getToolExecutor().execute(AgentToolCall.builder().name(CodingToolNames.READ_FILE).build())); try { policy.getToolExecutor().execute(AgentToolCall.builder().name(CodingToolNames.WRITE_FILE).build()); fail("Expected disallowed tool execution to fail"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("not allowed")); } } @Test public void shouldDelegateSynchronouslyAndTrackTaskAndLinks() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("runtime-sync").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit runtime sync workspace") .build(); QueueModelClient modelClient = new QueueModelClient(); modelClient.enqueue(AgentModelResult.builder().outputText("sync-child-done").build()); CodingTaskManager taskManager = new InMemoryCodingTaskManager(); CodingSessionLinkStore linkStore = new InMemoryCodingSessionLinkStore(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .taskManager(taskManager) .sessionLinkStore(linkStore) .build(); try (CodingSession session = agent.newSession()) { CodingDelegateResult result = session.delegate(CodingDelegateRequest.builder() .definitionName("explore") .input("Inspect the workspace and summarize what matters.") .build()); assertEquals(CodingTaskStatus.COMPLETED, result.getStatus()); assertEquals("sync-child-done", result.getOutputText()); CodingTask task = taskManager.getTask(result.getTaskId()); assertNotNull(task); assertEquals(CodingTaskStatus.COMPLETED, task.getStatus()); assertEquals(session.getSessionId(), task.getParentSessionId()); List links = linkStore.listLinksByParentSessionId(session.getSessionId()); assertEquals(1, links.size()); assertEquals(result.getTaskId(), links.get(0).getTaskId()); } } @Test public void shouldFallbackToAssistantMemoryWhenDelegateOutputTextIsBlank() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("runtime-sync-memory-fallback").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit runtime sync workspace fallback") .build(); QueueModelClient modelClient = new QueueModelClient(); modelClient.enqueue(AgentModelResult.builder() .outputText("") .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", "delegate plan ready from memory"))) .build()); CodingTaskManager taskManager = new InMemoryCodingTaskManager(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(CodingAgentOptions.builder() .autoContinueEnabled(false) .build()) .taskManager(taskManager) .build(); try (CodingSession session = agent.newSession()) { CodingDelegateResult result = session.delegate(CodingDelegateRequest.builder() .definitionName("plan") .input("Draft a short implementation plan.") .build()); assertEquals(CodingTaskStatus.COMPLETED, result.getStatus()); assertEquals("delegate plan ready from memory", result.getOutputText()); CodingTask task = taskManager.getTask(result.getTaskId()); assertNotNull(task); assertEquals("delegate plan ready from memory", task.getOutputText()); } } @Test public void shouldDelegateInBackgroundAndEventuallyComplete() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("runtime-background").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit runtime background workspace") .build(); BlockingModelClient modelClient = new BlockingModelClient("background-child-done"); CodingTaskManager taskManager = new InMemoryCodingTaskManager(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .taskManager(taskManager) .build(); try (CodingSession session = agent.newSession()) { CodingDelegateResult result = session.delegate(CodingDelegateRequest.builder() .definitionName("verification") .input("Run verification in the background.") .build()); assertTrue(result.isBackground()); assertTrue(modelClient.awaitStarted(3, TimeUnit.SECONDS)); CodingTask runningTask = taskManager.getTask(result.getTaskId()); assertNotNull(runningTask); assertTrue(Arrays.asList(CodingTaskStatus.QUEUED, CodingTaskStatus.STARTING, CodingTaskStatus.RUNNING, CodingTaskStatus.COMPLETED) .contains(runningTask.getStatus())); modelClient.release(); CodingTask completed = waitForTask(taskManager, result.getTaskId(), 5000L); assertNotNull(completed); assertEquals(CodingTaskStatus.COMPLETED, completed.getStatus()); assertEquals("background-child-done", completed.getOutputText()); } } private CodingTask waitForTask(CodingTaskManager taskManager, String taskId, long timeoutMs) throws Exception { long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { CodingTask task = taskManager.getTask(taskId); if (task != null && task.getStatus() != null && task.getStatus().isTerminal()) { return task; } Thread.sleep(50L); } return taskManager.getTask(taskId); } private List collectToolNames(AgentToolRegistry registry) { List toolNames = new java.util.ArrayList(); if (registry == null || registry.getTools() == null) { return toolNames; } for (Object tool : registry.getTools()) { if (tool instanceof io.github.lnyocly.ai4j.platform.openai.tool.Tool) { io.github.lnyocly.ai4j.platform.openai.tool.Tool.Function function = ((io.github.lnyocly.ai4j.platform.openai.tool.Tool) tool).getFunction(); if (function != null && function.getName() != null) { toolNames.add(function.getName()); } } } return toolNames; } private static class QueueModelClient implements AgentModelClient { private final LinkedBlockingQueue results = new LinkedBlockingQueue(); private void enqueue(AgentModelResult result) { results.offer(result); } @Override public AgentModelResult create(AgentPrompt prompt) throws Exception { return results.poll(3, TimeUnit.SECONDS); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } } private static class BlockingModelClient implements AgentModelClient { private final String outputText; private final CountDownLatch started = new CountDownLatch(1); private final CountDownLatch release = new CountDownLatch(1); private BlockingModelClient(String outputText) { this.outputText = outputText; } @Override public AgentModelResult create(AgentPrompt prompt) throws Exception { started.countDown(); release.await(3, TimeUnit.SECONDS); return AgentModelResult.builder() .outputText(outputText) .memoryItems(Collections.emptyList()) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception { AgentModelResult result = create(prompt); if (listener != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private boolean awaitStarted(long timeout, TimeUnit unit) throws InterruptedException { return started.await(timeout, unit); } private void release() { release.countDown(); } } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingSessionCheckpointFormatterTest.java ================================================ package io.github.lnyocly.ai4j.coding; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class CodingSessionCheckpointFormatterTest { @Test public void shouldIgnoreLeadingProseBeforeMarkdownSections() { String summary = "Please let me know how you would like to proceed!\n" + "\n" + "## Goal\n" + "Fix auto compact.\n" + "## Constraints & Preferences\n" + "- Preserve session state.\n" + "## Progress\n" + "### Done\n" + "- [x] Reproduced the issue.\n" + "### In Progress\n" + "- [ ] Patch the parser.\n" + "### Blocked\n" + "- (none)\n"; CodingSessionCheckpoint checkpoint = CodingSessionCheckpointFormatter.parse(summary); assertEquals("Fix auto compact.", checkpoint.getGoal()); assertTrue(checkpoint.getConstraints().contains("Preserve session state.")); assertTrue(checkpoint.getDoneItems().contains("Reproduced the issue.")); assertTrue(checkpoint.getInProgressItems().contains("Patch the parser.")); } @Test public void shouldParseStructuredJsonCheckpoint() { String summary = "{\n" + " \"goal\": \"Resume the coding task.\",\n" + " \"constraints\": [\"Keep exact file paths.\"],\n" + " \"progress\": {\n" + " \"done\": [\"Updated CodingSession.java.\"],\n" + " \"inProgress\": [\"Finish CodingSessionCompactor.java.\"],\n" + " \"blocked\": [\"Need compile validation.\"]\n" + " },\n" + " \"keyDecisions\": [\"Use CodingSessionCheckpoint as the canonical state.\"],\n" + " \"nextSteps\": [\"Run mvn -pl ai4j-coding -am -DskipTests compile.\"],\n" + " \"criticalContext\": [\"Markdown parsing remains compatibility-only.\"]\n" + "}"; CodingSessionCheckpoint checkpoint = CodingSessionCheckpointFormatter.parse(summary); assertEquals("Resume the coding task.", checkpoint.getGoal()); assertTrue(checkpoint.getConstraints().contains("Keep exact file paths.")); assertTrue(checkpoint.getDoneItems().contains("Updated CodingSession.java.")); assertTrue(checkpoint.getInProgressItems().contains("Finish CodingSessionCompactor.java.")); assertTrue(checkpoint.getBlockedItems().contains("Need compile validation.")); assertTrue(checkpoint.getKeyDecisions().contains("Use CodingSessionCheckpoint as the canonical state.")); assertTrue(checkpoint.getNextSteps().contains("Run mvn -pl ai4j-coding -am -DskipTests compile.")); assertTrue(checkpoint.getCriticalContext().contains("Markdown parsing remains compatibility-only.")); } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingSessionTest.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk; import io.github.lnyocly.ai4j.coding.process.BashProcessStatus; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.Collections; import java.util.Deque; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class CodingSessionTest { private static final String STUB_TOOL = "stub_tool"; @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldSnapshotAndCompactSessionMemory() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session workspace") .build(); CodingAgent agent = CodingAgents.builder() .modelClient(new CompactionAwareModelClient()) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSession session = agent.newSession(); try { session.run("Inspect the repository layout."); session.run("Now summarize the current progress."); CodingSessionSnapshot before = session.snapshot(); CodingSessionCompactResult compactResult = session.compact(); CodingSessionSnapshot after = session.snapshot(); assertTrue(before.getMemoryItemCount() >= 2); assertNotNull(compactResult.getSummary()); assertNotNull(compactResult.getCheckpoint()); assertTrue(compactResult.getSummary().contains("## Goal")); assertEquals(before.getMemoryItemCount(), compactResult.getBeforeItemCount()); assertEquals(after.getMemoryItemCount(), compactResult.getAfterItemCount()); assertTrue(after.getMemoryItemCount() <= before.getMemoryItemCount()); assertTrue(compactResult.getEstimatedTokensBefore() > 0); assertEquals(workspaceRoot.toString(), after.getWorkspaceRoot()); assertEquals("Continue the coding task.", after.getCheckpointGoal()); assertEquals("manual", after.getLastCompactMode()); assertTrue(compactResult.getStrategy().contains("checkpoint")); assertEquals(compactResult.getEstimatedTokensBefore(), after.getLastCompactTokensBefore()); assertEquals(compactResult.getEstimatedTokensAfter(), after.getLastCompactTokensAfter()); } finally { session.close(); } } @Test public void shouldExportAndRestoreSessionState() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-restore").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session restore workspace") .build(); CodingAgent agent = CodingAgents.builder() .modelClient(new CompactionAwareModelClient()) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSession first = agent.newSession("resume-session", null); CodingSessionState state; try { first.run("Inspect the repository layout."); first.run("Now summarize the current progress."); state = first.exportState(); } finally { first.close(); } CodingSession resumed = agent.newSession(state); try { CodingSessionSnapshot snapshot = resumed.snapshot(); assertEquals("resume-session", resumed.getSessionId()); assertEquals(workspaceRoot.toString(), snapshot.getWorkspaceRoot()); assertTrue(snapshot.getMemoryItemCount() >= 2); resumed.run("Continue from previous context."); assertTrue(resumed.snapshot().getMemoryItemCount() > snapshot.getMemoryItemCount()); } finally { resumed.close(); } } @Test public void shouldRestoreProcessMetadataAsReadOnlySnapshots() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-process-restore").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session process restore workspace") .build(); CodingAgent agent = CodingAgents.builder() .modelClient(new CompactionAwareModelClient()) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSessionState seededState = CodingSessionState.builder() .sessionId("process-seeded") .workspaceRoot(workspaceRoot.toString()) .memorySnapshot(firstMemorySnapshot()) .processCount(1) .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder() .processId("proc_demo") .command("echo ready && sleep 10") .workingDirectory(workspaceRoot.toString()) .status(BashProcessStatus.STOPPED) .startedAt(System.currentTimeMillis()) .endedAt(System.currentTimeMillis()) .lastLogOffset(42L) .lastLogPreview("[stdout] ready") .restored(false) .controlAvailable(true) .build())) .build(); try (CodingSession resumed = agent.newSession(seededState)) { CodingSessionSnapshot snapshot = resumed.snapshot(); assertEquals(1, snapshot.getProcessCount()); assertEquals(0, snapshot.getActiveProcessCount()); assertEquals(1, snapshot.getRestoredProcessCount()); assertTrue(snapshot.getProcesses().get(0).isRestored()); assertFalse(snapshot.getProcesses().get(0).isControlAvailable()); } } @Test public void shouldReadPreviewLogsFromRestoredProcessSnapshots() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-process-logs").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session restored process logs workspace") .build(); CodingAgent agent = CodingAgents.builder() .modelClient(new CompactionAwareModelClient()) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSessionState seededState = CodingSessionState.builder() .sessionId("process-log-seeded") .workspaceRoot(workspaceRoot.toString()) .memorySnapshot(firstMemorySnapshot()) .processCount(1) .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder() .processId("proc_demo") .command("npm run dev") .workingDirectory(workspaceRoot.toString()) .status(BashProcessStatus.STOPPED) .startedAt(System.currentTimeMillis()) .endedAt(System.currentTimeMillis()) .lastLogOffset(30L) .lastLogPreview("[stdout] server ready\n") .restored(false) .controlAvailable(true) .build())) .build(); try (CodingSession resumed = agent.newSession(seededState)) { BashProcessLogChunk logs = resumed.processLogs("proc_demo", null, 200); assertNotNull(logs); assertEquals("proc_demo", logs.getProcessId()); assertTrue(logs.getContent().contains("server ready")); assertEquals(BashProcessStatus.STOPPED, logs.getStatus()); } } @Test public void shouldAutoCompactWhenTokenBudgetExceeded() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-auto").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session auto compact workspace") .build(); CodingAgent agent = CodingAgents.builder() .modelClient(new CompactionAwareModelClient()) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(CodingAgentOptions.builder() .autoCompactEnabled(true) .compactContextWindowTokens(120) .compactReserveTokens(20) .compactKeepRecentTokens(40) .compactSummaryMaxOutputTokens(120) .build()) .build(); CodingSession session = agent.newSession(); try { session.run("Please write a very long analysis about the workspace state and repeat details many times."); CodingSessionCompactResult autoResult = session.drainLastAutoCompactResult(); CodingSessionSnapshot snapshot = session.snapshot(); assertNotNull(autoResult); assertTrue(autoResult.isAutomatic()); assertTrue(autoResult.getSummary().contains("## Goal")); assertTrue(snapshot.getEstimatedContextTokens() <= autoResult.getEstimatedTokensBefore()); assertNull(session.drainLastAutoCompactError()); assertEquals("auto", snapshot.getLastCompactMode()); assertTrue(autoResult.getStrategy().contains("checkpoint")); assertEquals("Continue the coding task.", snapshot.getCheckpointGoal()); } finally { session.close(); } } @Test public void shouldMicroCompactOversizedToolResultsBeforeFullCheckpointCompaction() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-micro").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session micro compact workspace") .build(); QueueModelClient modelClient = new QueueModelClient(); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-1")); modelClient.enqueue(assistantResult("Tool work finished for this step.")); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .toolRegistry(singleToolRegistry(STUB_TOOL)) .toolExecutor(largeToolExecutor()) .codingOptions(CodingAgentOptions.builder() .autoCompactEnabled(true) .compactContextWindowTokens(700) .compactReserveTokens(120) .compactKeepRecentTokens(200) .toolResultMicroCompactEnabled(true) .toolResultMicroCompactKeepRecent(0) .toolResultMicroCompactMaxTokens(120) .build()) .build(); try (CodingSession session = agent.newSession()) { session.run("Run the stub tool and continue."); CodingSessionCompactResult autoResult = session.drainLastAutoCompactResult(); MemorySnapshot memorySnapshot = session.exportState().getMemorySnapshot(); assertNotNull(autoResult); assertEquals("tool-result-micro", autoResult.getStrategy()); assertTrue(autoResult.getCompactedToolResultCount() > 0); assertTrue(autoResult.getEstimatedTokensAfter() < autoResult.getEstimatedTokensBefore()); assertTrue(String.valueOf(memorySnapshot.getItems()).contains("tool result compacted to save context")); assertEquals(0, modelClient.getSummaryPromptCount()); } } @Test public void shouldOpenAutoCompactCircuitBreakerAfterRepeatedFailures() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-breaker").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session auto compact breaker workspace") .build(); FailingCompactionModelClient modelClient = new FailingCompactionModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(CodingAgentOptions.builder() .autoCompactEnabled(true) .compactContextWindowTokens(160) .compactReserveTokens(40) .compactKeepRecentTokens(40) .compactSummaryMaxOutputTokens(80) .autoCompactMaxConsecutiveFailures(3) .build()) .build(); try (CodingSession session = agent.newSession()) { session.run("First prompt."); assertNotNull(session.drainLastAutoCompactError()); session.run("Second prompt."); assertNotNull(session.drainLastAutoCompactError()); session.run("Third prompt."); Exception thirdError = session.drainLastAutoCompactError(); CodingSessionSnapshot afterFailures = session.snapshot(); assertNotNull(thirdError); assertTrue(thirdError.getMessage().contains("circuit breaker opened")); assertEquals(3, afterFailures.getAutoCompactFailureCount()); assertTrue(afterFailures.isAutoCompactCircuitBreakerOpen()); assertEquals(3, modelClient.getSummaryPromptCount()); session.run("Fourth prompt."); Exception pausedError = session.drainLastAutoCompactError(); assertNotNull(pausedError); assertTrue(pausedError.getMessage().contains("paused after 3 consecutive failures")); assertEquals(3, modelClient.getSummaryPromptCount()); } } @Test public void shouldRetryCompactSummaryAfterPromptTooLong() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-ptl-retry").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session prompt-too-long retry workspace") .build(); PromptTooLongOnceCompactionModelClient modelClient = new PromptTooLongOnceCompactionModelClient(3200); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); try (CodingSession session = agent.newSession()) { session.run("Turn one."); session.run("Turn two."); session.run("Turn three."); CodingSessionCompactResult compactResult = session.compact(); assertNotNull(compactResult); assertEquals(2, modelClient.getSummaryPromptCount()); assertEquals(1, modelClient.getPromptTooLongFailures()); assertTrue(compactResult.getSummary().contains("Compaction retry")); assertFalse(compactResult.getSummary().contains("Compaction fallback")); } } @Test public void shouldFallbackWhenPromptTooLongRetriesAreExhausted() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-ptl-fallback").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session prompt-too-long fallback workspace") .build(); AlwaysPromptTooLongCompactionModelClient modelClient = new AlwaysPromptTooLongCompactionModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); try (CodingSession session = agent.newSession()) { session.run("Turn one."); session.run("Turn two."); session.run("Turn three."); CodingSessionCompactResult compactResult = session.compact(); assertNotNull(compactResult); assertEquals(4, modelClient.getSummaryPromptCount()); assertTrue(compactResult.isFallbackSummary()); assertTrue(compactResult.getSummary().contains("Compaction fallback")); assertTrue(compactResult.getSummary().contains("Compaction retry")); } } @Test public void shouldUseSessionMemoryFallbackWhenCheckpointAlreadyExists() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-memory-fallback").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session memory fallback workspace") .build(); FirstSummaryThenFailingCompactionModelClient modelClient = new FirstSummaryThenFailingCompactionModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); try (CodingSession session = agent.newSession()) { session.run("Create the initial checkpoint."); CodingSessionCompactResult first = session.compact(); session.run("Follow-up change after the checkpoint already exists."); CodingSessionCompactResult second = session.compact(); assertNotNull(first); assertNotNull(second); assertFalse(first.isFallbackSummary()); assertTrue(second.isFallbackSummary()); assertEquals("checkpoint-delta", second.getStrategy()); assertTrue(second.isCheckpointReused()); assertTrue(second.getSummary().contains("Session-memory fallback")); assertTrue(second.getSummary().contains("Previous compacted context retained.")); assertTrue(second.getSummary().contains("Latest user delta: Follow-up change after the checkpoint already exists.")); } } @Test public void shouldReuseExistingCheckpointForAggressiveFallback() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-aggressive-memory-fallback").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session aggressive memory fallback workspace") .build(); FirstSummaryThenAggressiveFailingCompactionModelClient modelClient = new FirstSummaryThenAggressiveFailingCompactionModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(CodingAgentOptions.builder() .autoCompactEnabled(false) .compactContextWindowTokens(260) .compactReserveTokens(80) .compactKeepRecentTokens(800) .compactSummaryMaxOutputTokens(120) .build()) .build(); try (CodingSession session = agent.newSession()) { session.run("Create the initial checkpoint."); CodingSessionCompactResult first = session.compact(); session.run("Make a very large follow-up change that should trigger aggressive compaction."); CodingSessionCompactResult second = session.compact(); assertNotNull(first); assertNotNull(second); assertTrue(second.isFallbackSummary()); assertEquals("aggressive-checkpoint-delta", second.getStrategy()); assertTrue(second.isCheckpointReused()); assertTrue(second.getSummary().contains("Session-memory fallback")); assertTrue(second.getSummary().contains("Created the initial checkpoint.")); assertTrue(second.getSummary().contains("Latest user delta: Make a very large follow-up change that should trigger aggressive compaction.")); assertEquals(3, modelClient.getSummaryPromptCount()); } } @Test public void shouldExposeCheckpointDeltaStrategyWhenReusingPreviousCheckpoint() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-delta").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session delta compact workspace") .build(); CompactionAwareModelClient modelClient = new CompactionAwareModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); try (CodingSession session = agent.newSession()) { session.run("Initial workspace analysis."); CodingSessionCompactResult first = session.compact(); session.run("Apply another change after the first compact."); CodingSessionCompactResult second = session.compact(); assertNotNull(first); assertNotNull(second); assertEquals("checkpoint", first.getStrategy()); assertEquals("checkpoint-delta", second.getStrategy()); assertFalse(first.isCheckpointReused()); assertTrue(second.isCheckpointReused()); assertTrue(second.getDeltaItemCount() > 0); } } @Test public void shouldPreserveLatestCompactMetadataAcrossExportAndRestore() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-compact-restore").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session compact restore workspace") .build(); CompactionAwareModelClient modelClient = new CompactionAwareModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); CodingSessionState exportedState; CodingSessionCompactResult compactResult; try (CodingSession session = agent.newSession()) { session.run("Inspect the repository layout."); session.run("Summarize the current progress."); compactResult = session.compact(); exportedState = session.exportState(); } assertNotNull(exportedState); assertNotNull(exportedState.getLatestCompactResult()); assertEquals(compactResult.getStrategy(), exportedState.getLatestCompactResult().getStrategy()); try (CodingSession resumed = agent.newSession(exportedState)) { CodingSessionSnapshot snapshot = resumed.snapshot(); assertEquals("manual", snapshot.getLastCompactMode()); assertEquals(compactResult.getStrategy(), snapshot.getLastCompactStrategy()); assertEquals(compactResult.getEstimatedTokensBefore(), snapshot.getLastCompactTokensBefore()); assertEquals(compactResult.getEstimatedTokensAfter(), snapshot.getLastCompactTokensAfter()); assertTrue(snapshot.getLastCompactSummary().contains("## Goal")); } } @Test public void shouldPreserveAutoCompactBreakerAcrossExportAndRestore() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-breaker-restore").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session breaker restore workspace") .build(); FailingCompactionModelClient modelClient = new FailingCompactionModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(CodingAgentOptions.builder() .autoCompactEnabled(true) .compactContextWindowTokens(160) .compactReserveTokens(40) .compactKeepRecentTokens(40) .compactSummaryMaxOutputTokens(80) .autoCompactMaxConsecutiveFailures(3) .build()) .build(); CodingSessionState exportedState; try (CodingSession session = agent.newSession()) { session.run("First prompt."); assertNotNull(session.drainLastAutoCompactError()); session.run("Second prompt."); assertNotNull(session.drainLastAutoCompactError()); session.run("Third prompt."); assertNotNull(session.drainLastAutoCompactError()); exportedState = session.exportState(); assertEquals(3, exportedState.getAutoCompactFailureCount()); assertTrue(exportedState.isAutoCompactCircuitBreakerOpen()); } try (CodingSession resumed = agent.newSession(exportedState)) { CodingSessionSnapshot snapshot = resumed.snapshot(); assertEquals(3, snapshot.getAutoCompactFailureCount()); assertTrue(snapshot.isAutoCompactCircuitBreakerOpen()); resumed.run("Fourth prompt."); Exception pausedError = resumed.drainLastAutoCompactError(); assertNotNull(pausedError); assertTrue(pausedError.getMessage().contains("paused after 3 consecutive failures")); assertEquals(3, modelClient.getSummaryPromptCount()); } } @Test public void shouldClearPendingLoopArtifactsAfterManualCompact() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-session-manual-compact-cleanup").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit coding session manual compact cleanup workspace") .build(); CodingAgent agent = CodingAgents.builder() .modelClient(new CompactionAwareModelClient()) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .build(); try (CodingSession session = agent.newSession()) { session.run("Inspect the repository layout."); session.recordLoopDecision(io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision.builder() .turnNumber(1) .continueLoop(false) .summary("stale loop state") .build()); session.compact(); assertTrue(session.drainLoopDecisions().isEmpty()); assertTrue(session.drainAutoCompactResults().isEmpty()); assertTrue(session.drainAutoCompactErrors().isEmpty()); } } private static final class CompactionAwareModelClient implements AgentModelClient { @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { return AgentModelResult.builder() .outputText("{\n" + " \"goal\": \"Continue the coding task.\",\n" + " \"constraints\": [\"Preserve exact file paths.\"],\n" + " \"progress\": {\n" + " \"done\": [\"Compacted old messages.\"],\n" + " \"inProgress\": [\"Continue with the latest kept context.\"],\n" + " \"blocked\": []\n" + " },\n" + " \"keyDecisions\": [\"**Compaction**: Summarized older messages into a checkpoint.\"],\n" + " \"nextSteps\": [\"Continue the coding task from the latest workspace state.\"],\n" + " \"criticalContext\": [\"Keep using the same workspace and session.\"]\n" + "}") .build(); } String text = findLastUserText(prompt); if (text.contains("very long analysis")) { String output = repeat("long-output-", 80); return AgentModelResult.builder() .outputText(output) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", output))) .build(); } return AgentModelResult.builder() .outputText("echo") .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", "echo"))) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null || prompt.getItems().isEmpty()) { return ""; } Object last = prompt.getItems().get(prompt.getItems().size() - 1); return last == null ? "" : String.valueOf(last); } private String repeat(String text, int times) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < times; i++) { builder.append(text); } return builder.toString(); } } private MemorySnapshot firstMemorySnapshot() { return MemorySnapshot.from(Collections.singletonList(AgentInputItem.userMessage("hello")), null); } private AgentToolRegistry singleToolRegistry(String toolName) { Tool.Function function = new Tool.Function(); function.setName(toolName); function.setDescription("Stub tool for testing"); return new StaticToolRegistry(Collections.singletonList(new Tool("function", function))); } private ToolExecutor largeToolExecutor() { return new ToolExecutor() { @Override public String execute(AgentToolCall call) { return repeat("tool-output-", 260); } }; } private AgentModelResult toolCallResult(String toolName, String callId) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .name(toolName) .arguments("{}") .callId(callId) .type("function") .build())) .memoryItems(Collections.emptyList()) .build(); } private AgentModelResult assistantResult(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", text))) .build(); } private String repeat(String text, int times) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < times; i++) { builder.append(text); } return builder.toString(); } private String findLastUserText(AgentPrompt prompt) { if (prompt == null || prompt.getItems() == null || prompt.getItems().isEmpty()) { return ""; } Object last = prompt.getItems().get(prompt.getItems().size() - 1); return last == null ? "" : String.valueOf(last); } private static final class QueueModelClient implements AgentModelClient { private final Deque results = new ArrayDeque(); private int summaryPromptCount; private void enqueue(AgentModelResult result) { results.addLast(result); } private int getSummaryPromptCount() { return summaryPromptCount; } @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { summaryPromptCount += 1; } AgentModelResult result = results.removeFirst(); return result == null ? AgentModelResult.builder().build() : result; } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } } private final class FailingCompactionModelClient implements AgentModelClient { private int summaryPromptCount; @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { summaryPromptCount += 1; throw new IllegalStateException("summary model unavailable"); } String output = repeat("long-output-", 40); return AgentModelResult.builder() .outputText(output) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", output))) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private int getSummaryPromptCount() { return summaryPromptCount; } } private final class PromptTooLongOnceCompactionModelClient implements AgentModelClient { private final int promptTooLongThreshold; private int summaryPromptCount; private int promptTooLongFailures; private PromptTooLongOnceCompactionModelClient(int promptTooLongThreshold) { this.promptTooLongThreshold = promptTooLongThreshold; } @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { summaryPromptCount += 1; String text = findLastUserText(prompt); if (promptTooLongFailures == 0 && text.length() >= promptTooLongThreshold) { promptTooLongFailures += 1; throw new IllegalStateException("Prompt too long for compact summary."); } return AgentModelResult.builder() .outputText("{\n" + " \"goal\": \"Continue the coding task.\",\n" + " \"constraints\": [\"Retry summary when needed.\"],\n" + " \"progress\": {\n" + " \"done\": [\"Recovered compaction after a prompt-too-long retry.\"],\n" + " \"inProgress\": [\"Continue from compacted context.\"],\n" + " \"blocked\": []\n" + " },\n" + " \"keyDecisions\": [\"Use retry slicing when the summary prompt exceeds context.\"],\n" + " \"nextSteps\": [\"Continue from the compacted checkpoint.\"],\n" + " \"criticalContext\": [\"Retry path succeeded.\"]\n" + "}") .build(); } String output = repeat("session-output-", 90); return AgentModelResult.builder() .outputText(output) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", output))) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private int getSummaryPromptCount() { return summaryPromptCount; } private int getPromptTooLongFailures() { return promptTooLongFailures; } } private final class AlwaysPromptTooLongCompactionModelClient implements AgentModelClient { private int summaryPromptCount; @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { summaryPromptCount += 1; throw new IllegalStateException("Maximum context length exceeded for compact summary."); } String output = repeat("session-output-", 90); return AgentModelResult.builder() .outputText(output) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", output))) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private int getSummaryPromptCount() { return summaryPromptCount; } } private final class FirstSummaryThenFailingCompactionModelClient implements AgentModelClient { private int summaryPromptCount; @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { summaryPromptCount += 1; if (summaryPromptCount == 1) { return AgentModelResult.builder() .outputText("{\n" + " \"goal\": \"Continue the coding task.\",\n" + " \"constraints\": [\"Preserve exact file paths.\"],\n" + " \"progress\": {\n" + " \"done\": [\"Created the initial checkpoint.\"],\n" + " \"inProgress\": [\"Wait for the next delta.\"],\n" + " \"blocked\": []\n" + " },\n" + " \"keyDecisions\": [\"Use checkpoint + delta updates.\"],\n" + " \"nextSteps\": [\"Continue from the next user change.\"],\n" + " \"criticalContext\": [\"Checkpoint already exists before the next compact.\"]\n" + "}") .build(); } throw new IllegalStateException("summary model unavailable"); } return AgentModelResult.builder() .outputText("echo") .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", "echo"))) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } } private final class FirstSummaryThenAggressiveFailingCompactionModelClient implements AgentModelClient { private int summaryPromptCount; @Override public AgentModelResult create(AgentPrompt prompt) { if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { summaryPromptCount += 1; if (summaryPromptCount == 1 || summaryPromptCount == 2) { return AgentModelResult.builder() .outputText("{\n" + " \"goal\": \"Continue the coding task.\",\n" + " \"constraints\": [\"Preserve exact file paths.\"],\n" + " \"progress\": {\n" + " \"done\": [\"Created the initial checkpoint.\"],\n" + " \"inProgress\": [\"Handle the recent large delta.\"],\n" + " \"blocked\": []\n" + " },\n" + " \"keyDecisions\": [\"Use checkpoint + delta updates.\"],\n" + " \"nextSteps\": [\"Continue from the next user change.\"],\n" + " \"criticalContext\": [\"Checkpoint already exists before aggressive compact.\"]\n" + "}") .build(); } throw new IllegalStateException("summary model unavailable"); } String text = findLastUserText(prompt); if (text.contains("very large follow-up change")) { String output = repeat("large-follow-up-output-", 240); return AgentModelResult.builder() .outputText(output) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", output))) .build(); } return AgentModelResult.builder() .outputText("echo") .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", "echo"))) .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } private int getSummaryPromptCount() { return summaryPromptCount; } } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingSkillSupportTest.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor; import io.github.lnyocly.ai4j.coding.skill.CodingSkillDiscovery; import io.github.lnyocly.ai4j.coding.workspace.LocalWorkspaceFileService; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileReadResult; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class CodingSkillSupportTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldDiscoverSkillsAndInjectAvailableSkillsPrompt() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-skills").toPath(); Path workspaceSkillFile = writeSkill( workspaceRoot.resolve(".ai4j").resolve("skills").resolve("reviewer").resolve("SKILL.md"), "---\nname: reviewer\ndescription: Review code changes for risks.\n---\n" ); Path fakeHome = temporaryFolder.newFolder("fake-home").toPath(); Path globalSkillFile = writeSkill( fakeHome.resolve(".ai4j").resolve("skills").resolve("refactorer").resolve("SKILL.md"), "# refactorer\nSimplify recently modified code without changing behavior.\n" ); String originalUserHome = System.getProperty("user.home"); System.setProperty("user.home", fakeHome.toString()); try { WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("Skill-aware workspace") .build(); WorkspaceContext enriched = CodingSkillDiscovery.enrich(workspaceContext); List skills = enriched.getAvailableSkills(); assertEquals(2, skills.size()); assertEquals("reviewer", skills.get(0).getName()); assertEquals("workspace", skills.get(0).getSource()); assertEquals(workspaceSkillFile.toAbsolutePath().normalize().toString(), skills.get(0).getSkillFilePath()); assertEquals("refactorer", skills.get(1).getName()); assertEquals("global", skills.get(1).getSource()); assertEquals(globalSkillFile.toAbsolutePath().normalize().toString(), skills.get(1).getSkillFilePath()); assertTrue(enriched.getAllowedReadRoots().contains(workspaceSkillFile.getParent().getParent().toString())); assertTrue(enriched.getAllowedReadRoots().contains(globalSkillFile.getParent().getParent().toString())); CapturingModelClient modelClient = new CapturingModelClient(); CodingAgent agent = CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .systemPrompt("Base prompt.") .build(); CodingSession session = agent.newSession(); try { session.run("Use the most relevant skill."); } finally { session.close(); } AgentPrompt prompt = modelClient.getLastPrompt(); assertNotNull(prompt); assertTrue(prompt.getSystemPrompt().contains("")); assertTrue(prompt.getSystemPrompt().contains("name: reviewer")); assertTrue(prompt.getSystemPrompt().contains(workspaceSkillFile.toAbsolutePath().normalize().toString())); assertTrue(prompt.getSystemPrompt().contains("name: refactorer")); assertTrue(prompt.getSystemPrompt().contains(globalSkillFile.toAbsolutePath().normalize().toString())); } finally { restoreProperty("user.home", originalUserHome); } } @Test public void shouldAllowReadOnlySkillFilesOutsideWorkspace() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-read-skill").toPath(); Path fakeHome = temporaryFolder.newFolder("fake-home-read").toPath(); Path globalSkillFile = writeSkill( fakeHome.resolve(".ai4j").resolve("skills").resolve("planner").resolve("SKILL.md"), "---\nname: planner\ndescription: Plan implementation steps.\n---\n" ); String originalUserHome = System.getProperty("user.home"); System.setProperty("user.home", fakeHome.toString()); try { WorkspaceContext workspaceContext = CodingSkillDiscovery.enrich(WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .build()); LocalWorkspaceFileService fileService = new LocalWorkspaceFileService(workspaceContext); WorkspaceFileReadResult result = fileService.readFile(globalSkillFile.toString(), 1, 2, 4000); assertTrue(result.getContent().contains("planner")); assertEquals(globalSkillFile.toAbsolutePath().normalize().toString().replace('\\', '/'), result.getPath()); try { fileService.writeFile(globalSkillFile.toString(), "mutated", false); fail("writeFile should not allow writes outside the workspace root"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("escapes workspace root")); } } finally { restoreProperty("user.home", originalUserHome); } } private static Path writeSkill(Path skillFile, String content) throws Exception { Files.createDirectories(skillFile.getParent()); Files.write(skillFile, content.getBytes(StandardCharsets.UTF_8)); return skillFile; } private static void restoreProperty(String key, String value) { if (value == null) { System.clearProperty(key); return; } System.setProperty(key, value); } private static final class CapturingModelClient implements AgentModelClient { private AgentPrompt lastPrompt; @Override public AgentModelResult create(AgentPrompt prompt) { lastPrompt = prompt; return AgentModelResult.builder() .outputText("ok") .rawResponse("ok") .build(); } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { return create(prompt); } public AgentPrompt getLastPrompt() { return lastPrompt; } } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/LocalShellCommandExecutorTest.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.coding.shell.LocalShellCommandExecutor; import io.github.lnyocly.ai4j.coding.shell.ShellCommandRequest; import io.github.lnyocly.ai4j.coding.shell.ShellCommandResult; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class LocalShellCommandExecutorTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldRunCommandInsideWorkspace() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-shell").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .build(); LocalShellCommandExecutor executor = new LocalShellCommandExecutor(workspaceContext, 10000L); ShellCommandResult result = executor.execute(ShellCommandRequest.builder() .command("echo hello-ai4j") .build()); assertFalse(result.isTimedOut()); assertEquals(0, result.getExitCode()); assertTrue(result.getStdout().toLowerCase().contains("hello-ai4j")); } @Test public void shouldExplainHowToHandleTimedOutInteractiveOrLongRunningCommands() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-shell-timeout").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .build(); LocalShellCommandExecutor executor = new LocalShellCommandExecutor(workspaceContext, 200L); ShellCommandResult result = executor.execute(ShellCommandRequest.builder() .command(timeoutCommand()) .build()); assertTrue(result.isTimedOut()); assertEquals(-1, result.getExitCode()); assertTrue(result.getStderr().contains("bash action=start")); } private String timeoutCommand() { String osName = System.getProperty("os.name", "").toLowerCase(); if (osName.contains("win")) { return "ping 127.0.0.1 -n 5 > nul"; } return "sleep 5"; } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/MinimaxCodingAgentTeamWorkspaceUsageTest.java ================================================ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.Agent; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; import io.github.lnyocly.ai4j.agent.Agents; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.agent.team.AgentTeam; import io.github.lnyocly.ai4j.agent.team.AgentTeamMember; import io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamOptions; import io.github.lnyocly.ai4j.agent.team.AgentTeamPlan; import io.github.lnyocly.ai4j.agent.team.AgentTeamResult; import io.github.lnyocly.ai4j.agent.team.AgentTeamTask; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry; import io.github.lnyocly.ai4j.coding.prompt.CodingContextPromptAssembler; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.config.MinimaxConfig; import io.github.lnyocly.ai4j.interceptor.ErrorInterceptor; import io.github.lnyocly.ai4j.service.Configuration; import io.github.lnyocly.ai4j.service.PlatformType; import io.github.lnyocly.ai4j.service.factory.AiService; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; public class MinimaxCodingAgentTeamWorkspaceUsageTest { private static final String DEFAULT_MODEL = "MiniMax-M2.7"; @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); private ChatModelClient modelClient; private String model; @Before public void setupMinimaxClient() { String apiKey = readValue("MINIMAX_API_KEY", "minimax.api.key"); Assume.assumeTrue("Skip because MiniMax API key is not configured", !isBlank(apiKey)); model = readValue("MINIMAX_MODEL", "minimax.model"); if (isBlank(model)) { model = DEFAULT_MODEL; } Configuration configuration = new Configuration(); MinimaxConfig minimaxConfig = new MinimaxConfig(); minimaxConfig.setApiKey(apiKey); configuration.setMinimaxConfig(minimaxConfig); configuration.setOkHttpClient(createHttpClient()); AiService aiService = new AiService(configuration); modelClient = new ChatModelClient(aiService.getChatService(PlatformType.MINIMAX)); } @Test public void test_travel_demo_team_delivers_workspace_artifacts() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("minimax-travel-team-workspace").toPath(); seedWorkspace(workspaceRoot); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("Temporary travel demo workspace for MiniMax team delivery verification") .build(); CodingAgentOptions codingOptions = CodingAgentOptions.builder() .autoCompactEnabled(false) .build(); AgentTeam team = Agents.team() .planner((objective, members, options) -> AgentTeamPlan.builder() .rawPlanText("travel-workspace-delivery-plan") .tasks(Arrays.asList( AgentTeamTask.builder() .id("product") .memberId("product") .task("Fill docs/product/prd.md with a concise travel app PRD. Replace TODO_PRODUCT only.") .context("Read README.md first. Keep the product scope MVP-level and implementation-ready.") .build(), AgentTeamTask.builder() .id("architecture") .memberId("architect") .task("Fill docs/architecture/system-design.md with the architecture and file ownership plan. Replace TODO_ARCHITECTURE only.") .context("Read README.md and docs/product/prd.md first. Tell backend/frontend exactly which files they own.") .dependsOn(Arrays.asList("product")) .build(), AgentTeamTask.builder() .id("backend") .memberId("backend") .task("Replace TODO_BACKEND_OPENAPI in backend/openapi.yaml and TODO_BACKEND_DATA in backend/mock-destinations.json.") .context("Read the product and architecture docs first. Produce a small but valid travel API contract and destination dataset.") .dependsOn(Arrays.asList("product", "architecture")) .build(), AgentTeamTask.builder() .id("frontend") .memberId("frontend") .task("Replace TODO_FRONTEND_HTML in frontend/index.html, TODO_FRONTEND_CSS in frontend/styles.css, and TODO_FRONTEND_JS in frontend/app.js.") .context("Read the product doc, architecture doc, and backend files first. Produce a minimal static travel planner demo page that uses the mock destination data shape.") .dependsOn(Arrays.asList("product", "architecture", "backend")) .build(), AgentTeamTask.builder() .id("qa") .memberId("qa") .task("Replace TODO_QA in qa/test-plan.md with a practical QA plan for the generated travel demo workspace.") .context("Read all generated docs and app files first. Cover smoke, API-contract, UI, and regression checks.") .dependsOn(Arrays.asList("product", "architecture", "backend", "frontend")) .build() )) .build()) .synthesizer((objective, plan, memberResults, options) -> AgentResult.builder() .outputText(renderSummary(objective, memberResults)) .build()) .member(member( workspaceContext, codingOptions, "product", "Product Manager", "Owns the product scope and acceptance criteria.", "You are the product manager for a travel planning demo app.\n" + "You must read README.md, then update only docs/product/prd.md.\n" + "Replace TODO_PRODUCT with a compact PRD that covers target users, user stories, MVP scope, non-goals, and acceptance criteria.\n" + "Do not modify any other file." )) .member(member( workspaceContext, codingOptions, "architect", "Architecture Analyst", "Owns architecture and delivery boundaries.", "You are the architecture analyst for a travel planning demo app.\n" + "You must read README.md and docs/product/prd.md, then update only docs/architecture/system-design.md.\n" + "Replace TODO_ARCHITECTURE with the system design, module boundaries, and exact file ownership for backend/frontend.\n" + "Do not modify any other file." )) .member(member( workspaceContext, codingOptions, "backend", "Backend Engineer", "Owns the mock API contract and seed data.", "You are the backend engineer for a travel planning demo app.\n" + "You must read README.md, docs/product/prd.md, and docs/architecture/system-design.md.\n" + "Update only backend/openapi.yaml and backend/mock-destinations.json.\n" + "Replace the TODO markers with a valid OpenAPI skeleton and a small JSON destination dataset for a travel planner." )) .member(member( workspaceContext, codingOptions, "frontend", "Frontend Engineer", "Owns the static demo UI.", "You are the frontend engineer for a travel planning demo app.\n" + "You must read README.md, docs/product/prd.md, docs/architecture/system-design.md, backend/openapi.yaml, and backend/mock-destinations.json.\n" + "Update only frontend/index.html, frontend/styles.css, and frontend/app.js.\n" + "Replace the TODO markers with a minimal static travel planner demo that matches the mock data shape and references app.js/styles.css correctly." )) .member(member( workspaceContext, codingOptions, "qa", "QA Engineer", "Owns verification strategy for the generated workspace.", "You are the QA engineer for a travel planning demo app.\n" + "You must read all generated docs and app files, then update only qa/test-plan.md.\n" + "Replace TODO_QA with a practical test plan covering smoke, API contract checks, UI behavior, and regression scope.\n" + "Do not modify any other file." )) .options(AgentTeamOptions.builder() .parallelDispatch(true) .maxConcurrency(3) .enableMessageBus(true) .includeMessageHistoryInDispatch(true) .enableMemberTeamTools(true) .maxRounds(16) .build()) .build(); AgentTeamResult result = callWithProviderGuard(new ThrowingSupplier() { @Override public AgentTeamResult get() throws Exception { return team.run(AgentRequest.builder() .input("Collaboratively deliver the files for the travel demo workspace without changing files outside the assigned ownership.") .build()); } }); printResult(result, workspaceRoot); Assert.assertNotNull(result); Assert.assertEquals(5, result.getTaskStates().size()); Assert.assertTrue("Team tasks did not all complete: " + describeTaskStates(result.getTaskStates()), allTasksCompleted(result.getTaskStates())); assertFileContains(workspaceRoot.resolve("docs/product/prd.md"), "MVP"); assertFileContains(workspaceRoot.resolve("docs/architecture/system-design.md"), "backend/openapi.yaml"); assertFileContains(workspaceRoot.resolve("backend/openapi.yaml"), "openapi:"); assertFileContains(workspaceRoot.resolve("backend/mock-destinations.json"), "\"destinations\""); assertFileContains(workspaceRoot.resolve("frontend/index.html"), "app.js"); assertFileContainsAny(workspaceRoot.resolve("frontend/styles.css"), ".grid", ".destinations-grid", "grid-template-columns"); assertFileContainsIgnoreCase(workspaceRoot.resolve("frontend/app.js"), "destination"); assertFileContainsIgnoreCase(workspaceRoot.resolve("qa/test-plan.md"), "smoke"); assertTodoRemoved(workspaceRoot.resolve("docs/product/prd.md"), "TODO_PRODUCT"); assertTodoRemoved(workspaceRoot.resolve("docs/architecture/system-design.md"), "TODO_ARCHITECTURE"); assertTodoRemoved(workspaceRoot.resolve("backend/openapi.yaml"), "TODO_BACKEND_OPENAPI"); assertTodoRemoved(workspaceRoot.resolve("backend/mock-destinations.json"), "TODO_BACKEND_DATA"); assertTodoRemoved(workspaceRoot.resolve("frontend/index.html"), "TODO_FRONTEND_HTML"); assertTodoRemoved(workspaceRoot.resolve("frontend/styles.css"), "TODO_FRONTEND_CSS"); assertTodoRemoved(workspaceRoot.resolve("frontend/app.js"), "TODO_FRONTEND_JS"); assertTodoRemoved(workspaceRoot.resolve("qa/test-plan.md"), "TODO_QA"); } private AgentTeamMember member(WorkspaceContext workspaceContext, CodingAgentOptions codingOptions, String id, String name, String description, String systemPrompt) { return AgentTeamMember.builder() .id(id) .name(name) .description(description) .agent(buildWorkspaceCodingAgent(workspaceContext, codingOptions, systemPrompt)) .build(); } private Agent buildWorkspaceCodingAgent(WorkspaceContext workspaceContext, CodingAgentOptions codingOptions, String systemPrompt) { SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, codingOptions); AgentToolRegistry toolRegistry = CodingAgentBuilder.createBuiltInRegistry(codingOptions); ToolExecutor toolExecutor = CodingAgentBuilder.createBuiltInToolExecutor( workspaceContext, codingOptions, processRegistry ); return Agents.react() .modelClient(modelClient) .model(model) .temperature(0.2) .systemPrompt(CodingContextPromptAssembler.mergeSystemPrompt(systemPrompt, workspaceContext)) .toolRegistry(toolRegistry) .toolExecutor(toolExecutor) .options(AgentOptions.builder() .maxSteps(10) .stream(false) .build()) .build(); } private void seedWorkspace(Path workspaceRoot) throws IOException { Files.createDirectories(workspaceRoot.resolve("docs/product")); Files.createDirectories(workspaceRoot.resolve("docs/architecture")); Files.createDirectories(workspaceRoot.resolve("backend")); Files.createDirectories(workspaceRoot.resolve("frontend")); Files.createDirectories(workspaceRoot.resolve("qa")); write(workspaceRoot.resolve("README.md"), "# Travel Demo Workspace\n" + "\n" + "Goal: deliver a small travel planning demo workspace.\n" + "\n" + "Required outputs:\n" + "- docs/product/prd.md\n" + "- docs/architecture/system-design.md\n" + "- backend/openapi.yaml\n" + "- backend/mock-destinations.json\n" + "- frontend/index.html\n" + "- frontend/styles.css\n" + "- frontend/app.js\n" + "- qa/test-plan.md\n" + "\n" + "Rules:\n" + "- Only modify the files you own.\n" + "- Replace TODO markers rather than inventing new paths.\n" + "- Keep the demo small, concrete, and internally consistent.\n"); write(workspaceRoot.resolve("docs/product/prd.md"), "# Travel Demo PRD\n\nTODO_PRODUCT\n"); write(workspaceRoot.resolve("docs/architecture/system-design.md"), "# Travel Demo Architecture\n\nTODO_ARCHITECTURE\n"); write(workspaceRoot.resolve("backend/openapi.yaml"), "TODO_BACKEND_OPENAPI\n"); write(workspaceRoot.resolve("backend/mock-destinations.json"), "{\n \"destinations\": TODO_BACKEND_DATA\n}\n"); write(workspaceRoot.resolve("frontend/index.html"), "\n\n\n \n Travel Demo\n \n\n\nTODO_FRONTEND_HTML\n\n\n\n"); write(workspaceRoot.resolve("frontend/styles.css"), "TODO_FRONTEND_CSS\n"); write(workspaceRoot.resolve("frontend/app.js"), "TODO_FRONTEND_JS\n"); write(workspaceRoot.resolve("qa/test-plan.md"), "# QA Test Plan\n\nTODO_QA\n"); } private void write(Path path, String content) throws IOException { Files.write(path, content.getBytes(StandardCharsets.UTF_8)); } private void assertFileContains(Path path, String expected) throws IOException { Assert.assertTrue("Expected file to exist: " + path, Files.exists(path)); String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); Assert.assertTrue("Expected [" + expected + "] in " + path + " but content was:\n" + content, content.contains(expected)); } private void assertFileContainsAny(Path path, String... expectedValues) throws IOException { Assert.assertTrue("Expected file to exist: " + path, Files.exists(path)); String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); if (expectedValues != null) { for (String expected : expectedValues) { if (expected != null && content.contains(expected)) { return; } } } Assert.fail("Expected one of " + Arrays.toString(expectedValues) + " in " + path + " but content was:\n" + content); } private void assertFileContainsIgnoreCase(Path path, String expected) throws IOException { Assert.assertTrue("Expected file to exist: " + path, Files.exists(path)); String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); Assert.assertTrue("Expected [" + expected + "] in " + path + " but content was:\n" + content, content.toLowerCase().contains(expected.toLowerCase())); } private void assertTodoRemoved(Path path, String todoMarker) throws IOException { String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); Assert.assertFalse("Expected TODO marker to be removed from " + path + ": " + todoMarker, content.contains(todoMarker)); } private String renderSummary(String objective, List memberResults) { Map outputs = new LinkedHashMap(); if (memberResults != null) { for (AgentTeamMemberResult item : memberResults) { if (item == null || isBlank(item.getMemberId())) { continue; } outputs.put(item.getMemberId(), item.isSuccess() ? safe(item.getOutput()) : "FAILED: " + safe(item.getError())); } } StringBuilder builder = new StringBuilder(); builder.append("Travel Workspace Delivery Summary\n"); builder.append("Objective: ").append(safe(objective)).append("\n\n"); appendSection(builder, "Product", outputs.get("product")); appendSection(builder, "Architect", outputs.get("architect")); appendSection(builder, "Backend", outputs.get("backend")); appendSection(builder, "Frontend", outputs.get("frontend")); appendSection(builder, "QA", outputs.get("qa")); return builder.toString().trim(); } private void appendSection(StringBuilder builder, String title, String output) { builder.append('[').append(title).append("]\n"); builder.append(safe(output)).append("\n\n"); } private void printResult(AgentTeamResult result, Path workspaceRoot) throws IOException { System.out.println("==== TASK STATES ===="); if (result != null && result.getTaskStates() != null) { for (AgentTeamTaskState state : result.getTaskStates()) { System.out.println(state.getTaskId() + " => " + state.getStatus() + " by " + state.getClaimedBy()); } } System.out.println("==== GENERATED FILES ===="); Files.walk(workspaceRoot) .filter(Files::isRegularFile) .forEach(path -> System.out.println(workspaceRoot.relativize(path).toString())); System.out.println("==== FINAL OUTPUT ===="); System.out.println(result == null ? "" : result.getOutput()); } private boolean allTasksCompleted(List states) { if (states == null || states.isEmpty()) { return false; } for (AgentTeamTaskState state : states) { if (state == null || state.getStatus() != AgentTeamTaskStatus.COMPLETED) { return false; } } return true; } private String describeTaskStates(List states) { if (states == null || states.isEmpty()) { return "[]"; } StringBuilder sb = new StringBuilder("["); for (int i = 0; i < states.size(); i++) { AgentTeamTaskState state = states.get(i); if (i > 0) { sb.append(", "); } if (state == null) { sb.append("null"); continue; } sb.append(state.getTaskId()).append(":").append(state.getStatus()); if (!isBlank(state.getClaimedBy())) { sb.append("@").append(state.getClaimedBy()); } if (!isBlank(state.getError())) { sb.append("(error=").append(state.getError()).append(")"); } } sb.append("]"); return sb.toString(); } private T callWithProviderGuard(ThrowingSupplier supplier) throws Exception { try { return supplier.get(); } catch (Exception ex) { skipIfProviderUnavailable(ex); throw ex; } } private void skipIfProviderUnavailable(Throwable throwable) { if (isProviderUnavailable(throwable)) { Assume.assumeTrue("Skip due provider limit/unavailable: " + extractRootMessage(throwable), false); } } private boolean isProviderUnavailable(Throwable throwable) { Throwable current = throwable; while (current != null) { String message = current.getMessage(); if (!isBlank(message)) { String lower = message.toLowerCase(); if (lower.contains("timeout") || lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("quota") || lower.contains("tool arguments must be a json object") || message.contains("频次") || message.contains("限流") || message.contains("额度") || message.contains("配额") || message.contains("账户已达到")) { return true; } } current = current.getCause(); } return false; } private String extractRootMessage(Throwable throwable) { Throwable current = throwable; Throwable last = throwable; while (current != null) { last = current; current = current.getCause(); } return last == null || isBlank(last.getMessage()) ? "unknown error" : last.getMessage(); } private OkHttpClient createHttpClient() { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BASIC); return new OkHttpClient.Builder() .addInterceptor(logging) .addInterceptor(new ErrorInterceptor()) .connectTimeout(300, TimeUnit.SECONDS) .writeTimeout(300, TimeUnit.SECONDS) .readTimeout(300, TimeUnit.SECONDS) .build(); } private String readValue(String envKey, String propertyKey) { String value = envKey == null ? null : System.getenv(envKey); if (isBlank(value) && propertyKey != null) { value = System.getProperty(propertyKey); } return value; } private String safe(String value) { return isBlank(value) ? "" : value.trim(); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } @FunctionalInterface private interface ThrowingSupplier { T get() throws Exception; } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/ReadFileToolExecutorTest.java ================================================ package io.github.lnyocly.ai4j.coding; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.tool.ReadFileToolExecutor; import io.github.lnyocly.ai4j.coding.workspace.LocalWorkspaceFileService; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; public class ReadFileToolExecutorTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldReadFileInsideWorkspace() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-read").toPath(); Files.write(workspaceRoot.resolve("demo.txt"), "alpha\nbeta".getBytes(StandardCharsets.UTF_8)); ReadFileToolExecutor executor = new ReadFileToolExecutor( new LocalWorkspaceFileService(WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build()), CodingAgentOptions.builder().build() ); String raw = executor.execute(AgentToolCall.builder() .name(CodingToolNames.READ_FILE) .arguments("{\"path\":\"demo.txt\"}") .build()); JSONObject result = JSON.parseObject(raw); assertEquals("demo.txt", result.getString("path")); assertEquals("alpha\nbeta", result.getString("content")); assertFalse(result.getBooleanValue("truncated")); } @Test(expected = IllegalArgumentException.class) public void shouldRejectPathOutsideWorkspace() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-read-escape").toPath(); ReadFileToolExecutor executor = new ReadFileToolExecutor( new LocalWorkspaceFileService(WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build()), CodingAgentOptions.builder().build() ); executor.execute(AgentToolCall.builder() .name(CodingToolNames.READ_FILE) .arguments("{\"path\":\"../escape.txt\"}") .build()); } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/WriteFileToolExecutorTest.java ================================================ package io.github.lnyocly.ai4j.coding; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.coding.tool.CodingToolNames; import io.github.lnyocly.ai4j.coding.tool.WriteFileToolExecutor; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class WriteFileToolExecutorTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldCreateOverwriteAndAppendFiles() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-write").toPath(); WriteFileToolExecutor executor = new WriteFileToolExecutor( WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build() ); JSONObject created = JSON.parseObject(executor.execute(call("notes/todo.txt", "alpha", "create"))); assertTrue(created.getBooleanValue("created")); assertFalse(created.getBooleanValue("appended")); assertEquals("alpha", new String(Files.readAllBytes(workspaceRoot.resolve("notes/todo.txt")), StandardCharsets.UTF_8)); JSONObject overwritten = JSON.parseObject(executor.execute(call("notes/todo.txt", "beta", "overwrite"))); assertFalse(overwritten.getBooleanValue("created")); assertFalse(overwritten.getBooleanValue("appended")); assertEquals("beta", new String(Files.readAllBytes(workspaceRoot.resolve("notes/todo.txt")), StandardCharsets.UTF_8)); JSONObject appended = JSON.parseObject(executor.execute(call("notes/todo.txt", "\ngamma", "append"))); assertFalse(appended.getBooleanValue("created")); assertTrue(appended.getBooleanValue("appended")); assertEquals("beta\ngamma", new String(Files.readAllBytes(workspaceRoot.resolve("notes/todo.txt")), StandardCharsets.UTF_8)); } @Test public void shouldAllowWritingAbsolutePathOutsideWorkspace() throws Exception { Path workspaceRoot = temporaryFolder.newFolder("workspace-write-outside").toPath(); Path outsideFile = temporaryFolder.newFolder("outside-root").toPath().resolve("outside.txt"); WriteFileToolExecutor executor = new WriteFileToolExecutor( WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build() ); JSONObject result = JSON.parseObject(executor.execute(call(outsideFile.toString(), "outside", "overwrite"))); assertTrue(Files.exists(outsideFile)); assertEquals("outside", new String(Files.readAllBytes(outsideFile), StandardCharsets.UTF_8)); assertEquals(outsideFile.toAbsolutePath().normalize().toString(), result.getString("resolvedPath")); } private AgentToolCall call(String path, String content, String mode) { JSONObject arguments = new JSONObject(); arguments.put("path", path); arguments.put("content", content); arguments.put("mode", mode); return AgentToolCall.builder() .name(CodingToolNames.WRITE_FILE) .arguments(arguments.toJSONString()) .build(); } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopControllerTest.java ================================================ package io.github.lnyocly.ai4j.coding.loop; import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.AgentModelResult; import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.coding.CodingAgent; import io.github.lnyocly.ai4j.coding.CodingAgentOptions; import io.github.lnyocly.ai4j.coding.CodingAgentRequest; import io.github.lnyocly.ai4j.coding.CodingAgentResult; import io.github.lnyocly.ai4j.coding.CodingAgents; import io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint; import io.github.lnyocly.ai4j.coding.CodingSessionCompactResult; import io.github.lnyocly.ai4j.coding.CodingSession; import io.github.lnyocly.ai4j.coding.process.BashProcessStatus; import io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot; import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class CodingAgentLoopControllerTest { private static final String STUB_TOOL = "stub_tool"; @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void shouldContinueWithHiddenInstructionsWithoutAddingExtraUserMessage() throws Exception { InspectableQueueModelClient modelClient = new InspectableQueueModelClient(); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-1")); modelClient.enqueue(assistantResult("Continuing with remaining work.")); modelClient.enqueue(assistantResult("Completed the requested change.")); try (CodingSession session = newAgent(modelClient, okToolExecutor(), defaultOptions()).newSession()) { CodingAgentResult result = session.run(CodingAgentRequest.builder().input("Implement the requested change.").build()); assertEquals(CodingStopReason.COMPLETED, result.getStopReason()); assertEquals(2, result.getTurns()); assertTrue(result.isAutoContinued()); assertEquals(1, result.getAutoFollowUpCount()); assertEquals(1, countUserMessages(session.exportState().getMemorySnapshot())); assertTrue(modelClient.getPrompts().size() >= 3); assertTrue(modelClient.getPrompts().get(2).getInstructions().contains("Internal continuation. This is not a new user message.")); assertTrue(modelClient.getPrompts().get(2).getInstructions().contains("Continuation reason: CONTINUE_AFTER_TOOL_WORK.")); } } @Test public void shouldStopWhenMaxAutoFollowUpsIsReached() throws Exception { InspectableQueueModelClient modelClient = new InspectableQueueModelClient(); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-1")); modelClient.enqueue(assistantResult("Continuing with remaining work.")); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-2")); modelClient.enqueue(assistantResult("Continuing with remaining work.")); CodingAgentOptions options = defaultOptions().toBuilder() .maxAutoFollowUps(1) .build(); try (CodingSession session = newAgent(modelClient, okToolExecutor(), options).newSession()) { CodingAgentResult result = session.run("Keep working until you are done."); List decisions = session.drainLoopDecisions(); assertEquals(CodingStopReason.MAX_AUTO_FOLLOWUPS_REACHED, result.getStopReason()); assertEquals(2, result.getTurns()); assertTrue(result.isAutoContinued()); assertEquals(1, result.getAutoFollowUpCount()); assertEquals(2, result.getToolResults().size()); assertEquals(2, decisions.size()); assertTrue(decisions.get(0).isContinueLoop()); assertEquals(CodingLoopDecision.CONTINUE_AFTER_TOOL_WORK, decisions.get(0).getContinueReason()); assertFalse(decisions.get(1).isContinueLoop()); assertEquals(CodingStopReason.MAX_AUTO_FOLLOWUPS_REACHED, decisions.get(1).getStopReason()); } } @Test public void shouldStopWhenApprovalIsRejected() throws Exception { InspectableQueueModelClient modelClient = new InspectableQueueModelClient(); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-1")); modelClient.enqueue(assistantResult("Approval required before proceeding.")); try (CodingSession session = newAgent(modelClient, approvalRejectedToolExecutor(), defaultOptions()).newSession()) { CodingAgentResult result = session.run("Run the protected operation."); List decisions = session.drainLoopDecisions(); assertEquals(CodingStopReason.BLOCKED_BY_APPROVAL, result.getStopReason()); assertFalse(result.isAutoContinued()); assertEquals(1, result.getTurns()); assertEquals(1, decisions.size()); assertTrue(decisions.get(0).isBlocked()); assertEquals(CodingStopReason.BLOCKED_BY_APPROVAL, decisions.get(0).getStopReason()); } } @Test public void shouldStopAfterToolWorkWhenAssistantAlreadyReturnedFinalAnswer() throws Exception { InspectableQueueModelClient modelClient = new InspectableQueueModelClient(); modelClient.enqueue(toolCallResult(STUB_TOOL, "call-1")); modelClient.enqueue(assistantResult("# ai4j")); try (CodingSession session = newAgent(modelClient, okToolExecutor(), defaultOptions()).newSession()) { CodingAgentResult result = session.run("Read README.md and answer with the first heading."); List decisions = session.drainLoopDecisions(); assertEquals(CodingStopReason.COMPLETED, result.getStopReason()); assertFalse(result.isAutoContinued()); assertEquals(1, result.getTurns()); assertEquals("# ai4j", result.getOutputText()); assertEquals(1, decisions.size()); assertFalse(decisions.get(0).isContinueLoop()); assertEquals(CodingStopReason.COMPLETED, decisions.get(0).getStopReason()); } } @Test public void shouldReanchorContinuationPromptFromCheckpointAfterCompaction() throws Exception { InspectableQueueModelClient modelClient = new InspectableQueueModelClient(); modelClient.enqueue(assistantResult("Continuing with remaining work. " + repeat("checkpoint-pressure-", 80))); modelClient.enqueue(assistantResult("Completed the requested change.")); CodingAgentOptions options = defaultOptions().toBuilder() .autoCompactEnabled(true) .compactContextWindowTokens(220) .compactReserveTokens(60) .compactKeepRecentTokens(60) .compactSummaryMaxOutputTokens(120) .continueAfterCompact(true) .build(); try (CodingSession session = newAgent(modelClient, okToolExecutor(), options).newSession()) { CodingAgentResult result = session.run("Keep working until the task is done."); assertEquals(CodingStopReason.COMPLETED, result.getStopReason()); assertEquals(2, result.getTurns()); assertTrue(result.isAutoContinued()); assertTrue(modelClient.getPrompts().size() >= 3); String continuationInstructions = findContinuationInstructions(modelClient.getPrompts()); assertTrue(continuationInstructions.contains("A context compaction was just applied.")); assertTrue(continuationInstructions.contains("Compaction strategy:")); assertTrue(continuationInstructions.contains("checkpoint")); assertTrue(continuationInstructions.contains("Checkpoint goal: Continue the coding task.")); assertTrue(continuationInstructions.contains("Checkpoint next steps:")); } } @Test public void shouldInjectConstraintsBlockedAndProcessesIntoContinuationPrompt() { CodingLoopDecision decision = CodingLoopDecision.builder() .turnNumber(2) .continueLoop(true) .continueReason(CodingLoopDecision.CONTINUE_AFTER_COMPACTION) .compactApplied(true) .build(); CodingSessionCompactResult compactResult = CodingSessionCompactResult.builder() .strategy("checkpoint-delta") .checkpointReused(true) .checkpoint(CodingSessionCheckpoint.builder() .goal("Finish the current implementation.") .splitTurn(true) .constraints(Collections.singletonList("Keep exact file paths.")) .blockedItems(Collections.singletonList("Wait for approval before deleting files.")) .nextSteps(Collections.singletonList("Run the next code change step.")) .criticalContext(Collections.singletonList("The workspace already contains partial edits.")) .inProgressItems(Collections.singletonList("Refine the compact continuation path.")) .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder() .processId("proc_demo") .command("npm run dev") .status(BashProcessStatus.RUNNING) .restored(true) .build())) .build()) .build(); String prompt = CodingContinuationPrompt.build( decision, CodingAgentResult.builder() .outputText("Continue from the checkpoint.") .build(), compactResult, 3 ); assertTrue(prompt.contains("Compaction strategy: checkpoint-delta.")); assertTrue(prompt.contains("The existing checkpoint was updated with new delta context.")); assertTrue(prompt.contains("This checkpoint came from a split-turn compaction.")); assertTrue(prompt.contains("Checkpoint constraints:")); assertTrue(prompt.contains("Keep exact file paths.")); assertTrue(prompt.contains("Checkpoint blocked items:")); assertTrue(prompt.contains("Wait for approval before deleting files.")); assertTrue(prompt.contains("Checkpoint process snapshots:")); assertTrue(prompt.contains("proc_demo [RUNNING] npm run dev (restored snapshot)")); } private CodingAgent newAgent(InspectableQueueModelClient modelClient, ToolExecutor toolExecutor, CodingAgentOptions options) throws Exception { Path workspaceRoot = temporaryFolder.newFolder("loop-controller-workspace").toPath(); WorkspaceContext workspaceContext = WorkspaceContext.builder() .rootPath(workspaceRoot.toString()) .description("JUnit loop controller workspace") .build(); return CodingAgents.builder() .modelClient(modelClient) .model("glm-4.5-flash") .workspaceContext(workspaceContext) .codingOptions(options) .toolRegistry(singleToolRegistry(STUB_TOOL)) .toolExecutor(toolExecutor) .build(); } private CodingAgentOptions defaultOptions() { return CodingAgentOptions.builder() .autoCompactEnabled(false) .autoContinueEnabled(true) .maxAutoFollowUps(2) .maxTotalTurns(6) .build(); } private AgentToolRegistry singleToolRegistry(String toolName) { Tool.Function function = new Tool.Function(); function.setName(toolName); function.setDescription("Stub tool for testing"); return new StaticToolRegistry(Collections.singletonList(new Tool("function", function))); } private ToolExecutor okToolExecutor() { return new ToolExecutor() { @Override public String execute(AgentToolCall call) { return "{\"ok\":true}"; } }; } private ToolExecutor approvalRejectedToolExecutor() { return new ToolExecutor() { @Override public String execute(AgentToolCall call) { return "[approval-rejected] protected action"; } }; } private AgentModelResult toolCallResult(String toolName, String callId) { return AgentModelResult.builder() .toolCalls(Collections.singletonList(AgentToolCall.builder() .name(toolName) .arguments("{}") .callId(callId) .type("function") .build())) .memoryItems(Collections.emptyList()) .build(); } private AgentModelResult assistantResult(String text) { return AgentModelResult.builder() .outputText(text) .memoryItems(Collections.singletonList(AgentInputItem.message("assistant", text))) .build(); } private String repeat(String text, int times) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < times; i++) { builder.append(text); } return builder.toString(); } private static AgentModelResult compactSummaryResult() { return AgentModelResult.builder() .outputText("{\n" + " \"goal\": \"Continue the coding task.\",\n" + " \"constraints\": [\"Preserve exact file paths.\"],\n" + " \"progress\": {\n" + " \"done\": [\"Compacted older context.\"],\n" + " \"inProgress\": [\"Resume from the checkpoint.\"],\n" + " \"blocked\": []\n" + " },\n" + " \"keyDecisions\": [\"Use the compacted checkpoint for continuation.\"],\n" + " \"nextSteps\": [\"Continue with the next concrete implementation step.\"],\n" + " \"criticalContext\": [\"Recent messages were compacted before auto-continuation.\"]\n" + "}") .build(); } private String findContinuationInstructions(List prompts) { if (prompts == null) { return ""; } for (AgentPrompt prompt : prompts) { if (prompt != null && prompt.getInstructions() != null && prompt.getInstructions().contains("Internal continuation.")) { return prompt.getInstructions(); } } return ""; } private int countUserMessages(MemorySnapshot snapshot) { if (snapshot == null || snapshot.getItems() == null) { return 0; } int count = 0; for (Object item : snapshot.getItems()) { if (!(item instanceof Map)) { continue; } Object role = ((Map) item).get("role"); if ("user".equals(role)) { count += 1; } } return count; } private static final class InspectableQueueModelClient implements AgentModelClient { private final Deque results = new ArrayDeque(); private final List prompts = new ArrayList(); private void enqueue(AgentModelResult result) { results.addLast(result); } private List getPrompts() { return prompts; } @Override public AgentModelResult create(AgentPrompt prompt) { prompts.add(prompt == null ? null : prompt.toBuilder().build()); if (prompt != null && prompt.getSystemPrompt() != null && prompt.getSystemPrompt().contains("context checkpoint summaries")) { return compactSummaryResult(); } AgentModelResult result = results.removeFirst(); return result == null ? AgentModelResult.builder().build() : result; } @Override public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { AgentModelResult result = create(prompt); if (listener != null && result != null && result.getOutputText() != null) { listener.onDeltaText(result.getOutputText()); listener.onComplete(result); } return result; } } } ================================================ FILE: ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandSupportTest.java ================================================ package io.github.lnyocly.ai4j.coding.shell; import org.junit.Test; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class ShellCommandSupportTest { @Test public void shouldBuildWindowsShellCommandAndGuidance() { assertEquals(Arrays.asList("cmd.exe", "/c", "dir"), ShellCommandSupport.buildShellCommand("dir", "Windows 11")); String guidance = ShellCommandSupport.buildShellUsageGuidance("Windows 11"); assertTrue(guidance.contains("cmd.exe /c")); assertTrue(guidance.contains("cat < file")); assertTrue(guidance.contains("Get-Content")); assertTrue(guidance.contains("action=start")); } @Test public void shouldResolveWindowsShellCharsetFromNativeEncoding() { Charset charset = ShellCommandSupport.resolveShellCharset( "Windows 11", new String[]{null, ""}, new String[]{"GBK", "UTF-8"} ); assertEquals(Charset.forName("GBK"), charset); } @Test public void shouldPreferExplicitShellCharsetOverride() { Charset charset = ShellCommandSupport.resolveShellCharset( "Windows 11", new String[]{"UTF-8"}, new String[]{"GBK"} ); assertEquals(StandardCharsets.UTF_8, charset); } } ================================================ FILE: ai4j-flowgram-demo/README.md ================================================ # ai4j FlowGram Demo ## Run ```powershell $env:ZHIPU_API_KEY="your-key" cmd /c "mvn -pl ai4j-flowgram-demo -am -DskipTests package" java -jar ai4j-flowgram-demo/target/ai4j-flowgram-demo-2.1.0.jar ``` The demo exposes FlowGram REST APIs under `/flowgram`. ## Quick Check ```powershell $body = @{ schema = @{ nodes = @( @{ id = "start_0" type = "Start" name = "start_0" data = @{ outputs = @{ type = "object" required = @("message") properties = @{ message = @{ type = "string" } } } } }, @{ id = "end_0" type = "End" name = "end_0" data = @{ inputs = @{ type = "object" required = @("result") properties = @{ result = @{ type = "string" } } } inputsValues = @{ result = @{ type = "ref" content = @("start_0", "message") } } } } ) edges = @( @{ sourceNodeID = "start_0" targetNodeID = "end_0" } ) } inputs = @{ message = "hello-flowgram" } } | ConvertTo-Json -Depth 10 Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:18080/flowgram/tasks/run" -ContentType "application/json" -Body $body ``` ## Real LLM Example ```powershell $body = @{ schema = @{ nodes = @( @{ id = "start_0" type = "Start" name = "start_0" data = @{ outputs = @{ type = "object" required = @("message") properties = @{ message = @{ type = "string" } } } } }, @{ id = "llm_0" type = "LLM" name = "llm_0" data = @{ inputs = @{ type = "object" required = @("modelName", "prompt") properties = @{ modelName = @{ type = "string" } prompt = @{ type = "string" } } } outputs = @{ type = "object" required = @("result") properties = @{ result = @{ type = "string" } } } inputsValues = @{ modelName = @{ type = "constant" content = "glm-4.7" } prompt = @{ type = "ref" content = @("start_0", "message") } } } }, @{ id = "end_0" type = "End" name = "end_0" data = @{ inputs = @{ type = "object" required = @("result") properties = @{ result = @{ type = "string" } } } inputsValues = @{ result = @{ type = "ref" content = @("llm_0", "result") } } } } ) edges = @( @{ sourceNodeID = "start_0" targetNodeID = "llm_0" }, @{ sourceNodeID = "llm_0" targetNodeID = "end_0" } ) } inputs = @{ message = "Please answer with exactly three words: FlowGram spring boot." } } | ConvertTo-Json -Depth 12 $run = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:18080/flowgram/tasks/run" -ContentType "application/json" -Body $body $result = Invoke-RestMethod -Method Get -Uri ("http://127.0.0.1:18080/flowgram/tasks/" + $run.taskId + "/result") ``` On a verified local run with `glm-4.7`, the workflow completed with: ```json { "status": "success", "result": "FlowGram spring boot" } ``` ================================================ FILE: ai4j-flowgram-demo/backend-18080-run.log ================================================ [INFO] Scanning for projects... [INFO] [INFO] ---------------< io.github.lnyo-cly:ai4j-flowgram-demo >---------------- [INFO] Building ai4j-flowgram-demo 2.0.0 [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] >>> spring-boot:2.3.12.RELEASE:run (default-cli) > test-compile @ ai4j-flowgram-demo >>> [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ ai4j-flowgram-demo --- [INFO] Copying 1 resource from src\main\resources to target\classes [INFO] [INFO] --- compiler:3.8.1:compile (default-compile) @ ai4j-flowgram-demo --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ ai4j-flowgram-demo --- [INFO] skip non existing resourceDirectory G:\My_Project\java\ai4j-sdk\ai4j-flowgram-demo\src\test\resources [INFO] [INFO] --- compiler:3.8.1:testCompile (default-testCompile) @ ai4j-flowgram-demo --- [INFO] No sources to compile [INFO] [INFO] <<< spring-boot:2.3.12.RELEASE:run (default-cli) < test-compile @ ai4j-flowgram-demo <<< [INFO] [INFO] [INFO] --- spring-boot:2.3.12.RELEASE:run (default-cli) @ ai4j-flowgram-demo --- [INFO] Attaching agents: [] . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v2.3.12.RELEASE) 2026-03-28 17:20:27.251 INFO 51532 --- [ main] i.g.l.a.f.demo.FlowGramDemoApplication : Starting FlowGramDemoApplication on DESKTOP-F1VDEPJ with PID 51532 (G:\My_Project\java\ai4j-sdk\ai4j-flowgram-demo\target\classes started by 1 in G:\My_Project\java\ai4j-sdk\ai4j-flowgram-demo) 2026-03-28 17:20:27.253 INFO 51532 --- [ main] i.g.l.a.f.demo.FlowGramDemoApplication : No active profile set, falling back to default profiles: default 2026-03-28 17:20:27.348 WARN 51532 --- [kground-preinit] o.s.h.c.j.Jackson2ObjectMapperBuilder : For Jackson Kotlin classes support please add "com.fasterxml.jackson.module:jackson-module-kotlin" to the classpath 2026-03-28 17:20:28.119 INFO 51532 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 18080 (http) 2026-03-28 17:20:28.130 INFO 51532 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] 2026-03-28 17:20:28.130 INFO 51532 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.46] 2026-03-28 17:20:28.274 INFO 51532 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext 2026-03-28 17:20:28.274 INFO 51532 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 980 ms 2026-03-28 17:20:28.502 INFO 51532 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor' 2026-03-28 17:20:28.644 INFO 51532 --- [ main] i.g.l.ai4j.utils.ServiceLoaderUtil : Loaded SPI implementation: DefaultDispatcherProvider 2026-03-28 17:20:28.646 INFO 51532 --- [ main] i.g.l.ai4j.utils.ServiceLoaderUtil : Loaded SPI implementation: DefaultConnectionPoolProvider 2026-03-28 17:20:28.947 INFO 51532 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' 2026-03-28 17:20:28.953 INFO 51532 --- [ main] i.g.l.a.f.demo.FlowGramDemoApplication : Started FlowGramDemoApplication in 2.019 seconds (JVM running for 2.555) 2026-03-28 17:20:35.313 INFO 51532 --- [io-18080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet' 2026-03-28 17:20:35.313 INFO 51532 --- [io-18080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' 2026-03-28 17:20:35.316 INFO 51532 --- [io-18080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 3 ms [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ [INFO] Total time: 01:08 h [INFO] Finished at: 2026-03-28T18:28:45+08:00 [INFO] ------------------------------------------------------------------------ [ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:2.3.12.RELEASE:run (default-cli) on project ai4j-flowgram-demo: Application finished with exit code: -1 -> [Help 1] [ERROR] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. [ERROR] Re-run Maven using the -X switch to enable full debug logging. [ERROR] [ERROR] For more information about the errors and possible solutions, please read the following articles: [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException ================================================ FILE: ai4j-flowgram-demo/backend-18080.log ================================================ [INFO] Scanning for projects... [INFO] ------------------------------------------------------------------------ [INFO] Reactor Build Order: [INFO] [INFO] ai4j-sdk [pom] [INFO] ai4j-core [jar] [INFO] ai4j-model [jar] [INFO] ai4j-agent [jar] [INFO] ai4j [jar] [INFO] ai4j-spring-boot-starter [jar] [INFO] ai4j-flowgram-spring-boot-starter [jar] [INFO] ai4j-flowgram-demo [jar] [INFO] [INFO] --------------------< io.github.lnyo-cly:ai4j-sdk >--------------------- [INFO] Building ai4j-sdk 2.0.0 [1/8] [INFO] from pom.xml [INFO] --------------------------------[ pom ]--------------------------------- [INFO] [INFO] >>> spring-boot:2.3.12.RELEASE:run (default-cli) > test-compile @ ai4j-sdk >>> [INFO] [INFO] <<< spring-boot:2.3.12.RELEASE:run (default-cli) < test-compile @ ai4j-sdk <<< [INFO] [INFO] [INFO] --- spring-boot:2.3.12.RELEASE:run (default-cli) @ ai4j-sdk --- [INFO] ------------------------------------------------------------------------ [INFO] Reactor Summary for ai4j-sdk 2.0.0: [INFO] [INFO] ai4j-sdk ........................................... FAILURE [ 1.107 s] [INFO] ai4j-core .......................................... SKIPPED [INFO] ai4j-model ......................................... SKIPPED [INFO] ai4j-agent ......................................... SKIPPED [INFO] ai4j ............................................... SKIPPED [INFO] ai4j-spring-boot-starter ........................... SKIPPED [INFO] ai4j-flowgram-spring-boot-starter .................. SKIPPED [INFO] ai4j-flowgram-demo ................................. SKIPPED [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ [INFO] Total time: 1.351 s [INFO] Finished at: 2026-03-28T17:19:49+08:00 [INFO] ------------------------------------------------------------------------ [ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:2.3.12.RELEASE:run (default-cli) on project ai4j-sdk: Unable to find a suitable main class, please add a 'mainClass' property -> [Help 1] [ERROR] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. [ERROR] Re-run Maven using the -X switch to enable full debug logging. [ERROR] [ERROR] For more information about the errors and possible solutions, please read the following articles: [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException ================================================ FILE: ai4j-flowgram-demo/pom.xml ================================================ 4.0.0 io.github.lnyo-cly ai4j-sdk 2.3.0 ai4j-flowgram-demo jar ai4j-flowgram-demo Demo application for ai4j FlowGram Spring Boot integration. 1.8 UTF-8 2.3.12.RELEASE io.github.lnyo-cly ai4j-flowgram-spring-boot-starter ${project.version} org.apache.maven.plugins maven-deploy-plugin 3.1.1 true org.apache.maven.plugins maven-install-plugin 3.1.1 true org.apache.maven.plugins maven-compiler-plugin 3.8.1 1.8 1.8 UTF8 org.springframework.boot spring-boot-maven-plugin ${spring-boot.version} io.github.lnyocly.ai4j.flowgram.demo.FlowGramDemoApplication repackage ================================================ FILE: ai4j-flowgram-demo/src/main/java/io/github/lnyocly/ai4j/flowgram/demo/FlowGramDemoApplication.java ================================================ package io.github.lnyocly.ai4j.flowgram.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class FlowGramDemoApplication { public static void main(String[] args) { SpringApplication.run(FlowGramDemoApplication.class, args); } } ================================================ FILE: ai4j-flowgram-demo/src/main/java/io/github/lnyocly/ai4j/flowgram/demo/FlowGramDemoMockController.java ================================================ package io.github.lnyocly.ai4j.flowgram.demo; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.LinkedHashMap; import java.util.Map; @RestController @RequestMapping("/flowgram/demo/mock") public class FlowGramDemoMockController { @GetMapping("/weather") public Map weather(@RequestParam(value = "city", required = false) String city, @RequestParam(value = "date", required = false) String date) { String safeCity = isBlank(city) ? "杭州" : city.trim(); String safeDate = isBlank(date) ? "2026-04-02" : date.trim(); int seed = Math.abs((safeCity + "|" + safeDate).hashCode()); Map payload = new LinkedHashMap(); payload.put("city", safeCity); payload.put("date", safeDate); payload.put("weather", pickWeather(seed)); payload.put("temperature", pickTemperature(seed)); payload.put("advice", pickAdvice(seed)); return payload; } private String pickWeather(int seed) { String[] values = new String[]{"晴", "多云", "小雨", "阴"}; return values[seed % values.length]; } private String pickTemperature(int seed) { int min = 12 + (seed % 7); int max = min + 5 + (seed % 4); return min + "-" + max + "C"; } private String pickAdvice(int seed) { String[] values = new String[]{ "适合按计划出行", "建议提前十分钟进站", "请留意沿途降雨变化", "建议准备一件薄外套" }; return values[seed % values.length]; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-flowgram-demo/src/main/resources/application.yml ================================================ server: port: 18080 ai: platforms: - id: minimax-coding platform: minimax api-key: ${MINIMAX_API_KEY:} api-host: https://api.minimax.chat/ chat-completion-url: v1/text/chatcompletion_v2 - id: glm-coding platform: zhipu api-key: ${ZHIPU_API_KEY:} api-host: https://open.bigmodel.cn/api/paas/ chat-completion-url: v4/chat/completions ai4j: flowgram: default-service-id: minimax-coding api: base-path: /flowgram ================================================ FILE: ai4j-flowgram-spring-boot-starter/pom.xml ================================================ 4.0.0 io.github.lnyo-cly ai4j-flowgram-spring-boot-starter jar 2.3.0 ai4j-flowgram-spring-boot-starter ai4j FlowGram 的 Spring Boot Starter,支持流程应用与 trace 集成。 Spring Boot starter for ai4j FlowGram workflow applications and trace integration. The Apache License, Version 2.0 https://www.apache.org/licenses/LICENSE-2.0.txt GitHub https://github.com/LnYo-Cly/ai4j/issues https://github.com/LnYo-Cly/ai4j LnYo-Cly LnYo-Cly lnyocly@gmail.com https://github.com/LnYo-Cly/ai4j +8 https://github.com/LnYo-Cly/ai4j scm:git:https://github.com/LnYo-Cly/ai4j.git scm:git:https://github.com/LnYo-Cly/ai4j.git 1.8 UTF-8 UTF-8 2.3.12.RELEASE true io.github.lnyo-cly ai4j-agent ${project.version} org.slf4j slf4j-simple io.github.lnyo-cly ai4j-spring-boot-starter ${project.version} org.slf4j slf4j-simple org.springframework.boot spring-boot-starter-web ${spring-boot.version} org.springframework.boot spring-boot-configuration-processor ${spring-boot.version} true javax.annotation javax.annotation-api 1.3.2 junit junit 4.13.2 test com.h2database h2 2.2.224 test org.springframework.boot spring-boot-starter-test ${spring-boot.version} test ${project.name}-${project.version} org.apache.maven.plugins maven-surefire-plugin 2.12.4 ${skipTests} org.apache.maven.plugins maven-compiler-plugin 3.8.1 1.8 1.8 UTF8 -parameters true org.projectlombok lombok 1.18.30 release org.apache.maven.plugins maven-source-plugin 3.3.1 attach-sources jar-no-fork org.apache.maven.plugins maven-javadoc-plugin 3.6.3 attach-javadocs jar none false Author a Author: Description a Description: Date a Date: org.apache.maven.plugins maven-gpg-plugin 1.6 D:\Develop\DevelopEnv\GnuPG\bin\gpg.exe cly sign-artifacts verify sign org.sonatype.central central-publishing-maven-plugin 0.4.0 true LnYo-Cly true ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/adapter/FlowGramProtocolAdapter.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.adapter; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunInput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskCancelResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskResultResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateResponse; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class FlowGramProtocolAdapter { public FlowGramTaskRunInput toTaskRunInput(FlowGramTaskRunRequest request) { return FlowGramTaskRunInput.builder() .schema(schemaToJson(request == null ? null : request.getSchema())) .inputs(safeMap(request == null ? null : request.getInputs())) .build(); } public FlowGramTaskRunInput toTaskRunInput(FlowGramTaskValidateRequest request) { return FlowGramTaskRunInput.builder() .schema(schemaToJson(request == null ? null : request.getSchema())) .inputs(safeMap(request == null ? null : request.getInputs())) .build(); } public FlowGramTaskRunResponse toRunResponse(FlowGramTaskRunOutput output) { return FlowGramTaskRunResponse.builder() .taskId(output == null ? null : output.getTaskID()) .build(); } public FlowGramTaskValidateResponse toValidateResponse(FlowGramTaskValidateOutput output) { return FlowGramTaskValidateResponse.builder() .valid(output != null && output.isValid()) .errors(output == null || output.getErrors() == null ? Collections.emptyList() : output.getErrors()) .build(); } public FlowGramTaskReportResponse toReportResponse(String taskId, FlowGramTaskReportOutput output, boolean includeNodeDetails, FlowGramTraceView trace) { Map nodes = null; if (includeNodeDetails && output != null && output.getNodes() != null) { nodes = new LinkedHashMap(); for (Map.Entry entry : output.getNodes().entrySet()) { FlowGramTaskReportOutput.NodeStatus value = entry.getValue(); nodes.put(entry.getKey(), FlowGramTaskReportResponse.NodeStatus.builder() .status(value == null ? null : value.getStatus()) .terminated(value != null && value.isTerminated()) .startTime(value == null ? null : value.getStartTime()) .endTime(value == null ? null : value.getEndTime()) .error(value == null ? null : value.getError()) .inputs(value == null ? Collections.emptyMap() : safeMap(value.getInputs())) .outputs(value == null ? Collections.emptyMap() : safeMap(value.getOutputs())) .build()); } } FlowGramTaskReportOutput.WorkflowStatus workflow = output == null ? null : output.getWorkflow(); return FlowGramTaskReportResponse.builder() .taskId(taskId) .inputs(output == null ? Collections.emptyMap() : safeMap(output.getInputs())) .outputs(output == null ? Collections.emptyMap() : safeMap(output.getOutputs())) .workflow(FlowGramTaskReportResponse.WorkflowStatus.builder() .status(workflow == null ? null : workflow.getStatus()) .terminated(workflow != null && workflow.isTerminated()) .startTime(workflow == null ? null : workflow.getStartTime()) .endTime(workflow == null ? null : workflow.getEndTime()) .error(workflow == null ? null : workflow.getError()) .build()) .nodes(nodes) .trace(trace) .build(); } public FlowGramTaskResultResponse toResultResponse(String taskId, FlowGramTaskResultOutput output, FlowGramTraceView trace) { return FlowGramTaskResultResponse.builder() .taskId(taskId) .status(output == null ? null : output.getStatus()) .terminated(output != null && output.isTerminated()) .error(output == null ? null : output.getError()) .result(output == null ? Collections.emptyMap() : safeMap(output.getResult())) .trace(trace) .build(); } public FlowGramTaskCancelResponse toCancelResponse(boolean success) { return FlowGramTaskCancelResponse.builder() .success(success) .build(); } private String schemaToJson(Object schema) { if (schema == null) { return null; } if (schema instanceof String) { return (String) schema; } return JSON.toJSONString(schema); } private Map safeMap(Map source) { Map target = new LinkedHashMap(); if (source == null) { return target; } for (Map.Entry entry : source.entrySet()) { target.put(entry.getKey(), copyValue(entry.getValue())); } return target; } @SuppressWarnings("unchecked") private Object copyValue(Object value) { if (value instanceof Map) { Map copy = new LinkedHashMap(); Map source = (Map) value; for (Map.Entry entry : source.entrySet()) { copy.put(String.valueOf(entry.getKey()), copyValue(entry.getValue())); } return copy; } if (value instanceof List) { java.util.List copy = new java.util.ArrayList(); for (Object item : (List) value) { copy.add(copyValue(item)); } return copy; } return value; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/autoconfigure/FlowGramAutoConfiguration.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.autoconfigure; import io.github.lnyocly.ai4j.AiConfigAutoConfiguration; import io.github.lnyocly.ai4j.agent.flowgram.Ai4jFlowGramLlmNodeRunner; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramLlmNodeRunner; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeListener; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeService; import io.github.lnyocly.ai4j.agent.trace.TracePricingResolver; import io.github.lnyocly.ai4j.flowgram.springboot.adapter.FlowGramProtocolAdapter; import io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties; import io.github.lnyocly.ai4j.flowgram.springboot.controller.FlowGramTaskController; import io.github.lnyocly.ai4j.flowgram.springboot.exception.FlowGramExceptionHandler; import io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramCodeNodeExecutor; import io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramHttpNodeExecutor; import io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramKnowledgeRetrieveNodeExecutor; import io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramToolNodeExecutor; import io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramVariableNodeExecutor; import io.github.lnyocly.ai4j.flowgram.springboot.security.DefaultFlowGramAccessChecker; import io.github.lnyocly.ai4j.flowgram.springboot.security.DefaultFlowGramCallerResolver; import io.github.lnyocly.ai4j.flowgram.springboot.security.DefaultFlowGramTaskOwnershipStrategy; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAccessChecker; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramCallerResolver; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramTaskOwnershipStrategy; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeFacade; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeTraceCollector; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramTaskStore; import io.github.lnyocly.ai4j.flowgram.springboot.support.InMemoryFlowGramTaskStore; import io.github.lnyocly.ai4j.flowgram.springboot.support.JdbcFlowGramTaskStore; import io.github.lnyocly.ai4j.flowgram.springboot.support.RegistryBackedFlowGramModelClientResolver; import io.github.lnyocly.ai4j.rag.RagContextAssembler; import io.github.lnyocly.ai4j.rag.Reranker; import io.github.lnyocly.ai4j.service.factory.AiServiceRegistry; import io.github.lnyocly.ai4j.vector.store.VectorStore; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.DispatcherServlet; import javax.sql.DataSource; @Configuration @ConditionalOnClass(DispatcherServlet.class) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @ConditionalOnProperty(prefix = "ai4j.flowgram", name = "enabled", havingValue = "true", matchIfMissing = true) @AutoConfigureAfter(AiConfigAutoConfiguration.class) @EnableConfigurationProperties(FlowGramProperties.class) public class FlowGramAutoConfiguration { @Bean @ConditionalOnMissingBean public FlowGramTaskStore flowGramTaskStore(FlowGramProperties properties, ObjectProvider dataSourceProvider) { String type = properties == null || properties.getTaskStore() == null ? "memory" : properties.getTaskStore().getType(); if (type == null || "memory".equalsIgnoreCase(type.trim())) { return new InMemoryFlowGramTaskStore(); } if ("jdbc".equalsIgnoreCase(type.trim())) { DataSource dataSource = dataSourceProvider.getIfAvailable(); if (dataSource == null) { throw new IllegalStateException("FlowGram jdbc task store requires a DataSource bean"); } FlowGramProperties.TaskStoreProperties taskStore = properties.getTaskStore(); return new JdbcFlowGramTaskStore( dataSource, taskStore == null ? null : taskStore.getTableName(), taskStore == null || taskStore.isInitializeSchema() ); } throw new IllegalStateException("Unsupported FlowGram task store type: " + type); } @Bean @ConditionalOnMissingBean public FlowGramCallerResolver flowGramCallerResolver(FlowGramProperties properties) { return new DefaultFlowGramCallerResolver(properties); } @Bean @ConditionalOnMissingBean public FlowGramAccessChecker flowGramAccessChecker() { return new DefaultFlowGramAccessChecker(); } @Bean @ConditionalOnMissingBean public FlowGramTaskOwnershipStrategy flowGramTaskOwnershipStrategy(FlowGramProperties properties) { return new DefaultFlowGramTaskOwnershipStrategy(properties == null ? null : properties.getTaskRetention()); } @Bean @ConditionalOnMissingBean public FlowGramProtocolAdapter flowGramProtocolAdapter() { return new FlowGramProtocolAdapter(); } @Bean @ConditionalOnMissingBean(FlowGramLlmNodeRunner.class) public FlowGramLlmNodeRunner flowGramLlmNodeRunner(AiServiceRegistry aiServiceRegistry, FlowGramProperties properties, ObjectProvider pricingResolverProvider) { return new Ai4jFlowGramLlmNodeRunner( new RegistryBackedFlowGramModelClientResolver(aiServiceRegistry, properties), pricingResolverProvider.getIfAvailable() ); } @Bean @ConditionalOnMissingBean public FlowGramRuntimeService flowGramRuntimeService(FlowGramLlmNodeRunner flowGramLlmNodeRunner, ObjectProvider executors) { FlowGramRuntimeService runtimeService = new FlowGramRuntimeService(flowGramLlmNodeRunner); for (FlowGramNodeExecutor executor : executors) { runtimeService.registerNodeExecutor(executor); } return runtimeService; } @Bean @ConditionalOnMissingBean(name = "flowGramHttpNodeExecutor") public FlowGramNodeExecutor flowGramHttpNodeExecutor() { return new FlowGramHttpNodeExecutor(); } @Bean @ConditionalOnMissingBean(name = "flowGramVariableNodeExecutor") public FlowGramNodeExecutor flowGramVariableNodeExecutor() { return new FlowGramVariableNodeExecutor(); } @Bean @ConditionalOnMissingBean(name = "flowGramCodeNodeExecutor") public FlowGramNodeExecutor flowGramCodeNodeExecutor() { return new FlowGramCodeNodeExecutor(); } @Bean @ConditionalOnMissingBean(name = "flowGramToolNodeExecutor") public FlowGramNodeExecutor flowGramToolNodeExecutor() { return new FlowGramToolNodeExecutor(); } @Bean @ConditionalOnBean(AiServiceRegistry.class) @ConditionalOnSingleCandidate(VectorStore.class) @ConditionalOnMissingBean(name = "flowGramKnowledgeRetrieveNodeExecutor") public FlowGramNodeExecutor flowGramKnowledgeRetrieveNodeExecutor(AiServiceRegistry aiServiceRegistry, VectorStore vectorStore, Reranker ragReranker, RagContextAssembler ragContextAssembler) { return new FlowGramKnowledgeRetrieveNodeExecutor( aiServiceRegistry, vectorStore, ragReranker, ragContextAssembler ); } @Bean public SmartInitializingSingleton flowGramNodeExecutorRegistrar(FlowGramRuntimeService runtimeService, ObjectProvider executors) { return new SmartInitializingSingleton() { @Override public void afterSingletonsInstantiated() { for (FlowGramNodeExecutor executor : executors) { runtimeService.registerNodeExecutor(executor); } } }; } @Bean public SmartInitializingSingleton flowGramRuntimeListenerRegistrar(FlowGramRuntimeService runtimeService, ObjectProvider listeners) { return new SmartInitializingSingleton() { @Override public void afterSingletonsInstantiated() { for (FlowGramRuntimeListener listener : listeners) { runtimeService.registerListener(listener); } } }; } @Bean @ConditionalOnMissingBean public FlowGramRuntimeTraceCollector flowGramRuntimeTraceCollector() { return new FlowGramRuntimeTraceCollector(); } @Bean @ConditionalOnMissingBean public FlowGramRuntimeFacade flowGramRuntimeFacade(FlowGramRuntimeService runtimeService, FlowGramProtocolAdapter protocolAdapter, FlowGramTaskStore taskStore, FlowGramCallerResolver callerResolver, FlowGramAccessChecker accessChecker, FlowGramTaskOwnershipStrategy ownershipStrategy, FlowGramProperties properties, FlowGramRuntimeTraceCollector traceCollector) { return new FlowGramRuntimeFacade(runtimeService, protocolAdapter, taskStore, callerResolver, accessChecker, ownershipStrategy, properties, traceCollector); } @Bean @ConditionalOnMissingBean public FlowGramTaskController flowGramTaskController(FlowGramRuntimeFacade runtimeFacade) { return new FlowGramTaskController(runtimeFacade); } @Bean @ConditionalOnMissingBean public FlowGramExceptionHandler flowGramExceptionHandler() { return new FlowGramExceptionHandler(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/config/FlowGramProperties.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import java.time.Duration; import java.util.ArrayList; import java.util.List; @Data @ConfigurationProperties(prefix = "ai4j.flowgram") public class FlowGramProperties { private boolean enabled = true; private String defaultServiceId; private boolean streamProgress = false; private Duration taskRetention = Duration.ofHours(1); private boolean reportNodeDetails = true; private boolean traceEnabled = true; private final ApiProperties api = new ApiProperties(); private final TaskStoreProperties taskStore = new TaskStoreProperties(); private final CorsProperties cors = new CorsProperties(); private final AuthProperties auth = new AuthProperties(); @Data public static class ApiProperties { private String basePath = "/flowgram"; } @Data public static class TaskStoreProperties { private String type = "memory"; private String tableName = "ai4j_flowgram_task"; private boolean initializeSchema = true; } @Data public static class CorsProperties { private List allowedOrigins = new ArrayList(); } @Data public static class AuthProperties { private boolean enabled = false; private String headerName = "Authorization"; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/controller/FlowGramTaskController.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.controller; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskCancelResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskResultResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateResponse; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeFacade; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; @RestController @RequestMapping("${ai4j.flowgram.api.base-path:/flowgram}") public class FlowGramTaskController { private final FlowGramRuntimeFacade runtimeFacade; public FlowGramTaskController(FlowGramRuntimeFacade runtimeFacade) { this.runtimeFacade = runtimeFacade; } @PostMapping("/tasks/run") public FlowGramTaskRunResponse run(@RequestBody FlowGramTaskRunRequest request, HttpServletRequest servletRequest) { return runtimeFacade.run(request, servletRequest); } @PostMapping("/tasks/validate") public FlowGramTaskValidateResponse validate(@RequestBody FlowGramTaskValidateRequest request, HttpServletRequest servletRequest) { return runtimeFacade.validate(request, servletRequest); } @GetMapping("/tasks/{taskId}/report") public FlowGramTaskReportResponse report(@PathVariable("taskId") String taskId, HttpServletRequest servletRequest) { return runtimeFacade.report(taskId, servletRequest); } @GetMapping("/tasks/{taskId}/result") public FlowGramTaskResultResponse result(@PathVariable("taskId") String taskId, HttpServletRequest servletRequest) { return runtimeFacade.result(taskId, servletRequest); } @PostMapping("/tasks/{taskId}/cancel") public FlowGramTaskCancelResponse cancel(@PathVariable("taskId") String taskId, HttpServletRequest servletRequest) { return runtimeFacade.cancel(taskId, servletRequest); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramErrorResponse.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramErrorResponse { private String code; private String message; private Object details; private long timestamp; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskCancelResponse.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskCancelResponse { private boolean success; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskReportResponse.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskReportResponse { private String taskId; private Map inputs; private Map outputs; private WorkflowStatus workflow; private Map nodes; private FlowGramTraceView trace; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class WorkflowStatus { private String status; private boolean terminated; private Long startTime; private Long endTime; private String error; } @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class NodeStatus { private String status; private boolean terminated; private Long startTime; private Long endTime; private String error; private Map inputs; private Map outputs; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskResultResponse.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskResultResponse { private String taskId; private String status; private boolean terminated; private String error; private Map result; private FlowGramTraceView trace; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskRunRequest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskRunRequest { private Object schema; private Map inputs; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskRunResponse.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskRunResponse { private String taskId; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskValidateRequest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskValidateRequest { private Object schema; private Map inputs; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskValidateResponse.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskValidateResponse { private boolean valid; private List errors; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTraceView.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTraceView { private String taskId; private String status; private Long startedAt; private Long endedAt; private SummaryView summary; private List events; private Map nodes; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class EventView { private String type; private Long timestamp; private String nodeId; private String status; private String error; } @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class NodeView { private String nodeId; private String status; private boolean terminated; private Long startedAt; private Long endedAt; private Long durationMillis; private String error; private Integer eventCount; private String model; private MetricsView metrics; } @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class SummaryView { private Long durationMillis; private Integer eventCount; private Integer nodeCount; private Integer terminatedNodeCount; private Integer successNodeCount; private Integer failedNodeCount; private Integer llmNodeCount; private MetricsView metrics; } @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public static class MetricsView { private Long promptTokens; private Long completionTokens; private Long totalTokens; private Double inputCost; private Double outputCost; private Double totalCost; private String currency; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramAccessDeniedException.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.exception; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAction; import org.springframework.http.HttpStatus; public class FlowGramAccessDeniedException extends FlowGramApiException { public FlowGramAccessDeniedException(FlowGramAction action) { super(HttpStatus.FORBIDDEN, "FLOWGRAM_ACCESS_DENIED", "Access denied for FlowGram action: " + action); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramApiException.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.exception; import org.springframework.http.HttpStatus; public class FlowGramApiException extends RuntimeException { private final HttpStatus status; private final String code; private final Object details; public FlowGramApiException(HttpStatus status, String code, String message) { this(status, code, message, null); } public FlowGramApiException(HttpStatus status, String code, String message, Object details) { super(message); this.status = status; this.code = code; this.details = details; } public HttpStatus getStatus() { return status; } public String getCode() { return code; } public Object getDetails() { return details; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramExceptionHandler.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.exception; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramErrorResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class FlowGramExceptionHandler { @ExceptionHandler(FlowGramApiException.class) public ResponseEntity handleFlowGramApiException(FlowGramApiException ex) { return ResponseEntity.status(ex.getStatus()) .body(error(ex.getCode(), ex.getMessage(), ex.getDetails())); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(error("FLOWGRAM_BAD_REQUEST", ex.getMessage(), null)); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(error("FLOWGRAM_RUNTIME_ERROR", ex.getMessage(), null)); } private FlowGramErrorResponse error(String code, String message, Object details) { return FlowGramErrorResponse.builder() .code(code) .message(message) .details(details) .timestamp(System.currentTimeMillis()) .build(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramTaskNotFoundException.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.exception; import org.springframework.http.HttpStatus; public class FlowGramTaskNotFoundException extends FlowGramApiException { public FlowGramTaskNotFoundException(String taskId) { super(HttpStatus.NOT_FOUND, "FLOWGRAM_TASK_NOT_FOUND", "FlowGram task not found: " + taskId); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramCodeNodeExecutor.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest; import io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult; import io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor; import java.util.LinkedHashMap; import java.util.Map; public class FlowGramCodeNodeExecutor implements FlowGramNodeExecutor { private final NashornCodeExecutor codeExecutor = new NashornCodeExecutor(); @Override public String getType() { return "CODE"; } @Override public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) { Map nodeData = context == null || context.getNode() == null ? new LinkedHashMap() : safeMap(context.getNode().getData()); Map script = mapValue(nodeData.get("script")); String language = valueAsString(script == null ? null : script.get("language")); if (isBlank(language)) { language = "javascript"; } String content = valueAsString(script == null ? null : script.get("content")); if (isBlank(content)) { throw new IllegalArgumentException("Code node requires script.content"); } CodeExecutionResult executionResult = codeExecutor.execute(CodeExecutionRequest.builder() .language(language) .code(buildScript(content, context == null ? null : context.getInputs())) .timeoutMs(8000L) .build()); if (executionResult == null) { throw new IllegalStateException("Code node executor returned no result"); } if (!executionResult.isSuccess()) { throw new IllegalStateException(executionResult.getError()); } Map outputs = parseOutputs(executionResult.getResult()); if (executionResult.getStdout() != null && !executionResult.getStdout().trim().isEmpty()) { outputs.put("stdout", executionResult.getStdout()); } return FlowGramNodeExecutionResult.builder() .outputs(outputs) .build(); } private String buildScript(String userCode, Map params) { String paramsJson = JSON.toJSONString(params == null ? new LinkedHashMap() : params); String paramsLiteral = JSON.toJSONString(paramsJson); StringBuilder builder = new StringBuilder(); builder.append("var params = JSON.parse(").append(paramsLiteral).append(");\n"); builder.append("var __flowgram_input = { params: params };\n"); builder.append(userCode).append("\n"); builder.append("if (typeof main === 'function') {\n"); builder.append(" var __flowgram_result = main(__flowgram_input);\n"); builder.append(" if (__flowgram_result != null && typeof __flowgram_result.then === 'function') {\n"); builder.append(" throw new Error('async main() is not supported in FlowGram Code node yet');\n"); builder.append(" }\n"); builder.append(" return JSON.stringify(__flowgram_result == null ? {} : __flowgram_result);\n"); builder.append("} else if (typeof ret !== 'undefined') {\n"); builder.append(" return JSON.stringify(ret == null ? {} : ret);\n"); builder.append("} else {\n"); builder.append(" return JSON.stringify({});\n"); builder.append("}\n"); return builder.toString(); } private Map parseOutputs(String rawResult) { if (isBlank(rawResult)) { return new LinkedHashMap(); } Object parsed = tryParse(rawResult); if (parsed instanceof JSONObject) { return new LinkedHashMap(((JSONObject) parsed)); } if (parsed instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) parsed; return new LinkedHashMap(map); } Map outputs = new LinkedHashMap(); outputs.put("result", parsed == null ? rawResult : parsed); return outputs; } private Object tryParse(String raw) { try { return JSON.parse(raw); } catch (Exception ex) { return raw; } } private Map mapValue(Object value) { if (!(value instanceof Map)) { return null; } @SuppressWarnings("unchecked") Map map = (Map) value; return map; } private Map safeMap(Map value) { return value == null ? new LinkedHashMap() : value; } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramHttpNodeExecutor.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; public class FlowGramHttpNodeExecutor implements FlowGramNodeExecutor { private final FlowGramNodeValueResolver valueResolver = new FlowGramNodeValueResolver(); @Override public String getType() { return "HTTP"; } @Override public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception { Map nodeData = context == null || context.getNode() == null ? new LinkedHashMap() : safeMap(context.getNode().getData()); Map api = valueResolver.resolveMap(mapValue(nodeData.get("api")), context); Map headers = valueResolver.resolveMap(mapValue(nodeData.get("headersValues")), context); Map params = valueResolver.resolveMap(mapValue(nodeData.get("paramsValues")), context); Map timeout = valueResolver.resolveMap(mapValue(nodeData.get("timeout")), context); Map body = safeMap(mapValue(nodeData.get("body"))); String method = firstNonBlank(valueAsString(api.get("method")), "GET").toUpperCase(Locale.ROOT); String url = valueAsString(api.get("url")); if (isBlank(url)) { throw new IllegalArgumentException("HTTP node requires api.url"); } String fullUrl = appendQueryParams(url, params); int timeoutMs = intValue(timeout.get("timeout"), 10000); int retryTimes = Math.max(1, intValue(timeout.get("retryTimes"), 1)); Exception lastError = null; for (int attempt = 0; attempt < retryTimes; attempt++) { try { return FlowGramNodeExecutionResult.builder() .outputs(executeRequest(fullUrl, method, headers, body, timeoutMs, context)) .build(); } catch (Exception ex) { lastError = ex; } } throw lastError == null ? new IllegalStateException("HTTP node execution failed") : lastError; } private Map executeRequest(String fullUrl, String method, Map headers, Map body, int timeoutMs, FlowGramNodeExecutionContext context) throws Exception { HttpURLConnection connection = (HttpURLConnection) new URL(fullUrl).openConnection(); connection.setRequestMethod(method); connection.setConnectTimeout(timeoutMs); connection.setReadTimeout(timeoutMs); connection.setUseCaches(false); connection.setDoInput(true); for (Map.Entry entry : headers.entrySet()) { if (isBlank(entry.getKey()) || entry.getValue() == null) { continue; } connection.setRequestProperty(entry.getKey(), String.valueOf(entry.getValue())); } String requestBody = buildRequestBody(body, context); if (requestBody != null && allowsRequestBody(method)) { connection.setDoOutput(true); if (isBlank(connection.getRequestProperty("Content-Type"))) { String bodyType = valueAsString(body.get("bodyType")); if ("JSON".equalsIgnoreCase(bodyType)) { connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); } else if ("raw-text".equalsIgnoreCase(bodyType)) { connection.setRequestProperty("Content-Type", "text/plain; charset=UTF-8"); } } byte[] payload = requestBody.getBytes(StandardCharsets.UTF_8); connection.setRequestProperty("Content-Length", String.valueOf(payload.length)); OutputStream outputStream = connection.getOutputStream(); try { outputStream.write(payload); } finally { outputStream.close(); } } int statusCode = connection.getResponseCode(); String responseBody = readBody(connection, statusCode); Map responseHeaders = new LinkedHashMap(); for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { if (entry.getKey() == null || entry.getValue() == null) { continue; } List values = entry.getValue(); if (values.size() == 1) { responseHeaders.put(entry.getKey(), values.get(0)); } else { responseHeaders.put(entry.getKey(), new ArrayList(values)); } } Map outputs = new LinkedHashMap(); outputs.put("statusCode", statusCode); outputs.put("body", responseBody); outputs.put("headers", responseHeaders); outputs.put("contentType", connection.getContentType()); return outputs; } private String buildRequestBody(Map body, FlowGramNodeExecutionContext context) { if (body == null || body.isEmpty()) { return null; } String bodyType = valueAsString(body.get("bodyType")); if (isBlank(bodyType) || "none".equalsIgnoreCase(bodyType)) { return null; } if ("JSON".equalsIgnoreCase(bodyType)) { Object jsonValue = valueResolver.resolve(body.get("json"), context); if (jsonValue == null) { return null; } return jsonValue instanceof String ? String.valueOf(jsonValue) : JSON.toJSONString(jsonValue); } if ("raw-text".equalsIgnoreCase(bodyType)) { Object rawText = valueResolver.resolve(body.get("rawText"), context); return rawText == null ? null : String.valueOf(rawText); } Object resolved = valueResolver.resolve(body, context); return resolved == null ? null : JSON.toJSONString(resolved); } private String readBody(HttpURLConnection connection, int statusCode) throws Exception { InputStream inputStream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream(); if (inputStream == null) { return null; } ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[2048]; try { int read; while ((read = inputStream.read(buffer)) >= 0) { outputStream.write(buffer, 0, read); } } finally { inputStream.close(); } return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); } private String appendQueryParams(String rawUrl, Map params) throws Exception { if (params == null || params.isEmpty()) { return rawUrl; } StringBuilder builder = new StringBuilder(rawUrl); builder.append(rawUrl.contains("?") ? '&' : '?'); boolean first = true; for (Map.Entry entry : params.entrySet()) { if (isBlank(entry.getKey()) || entry.getValue() == null) { continue; } if (!first) { builder.append('&'); } builder.append(encode(entry.getKey())) .append('=') .append(encode(String.valueOf(entry.getValue()))); first = false; } return builder.toString(); } private String encode(String value) throws Exception { return URLEncoder.encode(value, "UTF-8").replace("+", "%20"); } private boolean allowsRequestBody(String method) { return !"GET".equals(method) && !"HEAD".equals(method); } private Map mapValue(Object value) { if (!(value instanceof Map)) { return null; } @SuppressWarnings("unchecked") Map map = (Map) value; return map; } private Map safeMap(Map value) { return value == null ? new LinkedHashMap() : value; } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private int intValue(Object value, int defaultValue) { if (value == null) { return defaultValue; } if (value instanceof Number) { return ((Number) value).intValue(); } try { return Integer.parseInt(String.valueOf(value).trim()); } catch (Exception ex) { return defaultValue; } } private String firstNonBlank(String first, String fallback) { return isBlank(first) ? fallback : first; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramKnowledgeRetrieveNodeExecutor.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor; import io.github.lnyocly.ai4j.rag.DefaultRagService; import io.github.lnyocly.ai4j.rag.DenseRetriever; import io.github.lnyocly.ai4j.rag.RagCitation; import io.github.lnyocly.ai4j.rag.RagContextAssembler; import io.github.lnyocly.ai4j.rag.RagHit; import io.github.lnyocly.ai4j.rag.RagQuery; import io.github.lnyocly.ai4j.rag.RagResult; import io.github.lnyocly.ai4j.rag.RagService; import io.github.lnyocly.ai4j.rag.RagScoreDetail; import io.github.lnyocly.ai4j.rag.RagTrace; import io.github.lnyocly.ai4j.rag.Reranker; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.service.factory.AiServiceRegistry; import io.github.lnyocly.ai4j.vector.store.VectorStore; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class FlowGramKnowledgeRetrieveNodeExecutor implements FlowGramNodeExecutor { private final AiServiceRegistry aiServiceRegistry; private final VectorStore vectorStore; private final Reranker reranker; private final RagContextAssembler contextAssembler; public FlowGramKnowledgeRetrieveNodeExecutor(AiServiceRegistry aiServiceRegistry, VectorStore vectorStore, Reranker reranker, RagContextAssembler contextAssembler) { this.aiServiceRegistry = aiServiceRegistry; this.vectorStore = vectorStore; this.reranker = reranker; this.contextAssembler = contextAssembler; } @Override public String getType() { return "KNOWLEDGE"; } @Override public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception { Map inputs = context == null || context.getInputs() == null ? new LinkedHashMap() : context.getInputs(); String serviceId = requiredString(inputs, "serviceId"); String embeddingModel = requiredString(inputs, "embeddingModel"); String dataset = firstNonBlank(valueAsString(inputs.get("dataset")), valueAsString(inputs.get("namespace"))); if (dataset == null || dataset.trim().isEmpty()) { throw new IllegalArgumentException("Knowledge node requires dataset or namespace"); } String query = requiredString(inputs, "query"); int topK = intValue(inputs.get("topK"), 5); int finalTopK = intValue(inputs.get("finalTopK"), topK); String delimiter = valueAsString(inputs.get("delimiter")); if (delimiter == null) { delimiter = "\n\n"; } Map filter = mapValue(inputs.get("filter")); IEmbeddingService embeddingService = aiServiceRegistry.getEmbeddingService(serviceId); RagService ragService = new DefaultRagService( new DenseRetriever(embeddingService, vectorStore), reranker, contextAssembler ); RagResult ragResult = ragService.search(RagQuery.builder() .query(query) .embeddingModel(embeddingModel) .dataset(dataset) .topK(topK) .finalTopK(finalTopK) .filter(filter) .delimiter(delimiter) .build()); List> matches = mapHits(ragResult == null ? null : ragResult.getHits()); List> citations = mapCitations(ragResult == null ? null : ragResult.getCitations()); Map trace = mapTrace(ragResult == null ? null : ragResult.getTrace()); Map outputs = new LinkedHashMap(); outputs.put("matches", matches); outputs.put("hits", matches); outputs.put("context", ragResult == null ? "" : ragResult.getContext()); outputs.put("citations", citations); outputs.put("sources", citations); outputs.put("trace", trace); outputs.put("retrievedHits", trace.get("retrievedHits")); outputs.put("rerankedHits", trace.get("rerankedHits")); outputs.put("count", matches.size()); return FlowGramNodeExecutionResult.builder() .outputs(outputs) .build(); } private String requiredString(Map inputs, String key) { String value = valueAsString(inputs.get(key)); if (value == null || value.trim().isEmpty()) { throw new IllegalArgumentException("Knowledge node requires " + key); } return value.trim(); } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private int intValue(Object value, int defaultValue) { if (value == null) { return defaultValue; } if (value instanceof Number) { return ((Number) value).intValue(); } try { return Integer.parseInt(String.valueOf(value).trim()); } catch (Exception ex) { return defaultValue; } } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } @SuppressWarnings("unchecked") private Map mapValue(Object value) { if (value == null) { return null; } if (value instanceof Map) { return new LinkedHashMap((Map) value); } String text = valueAsString(value); if (text == null || text.trim().isEmpty()) { return null; } try { return JSON.parseObject(text, LinkedHashMap.class); } catch (Exception ignore) { return null; } } private List> mapHits(List hits) { if (hits == null || hits.isEmpty()) { return Collections.emptyList(); } List> results = new ArrayList>(); for (RagHit hit : hits) { if (hit == null) { continue; } Map item = new LinkedHashMap(); item.put("id", hit.getId()); item.put("score", hit.getScore()); item.put("rank", hit.getRank()); item.put("retrieverSource", hit.getRetrieverSource()); item.put("retrievalScore", hit.getRetrievalScore()); item.put("fusionScore", hit.getFusionScore()); item.put("rerankScore", hit.getRerankScore()); item.put("content", hit.getContent()); item.put("metadata", hit.getMetadata()); item.put("documentId", hit.getDocumentId()); item.put("sourceName", hit.getSourceName()); item.put("sourcePath", hit.getSourcePath()); item.put("sourceUri", hit.getSourceUri()); item.put("pageNumber", hit.getPageNumber()); item.put("sectionTitle", hit.getSectionTitle()); item.put("chunkIndex", hit.getChunkIndex()); item.put("scoreDetails", mapScoreDetails(hit.getScoreDetails())); results.add(item); } return results; } private List> mapCitations(List citations) { if (citations == null || citations.isEmpty()) { return Collections.emptyList(); } List> results = new ArrayList>(); for (RagCitation citation : citations) { if (citation == null) { continue; } Map item = new LinkedHashMap(); item.put("citationId", citation.getCitationId()); item.put("sourceName", citation.getSourceName()); item.put("sourcePath", citation.getSourcePath()); item.put("sourceUri", citation.getSourceUri()); item.put("pageNumber", citation.getPageNumber()); item.put("sectionTitle", citation.getSectionTitle()); item.put("snippet", citation.getSnippet()); results.add(item); } return results; } private Map mapTrace(RagTrace trace) { Map result = new LinkedHashMap(); if (trace == null) { result.put("retrievedHits", Collections.emptyList()); result.put("rerankedHits", Collections.emptyList()); return result; } result.put("retrievedHits", mapHits(trace.getRetrievedHits())); result.put("rerankedHits", mapHits(trace.getRerankedHits())); return result; } private List> mapScoreDetails(List details) { if (details == null || details.isEmpty()) { return Collections.emptyList(); } List> results = new ArrayList>(); for (RagScoreDetail detail : details) { if (detail == null) { continue; } Map item = new LinkedHashMap(); item.put("source", detail.getSource()); item.put("rank", detail.getRank()); item.put("retrievalScore", detail.getRetrievalScore()); item.put("fusionContribution", detail.getFusionContribution()); results.add(item); } return results; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramNodeValueResolver.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; class FlowGramNodeValueResolver { public Object resolve(Object value, FlowGramNodeExecutionContext context) { if (value == null) { return null; } if (value instanceof List) { return resolveList((List) value, context); } if (!(value instanceof Map)) { return copyValue(value); } Map valueMap = mapValue(value); if (valueMap == null) { return copyValue(value); } String type = normalizeType(valueAsString(valueMap.get("type"))); if ("REF".equals(type)) { return resolveReference(valueMap.get("content"), context); } if ("CONSTANT".equals(type)) { return copyValue(valueMap.get("content")); } if ("TEMPLATE".equals(type)) { Object content = valueMap.get("content"); if (content instanceof String) { return renderTemplate((String) content, context); } return resolve(content, context); } if ("EXPRESSION".equals(type)) { return evaluateExpression(valueAsString(valueMap.get("content")), context); } return resolveMap(valueMap, context); } public Map resolveMap(Map raw, FlowGramNodeExecutionContext context) { Map resolved = new LinkedHashMap(); if (raw == null) { return resolved; } for (Map.Entry entry : raw.entrySet()) { resolved.put(entry.getKey(), resolve(entry.getValue(), context)); } return resolved; } public List resolveList(List raw, FlowGramNodeExecutionContext context) { List resolved = new ArrayList(); if (raw == null) { return resolved; } for (Object item : raw) { resolved.add(resolve(item, context)); } return resolved; } public String renderTemplate(String template, FlowGramNodeExecutionContext context) { if (template == null) { return null; } String rendered = template; rendered = replaceTemplatePattern(rendered, "${", "}", context); rendered = replaceTemplatePattern(rendered, "{{", "}}", context); return rendered; } private String replaceTemplatePattern(String template, String prefix, String suffix, FlowGramNodeExecutionContext context) { String rendered = template; int start = rendered.indexOf(prefix); while (start >= 0) { int end = rendered.indexOf(suffix, start + prefix.length()); if (end < 0) { break; } String expression = rendered.substring(start + prefix.length(), end).trim(); Object value = resolvePathExpression(expression, context); rendered = rendered.substring(0, start) + (value == null ? "" : String.valueOf(value)) + rendered.substring(end + suffix.length()); start = rendered.indexOf(prefix, start); } return rendered; } private Object evaluateExpression(String expression, FlowGramNodeExecutionContext context) { if (isBlank(expression)) { return expression; } String trimmed = expression.trim(); if (trimmed.startsWith("${") && trimmed.endsWith("}")) { return resolvePathExpression(trimmed.substring(2, trimmed.length() - 1), context); } if (trimmed.startsWith("{{") && trimmed.endsWith("}}")) { return resolvePathExpression(trimmed.substring(2, trimmed.length() - 2), context); } if ("true".equalsIgnoreCase(trimmed) || "false".equalsIgnoreCase(trimmed)) { return Boolean.parseBoolean(trimmed); } Double number = valueAsDouble(trimmed); if (number != null) { return trimmed.contains(".") ? number : Integer.valueOf(number.intValue()); } return renderTemplate(trimmed, context); } private Object resolvePathExpression(String expression, FlowGramNodeExecutionContext context) { if (isBlank(expression)) { return null; } List path = new ArrayList(); for (String segment : expression.split("\\.")) { String trimmed = segment.trim(); if (!trimmed.isEmpty()) { path.add(trimmed); } } return resolveReference(path, context); } private Object resolveReference(Object content, FlowGramNodeExecutionContext context) { List path = objectList(content); if (path.isEmpty()) { return null; } Object current = resolveRootReference(path.get(0), context); for (int i = 1; i < path.size(); i++) { current = descend(current, path.get(i)); if (current == null) { return null; } } return current; } private Object resolveRootReference(Object segment, FlowGramNodeExecutionContext context) { String key = valueAsString(segment); if (isBlank(key)) { return null; } if ("locals".equals(key)) { return context == null ? null : context.getLocals(); } if (context != null && context.getLocals() != null && context.getLocals().containsKey(key)) { return context.getLocals().get(key); } if ("inputs".equals(key) || "taskInputs".equals(key) || "$inputs".equals(key)) { return context == null ? null : context.getTaskInputs(); } if (context != null && context.getInputs() != null && context.getInputs().containsKey(key)) { return context.getInputs().get(key); } return context == null ? null : safeMap(context.getNodeOutputs()).get(key); } private Object descend(Object current, Object segment) { if (current == null || segment == null) { return null; } if (current instanceof Map) { @SuppressWarnings("unchecked") Map map = (Map) current; return map.get(String.valueOf(segment)); } if (current instanceof List) { Integer index = valueAsInteger(segment); if (index == null) { return null; } List list = (List) current; return index >= 0 && index < list.size() ? list.get(index) : null; } return null; } private List objectList(Object value) { List result = new ArrayList(); if (value instanceof List) { result.addAll((List) value); return result; } String text = valueAsString(value); if (!isBlank(text)) { for (String segment : text.split("\\.")) { String trimmed = segment.trim(); if (!trimmed.isEmpty()) { result.add(trimmed); } } } return result; } private Map mapValue(Object value) { if (!(value instanceof Map)) { return null; } @SuppressWarnings("unchecked") Map cast = (Map) value; return cast; } private Map safeMap(Map value) { return value == null ? new LinkedHashMap() : value; } private Object copyValue(Object value) { if (value instanceof Map) { Map copy = new LinkedHashMap(); @SuppressWarnings("unchecked") Map source = (Map) value; for (Map.Entry entry : source.entrySet()) { copy.put(entry.getKey(), copyValue(entry.getValue())); } return copy; } if (value instanceof List) { List copy = new ArrayList(); for (Object item : (List) value) { copy.add(copyValue(item)); } return copy; } return value; } private String normalizeType(String type) { return type == null ? "" : type.trim().toUpperCase(); } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private Integer valueAsInteger(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).intValue(); } String text = String.valueOf(value).trim(); if (text.isEmpty()) { return null; } try { return Integer.parseInt(text); } catch (NumberFormatException ex) { return null; } } private Double valueAsDouble(Object value) { if (value == null) { return null; } if (value instanceof Number) { return ((Number) value).doubleValue(); } String text = String.valueOf(value).trim(); if (text.isEmpty()) { return null; } try { return Double.parseDouble(text); } catch (NumberFormatException ex) { return null; } } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramToolNodeExecutor.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.agent.tool.ToolUtilExecutor; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; public class FlowGramToolNodeExecutor implements FlowGramNodeExecutor { @Override public String getType() { return "TOOL"; } @Override public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception { Map inputs = context == null || context.getInputs() == null ? new LinkedHashMap() : new LinkedHashMap(context.getInputs()); String toolName = trimToNull(valueAsString(inputs.remove("toolName"))); if (toolName == null) { throw new IllegalArgumentException("Tool node requires toolName"); } Object argumentsValue = inputs.remove("argumentsJson"); String argumentsJson; if (argumentsValue == null) { argumentsJson = JSON.toJSONString(inputs); } else if (argumentsValue instanceof String) { String text = ((String) argumentsValue).trim(); argumentsJson = text.isEmpty() ? "{}" : text; } else { argumentsJson = JSON.toJSONString(argumentsValue); } String rawOutput = tryExecuteBuiltinDemoTool(toolName, argumentsJson); if (rawOutput == null) { ToolExecutor executor = new ToolUtilExecutor(Collections.singleton(toolName)); rawOutput = executor.execute(AgentToolCall.builder() .name(toolName) .arguments(argumentsJson) .type("function") .callId(context == null ? null : context.getTaskId()) .build()); } Map outputs = new LinkedHashMap(); outputs.put("toolName", toolName); outputs.put("rawOutput", rawOutput); Object parsed = tryParse(rawOutput); if (parsed instanceof Map) { @SuppressWarnings("unchecked") Map parsedMap = (Map) parsed; outputs.put("data", parsedMap); if (!parsedMap.containsKey("result")) { outputs.put("result", rawOutput); } outputs.putAll(parsedMap); } else { outputs.put("result", parsed == null ? rawOutput : parsed); } return FlowGramNodeExecutionResult.builder() .outputs(outputs) .build(); } private String tryExecuteBuiltinDemoTool(String toolName, String argumentsJson) { if ("queryTrainInfo".equals(toolName)) { Integer type = extractIntegerArgument(argumentsJson, "type"); return type != null && type.intValue() > 35 ? "天气情况正常,允许发车" : "天气情况较差,不允许发车"; } return null; } private Object tryParse(String raw) { if (raw == null) { return null; } try { return JSON.parse(raw); } catch (Exception ex) { return raw; } } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private Integer extractIntegerArgument(String argumentsJson, String key) { try { Map map = JSON.parseObject(argumentsJson); if (map == null) { return null; } Object value = map.get(key); if (value instanceof Number) { return ((Number) value).intValue(); } if (value == null) { return null; } return Integer.valueOf(String.valueOf(value)); } catch (Exception ex) { return null; } } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramVariableNodeExecutor.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class FlowGramVariableNodeExecutor implements FlowGramNodeExecutor { private final FlowGramNodeValueResolver valueResolver = new FlowGramNodeValueResolver(); @Override public String getType() { return "VARIABLE"; } @Override public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) { Map outputs = new LinkedHashMap(); List assigns = valueResolver.resolveList(listValue(dataValue(context, "assign")), context); for (Object assignObject : assigns) { Map assign = mapValue(assignObject); if (assign == null) { continue; } String left = valueAsString(assign.get("left")); if (isBlank(left)) { continue; } outputs.put(left, assign.get("right")); } if (outputs.isEmpty() && context != null && context.getInputs() != null) { outputs.putAll(context.getInputs()); } return FlowGramNodeExecutionResult.builder() .outputs(outputs) .build(); } private Object dataValue(FlowGramNodeExecutionContext context, String key) { Map data = context == null || context.getNode() == null ? null : context.getNode().getData(); return data == null ? null : data.get(key); } private List listValue(Object value) { if (value instanceof List) { @SuppressWarnings("unchecked") List list = (List) value; return list; } return null; } private Map mapValue(Object value) { if (!(value instanceof Map)) { return null; } @SuppressWarnings("unchecked") Map map = (Map) value; return map; } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/DefaultFlowGramAccessChecker.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramStoredTask; public class DefaultFlowGramAccessChecker implements FlowGramAccessChecker { @Override public boolean isAllowed(FlowGramAction action, FlowGramCaller caller, FlowGramStoredTask task) { return true; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/DefaultFlowGramCallerResolver.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; import io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties; import javax.servlet.http.HttpServletRequest; import java.util.Collections; public class DefaultFlowGramCallerResolver implements FlowGramCallerResolver { private static final String DEFAULT_TENANT_HEADER = "X-Tenant-Id"; private final FlowGramProperties properties; public DefaultFlowGramCallerResolver(FlowGramProperties properties) { this.properties = properties; } @Override public FlowGramCaller resolve(HttpServletRequest request) { if (request == null || properties == null || properties.getAuth() == null || !properties.getAuth().isEnabled()) { return anonymousCaller(); } String callerId = trimToNull(request.getHeader(properties.getAuth().getHeaderName())); String tenantId = trimToNull(request.getHeader(DEFAULT_TENANT_HEADER)); if (callerId == null) { return anonymousCaller(); } return FlowGramCaller.builder() .callerId(callerId) .tenantId(tenantId) .anonymous(false) .attributes(Collections.emptyMap()) .build(); } private FlowGramCaller anonymousCaller() { return FlowGramCaller.builder() .callerId("anonymous") .anonymous(true) .attributes(Collections.emptyMap()) .build(); } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/DefaultFlowGramTaskOwnershipStrategy.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; import java.time.Duration; public class DefaultFlowGramTaskOwnershipStrategy implements FlowGramTaskOwnershipStrategy { private final Duration retention; public DefaultFlowGramTaskOwnershipStrategy(Duration retention) { this.retention = retention; } @Override public FlowGramTaskOwnership createOwnership(String taskId, FlowGramCaller caller) { long createdAt = System.currentTimeMillis(); long expiresAt = createdAt + Math.max(retention == null ? 0L : retention.toMillis(), 0L); return FlowGramTaskOwnership.builder() .creatorId(caller == null ? null : caller.getCallerId()) .tenantId(caller == null ? null : caller.getTenantId()) .createdAt(createdAt) .expiresAt(expiresAt) .build(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramAccessChecker.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramStoredTask; public interface FlowGramAccessChecker { boolean isAllowed(FlowGramAction action, FlowGramCaller caller, FlowGramStoredTask task); } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramAction.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; public enum FlowGramAction { RUN, VALIDATE, REPORT, RESULT, CANCEL } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramCaller.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramCaller { private String callerId; private String tenantId; private boolean anonymous; private Map attributes; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramCallerResolver.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; import javax.servlet.http.HttpServletRequest; public interface FlowGramCallerResolver { FlowGramCaller resolve(HttpServletRequest request); } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramTaskOwnership.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramTaskOwnership { private String creatorId; private String tenantId; private Long createdAt; private Long expiresAt; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramTaskOwnershipStrategy.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.security; public interface FlowGramTaskOwnershipStrategy { FlowGramTaskOwnership createOwnership(String taskId, FlowGramCaller caller); } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramRuntimeFacade.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeService; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskCancelOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput; import io.github.lnyocly.ai4j.flowgram.springboot.adapter.FlowGramProtocolAdapter; import io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskCancelResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskResultResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView; import io.github.lnyocly.ai4j.flowgram.springboot.exception.FlowGramAccessDeniedException; import io.github.lnyocly.ai4j.flowgram.springboot.exception.FlowGramTaskNotFoundException; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAccessChecker; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAction; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramCaller; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramCallerResolver; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramTaskOwnership; import io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramTaskOwnershipStrategy; import javax.servlet.http.HttpServletRequest; import java.util.Collections; public class FlowGramRuntimeFacade { private final FlowGramRuntimeService runtimeService; private final FlowGramProtocolAdapter protocolAdapter; private final FlowGramTaskStore taskStore; private final FlowGramCallerResolver callerResolver; private final FlowGramAccessChecker accessChecker; private final FlowGramTaskOwnershipStrategy ownershipStrategy; private final FlowGramProperties properties; private final FlowGramRuntimeTraceCollector traceCollector; private final FlowGramTraceResponseEnricher traceResponseEnricher = new FlowGramTraceResponseEnricher(); public FlowGramRuntimeFacade(FlowGramRuntimeService runtimeService, FlowGramProtocolAdapter protocolAdapter, FlowGramTaskStore taskStore, FlowGramCallerResolver callerResolver, FlowGramAccessChecker accessChecker, FlowGramTaskOwnershipStrategy ownershipStrategy, FlowGramProperties properties, FlowGramRuntimeTraceCollector traceCollector) { this.runtimeService = runtimeService; this.protocolAdapter = protocolAdapter; this.taskStore = taskStore; this.callerResolver = callerResolver; this.accessChecker = accessChecker; this.ownershipStrategy = ownershipStrategy; this.properties = properties; this.traceCollector = traceCollector; } public FlowGramTaskRunResponse run(FlowGramTaskRunRequest request, HttpServletRequest servletRequest) { FlowGramCaller caller = resolveCaller(servletRequest); ensureAllowed(FlowGramAction.RUN, caller, null); FlowGramTaskRunOutput output = runtimeService.runTask(protocolAdapter.toTaskRunInput(request)); FlowGramTaskOwnership ownership = ownershipStrategy.createOwnership(output.getTaskID(), caller); taskStore.save(FlowGramStoredTask.builder() .taskId(output.getTaskID()) .creatorId(ownership == null ? null : ownership.getCreatorId()) .tenantId(ownership == null ? null : ownership.getTenantId()) .createdAt(ownership == null ? null : ownership.getCreatedAt()) .expiresAt(ownership == null ? null : ownership.getExpiresAt()) .status("pending") .terminated(false) .resultSnapshot(Collections.emptyMap()) .build()); return protocolAdapter.toRunResponse(output); } public FlowGramTaskValidateResponse validate(FlowGramTaskValidateRequest request, HttpServletRequest servletRequest) { FlowGramCaller caller = resolveCaller(servletRequest); ensureAllowed(FlowGramAction.VALIDATE, caller, null); FlowGramTaskValidateOutput output = runtimeService.validateTask(protocolAdapter.toTaskRunInput(request)); return protocolAdapter.toValidateResponse(output); } public FlowGramTaskReportResponse report(String taskId, HttpServletRequest servletRequest) { FlowGramTaskReportOutput report = runtimeService.getTaskReport(taskId); if (report == null) { throw new FlowGramTaskNotFoundException(taskId); } FlowGramStoredTask task = loadTask(taskId); FlowGramCaller caller = resolveCaller(servletRequest); ensureAllowed(FlowGramAction.REPORT, caller, task); if (report.getWorkflow() != null) { taskStore.updateState(taskId, report.getWorkflow().getStatus(), report.getWorkflow().isTerminated(), report.getWorkflow().getError(), null); } return traceResponseEnricher.enrichReportResponse(protocolAdapter.toReportResponse(taskId, report, properties == null || properties.isReportNodeDetails(), resolveTrace(taskId, report))); } public FlowGramTaskResultResponse result(String taskId, HttpServletRequest servletRequest) { FlowGramTaskResultOutput result = runtimeService.getTaskResult(taskId); if (result == null) { throw new FlowGramTaskNotFoundException(taskId); } FlowGramStoredTask task = loadTask(taskId); FlowGramCaller caller = resolveCaller(servletRequest); ensureAllowed(FlowGramAction.RESULT, caller, task); taskStore.updateState(taskId, result.getStatus(), result.isTerminated(), result.getError(), result.getResult()); FlowGramTaskReportOutput report = runtimeService.getTaskReport(taskId); FlowGramTraceView trace = resolveTrace(taskId, report); FlowGramTaskReportResponse reportResponse = traceResponseEnricher.enrichReportResponse( protocolAdapter.toReportResponse(taskId, report, true, trace)); return protocolAdapter.toResultResponse(taskId, result, reportResponse == null ? trace : reportResponse.getTrace()); } public FlowGramTaskCancelResponse cancel(String taskId, HttpServletRequest servletRequest) { FlowGramStoredTask task = loadTask(taskId); FlowGramCaller caller = resolveCaller(servletRequest); ensureAllowed(FlowGramAction.CANCEL, caller, task); FlowGramTaskCancelOutput output = runtimeService.cancelTask(taskId); if (output == null || !output.isSuccess()) { throw new FlowGramTaskNotFoundException(taskId); } return protocolAdapter.toCancelResponse(true); } private FlowGramCaller resolveCaller(HttpServletRequest servletRequest) { FlowGramCaller caller = callerResolver.resolve(servletRequest); return caller == null ? FlowGramCaller.builder().callerId("anonymous").anonymous(true).build() : caller; } private FlowGramStoredTask loadTask(String taskId) { FlowGramStoredTask task = taskStore.find(taskId); if (task != null) { return task; } return FlowGramStoredTask.builder().taskId(taskId).build(); } private void ensureAllowed(FlowGramAction action, FlowGramCaller caller, FlowGramStoredTask task) { if (!accessChecker.isAllowed(action, caller, task)) { throw new FlowGramAccessDeniedException(action); } } private FlowGramTraceView resolveTrace(String taskId, FlowGramTaskReportOutput report) { if (traceCollector == null || (properties != null && !properties.isTraceEnabled())) { return null; } return traceCollector.getTrace(taskId, report); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramRuntimeTraceCollector.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeEvent; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeListener; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class FlowGramRuntimeTraceCollector implements FlowGramRuntimeListener { private final ConcurrentMap snapshots = new ConcurrentHashMap(); @Override public void onEvent(FlowGramRuntimeEvent event) { if (event == null || isBlank(event.getTaskId())) { return; } TraceSnapshot snapshot = snapshots.computeIfAbsent(event.getTaskId(), TraceSnapshot::new); snapshot.onEvent(event); } public FlowGramTraceView getTrace(String taskId) { TraceSnapshot snapshot = taskId == null ? null : snapshots.get(taskId); return snapshot == null ? null : snapshot.toView(); } public FlowGramTraceView getTrace(String taskId, FlowGramTaskReportOutput report) { TraceSnapshot snapshot = taskId == null ? null : snapshots.get(taskId); if (snapshot == null) { return null; } snapshot.mergeReport(report); return snapshot.toView(); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static final class TraceSnapshot { private final String taskId; private final List events = new ArrayList(); private final Map nodes = new LinkedHashMap(); private String status; private Long startedAt; private Long endedAt; private TraceSnapshot(String taskId) { this.taskId = taskId; } private synchronized void onEvent(FlowGramRuntimeEvent event) { events.add(FlowGramTraceView.EventView.builder() .type(event.getType() == null ? null : event.getType().name()) .timestamp(Long.valueOf(event.getTimestamp())) .nodeId(event.getNodeId()) .status(event.getStatus()) .error(event.getError()) .build()); if (event.getType() == null) { return; } switch (event.getType()) { case TASK_STARTED: startedAt = Long.valueOf(event.getTimestamp()); status = event.getStatus(); break; case TASK_FINISHED: case TASK_FAILED: case TASK_CANCELED: if (startedAt == null) { startedAt = Long.valueOf(event.getTimestamp()); } endedAt = Long.valueOf(event.getTimestamp()); status = event.getStatus(); break; case NODE_STARTED: case NODE_FINISHED: case NODE_FAILED: case NODE_CANCELED: updateNode(event); break; default: break; } } private void updateNode(FlowGramRuntimeEvent event) { if (event.getNodeId() == null) { return; } MutableNodeTrace node = nodes.get(event.getNodeId()); if (node == null) { node = new MutableNodeTrace(event.getNodeId()); nodes.put(event.getNodeId(), node); } node.eventCount += 1; node.status = event.getStatus(); if (event.getType() == FlowGramRuntimeEvent.Type.NODE_STARTED) { node.startedAt = Long.valueOf(event.getTimestamp()); return; } node.endedAt = Long.valueOf(event.getTimestamp()); node.error = event.getError(); node.terminated = true; if (node.startedAt == null) { node.startedAt = Long.valueOf(event.getTimestamp()); } } private synchronized void mergeReport(FlowGramTaskReportOutput report) { if (report == null) { return; } if (report.getWorkflow() != null) { if (report.getWorkflow().getStartTime() != null) { startedAt = report.getWorkflow().getStartTime(); } if (report.getWorkflow().getEndTime() != null) { endedAt = report.getWorkflow().getEndTime(); } if (report.getWorkflow().getStatus() != null) { status = report.getWorkflow().getStatus(); } } if (report.getNodes() == null) { return; } for (Map.Entry entry : report.getNodes().entrySet()) { String nodeId = entry.getKey(); if (nodeId == null) { continue; } MutableNodeTrace node = nodes.get(nodeId); if (node == null) { node = new MutableNodeTrace(nodeId); nodes.put(nodeId, node); } FlowGramTaskReportOutput.NodeStatus statusView = entry.getValue(); if (statusView == null) { continue; } node.status = statusView.getStatus(); node.terminated = statusView.isTerminated(); node.startedAt = firstNonNull(statusView.getStartTime(), node.startedAt); node.endedAt = firstNonNull(statusView.getEndTime(), node.endedAt); node.error = firstNonNull(statusView.getError(), node.error); NodeMetrics metrics = extractMetrics(statusView.getInputs(), statusView.getOutputs()); if (metrics != null) { node.model = firstNonNull(metrics.model, node.model); node.promptTokens = firstNonNull(metrics.promptTokens, node.promptTokens); node.completionTokens = firstNonNull(metrics.completionTokens, node.completionTokens); node.totalTokens = firstNonNull(metrics.totalTokens, node.totalTokens); node.inputCost = firstNonNull(metrics.inputCost, node.inputCost); node.outputCost = firstNonNull(metrics.outputCost, node.outputCost); node.totalCost = firstNonNull(metrics.totalCost, node.totalCost); node.currency = firstNonNull(metrics.currency, node.currency); } } } private synchronized FlowGramTraceView toView() { Map nodeViews = new LinkedHashMap(); int terminatedNodeCount = 0; int successNodeCount = 0; int failedNodeCount = 0; int llmNodeCount = 0; Long promptTokens = null; Long completionTokens = null; Long totalTokens = null; Double inputCost = null; Double outputCost = null; Double totalCost = null; String currency = null; for (Map.Entry entry : nodes.entrySet()) { MutableNodeTrace value = entry.getValue(); Long durationMillis = duration(value.startedAt, value.endedAt); FlowGramTraceView.MetricsView metricsView = buildMetricsView(value); nodeViews.put(entry.getKey(), FlowGramTraceView.NodeView.builder() .nodeId(value.nodeId) .status(value.status) .terminated(value.terminated) .startedAt(value.startedAt) .endedAt(value.endedAt) .durationMillis(durationMillis) .error(value.error) .eventCount(Integer.valueOf(value.eventCount)) .model(value.model) .metrics(metricsView) .build()); if (value.terminated) { terminatedNodeCount += 1; } if ("success".equalsIgnoreCase(value.status)) { successNodeCount += 1; } if ("failed".equalsIgnoreCase(value.status) || "error".equalsIgnoreCase(value.status)) { failedNodeCount += 1; } if (hasUsageMetrics(value)) { llmNodeCount += 1; promptTokens = sum(promptTokens, value.promptTokens); completionTokens = sum(completionTokens, value.completionTokens); totalTokens = sum(totalTokens, value.totalTokens); inputCost = sum(inputCost, value.inputCost); outputCost = sum(outputCost, value.outputCost); totalCost = sum(totalCost, value.totalCost); if (currency == null) { currency = value.currency; } } } return FlowGramTraceView.builder() .taskId(taskId) .status(status) .startedAt(startedAt) .endedAt(endedAt) .summary(FlowGramTraceView.SummaryView.builder() .durationMillis(duration(startedAt, endedAt)) .eventCount(Integer.valueOf(events.size())) .nodeCount(Integer.valueOf(nodeViews.size())) .terminatedNodeCount(Integer.valueOf(terminatedNodeCount)) .successNodeCount(Integer.valueOf(successNodeCount)) .failedNodeCount(Integer.valueOf(failedNodeCount)) .llmNodeCount(Integer.valueOf(llmNodeCount)) .metrics(FlowGramTraceView.MetricsView.builder() .promptTokens(promptTokens) .completionTokens(completionTokens) .totalTokens(totalTokens) .inputCost(inputCost) .outputCost(outputCost) .totalCost(totalCost) .currency(currency) .build()) .build()) .events(Collections.unmodifiableList(new ArrayList(events))) .nodes(Collections.unmodifiableMap(nodeViews)) .build(); } private FlowGramTraceView.MetricsView buildMetricsView(MutableNodeTrace value) { if (value == null || !hasUsageMetrics(value) && value.totalCost == null && value.inputCost == null && value.outputCost == null) { return null; } return FlowGramTraceView.MetricsView.builder() .promptTokens(value.promptTokens) .completionTokens(value.completionTokens) .totalTokens(value.totalTokens) .inputCost(value.inputCost) .outputCost(value.outputCost) .totalCost(value.totalCost) .currency(value.currency) .build(); } private boolean hasUsageMetrics(MutableNodeTrace value) { return value != null && (value.promptTokens != null || value.completionTokens != null || value.totalTokens != null || value.model != null); } private NodeMetrics extractMetrics(Map inputs, Map outputs) { if ((inputs == null || inputs.isEmpty()) && (outputs == null || outputs.isEmpty())) { return null; } Object safeInputs = inputs == null ? Collections.emptyMap() : inputs; Object safeOutputs = outputs == null ? Collections.emptyMap() : outputs; Object metrics = propertyValue(safeOutputs, "metrics"); Object rawResponse = propertyValue(safeOutputs, "rawResponse"); Object usage = propertyValue(rawResponse, "usage"); return new NodeMetrics( firstNonBlank( stringValue(metrics, "model"), stringValue(rawResponse, "model"), stringValue(safeInputs, "model"), stringValue(safeInputs, "modelName")), longObject(firstNonNull(value(metrics, "promptTokens", "prompt_tokens"), value(usage, "promptTokens", "prompt_tokens", "input"))), longObject(firstNonNull(value(metrics, "completionTokens", "completion_tokens"), value(usage, "completionTokens", "completion_tokens", "output"))), longObject(firstNonNull(value(metrics, "totalTokens", "total_tokens"), value(usage, "totalTokens", "total_tokens", "total"))), doubleObject(value(metrics, "inputCost", "input_cost")), doubleObject(value(metrics, "outputCost", "output_cost")), doubleObject(value(metrics, "totalCost", "total_cost")), firstNonBlank(stringValue(metrics, "currency")) ); } } private static final class MutableNodeTrace { private final String nodeId; private String status; private boolean terminated; private Long startedAt; private Long endedAt; private String error; private int eventCount; private String model; private Long promptTokens; private Long completionTokens; private Long totalTokens; private Double inputCost; private Double outputCost; private Double totalCost; private String currency; private MutableNodeTrace(String nodeId) { this.nodeId = nodeId; } } private static final class NodeMetrics { private final String model; private final Long promptTokens; private final Long completionTokens; private final Long totalTokens; private final Double inputCost; private final Double outputCost; private final Double totalCost; private final String currency; private NodeMetrics(String model, Long promptTokens, Long completionTokens, Long totalTokens, Double inputCost, Double outputCost, Double totalCost, String currency) { this.model = model; this.promptTokens = promptTokens; this.completionTokens = completionTokens; this.totalTokens = totalTokens; this.inputCost = inputCost; this.outputCost = outputCost; this.totalCost = totalCost; this.currency = currency; } } private static Long duration(Long startedAt, Long endedAt) { if (startedAt == null) { return null; } long duration = Math.max((endedAt == null ? startedAt.longValue() : endedAt.longValue()) - startedAt.longValue(), 0L); return Long.valueOf(duration); } private static Object value(Object source, String... keys) { if (source == null || keys == null) { return null; } for (String key : keys) { Object value = propertyValue(source, key); if (value != null) { return value; } } return null; } private static String stringValue(Object source, String key) { Object value = source == null || key == null ? null : propertyValue(source, key); return value == null ? null : String.valueOf(value); } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } private static T firstNonNull(T left, T right) { return left != null ? left : right; } private static Object firstNonNull(Object... values) { if (values == null) { return null; } for (Object value : values) { if (value != null) { return value; } } return null; } private static Long longObject(Object value) { if (value == null) { return null; } if (value instanceof Number) { return Long.valueOf(((Number) value).longValue()); } try { return Long.valueOf(Long.parseLong(String.valueOf(value))); } catch (NumberFormatException ex) { return null; } } private static Double doubleObject(Object value) { if (value == null) { return null; } if (value instanceof Number) { return Double.valueOf(((Number) value).doubleValue()); } try { return Double.valueOf(Double.parseDouble(String.valueOf(value))); } catch (NumberFormatException ex) { return null; } } private static Long sum(Long left, Long right) { if (left == null) { return right; } if (right == null) { return left; } return Long.valueOf(left.longValue() + right.longValue()); } private static Double sum(Double left, Double right) { if (left == null) { return right; } if (right == null) { return left; } return Double.valueOf(left.doubleValue() + right.doubleValue()); } @SuppressWarnings("unchecked") private static Object propertyValue(Object source, String name) { if (source == null || name == null || name.trim().isEmpty()) { return null; } Object normalized = normalizeTree(source); if (normalized instanceof Map) { return ((Map) normalized).get(name); } Object value = invokeAccessor(normalized, "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1)); if (value != null) { return value; } value = invokeAccessor(normalized, "is" + Character.toUpperCase(name.charAt(0)) + name.substring(1)); if (value != null) { return value; } return fieldValue(normalized, name); } private static Object normalizedSource(Object source) { if (source == null || source instanceof Map || source instanceof List || source instanceof String || source instanceof Number || source instanceof Boolean) { return source; } try { return JSON.parseObject(JSON.toJSONString(source)); } catch (RuntimeException ignored) { return source; } } @SuppressWarnings("unchecked") private static Object normalizeTree(Object source) { Object normalized = normalizedSource(source); if (normalized == null || normalized instanceof String || normalized instanceof Number || normalized instanceof Boolean) { return normalized; } if (normalized instanceof Map) { Map copy = new LinkedHashMap(); Map sourceMap = (Map) normalized; for (Map.Entry entry : sourceMap.entrySet()) { copy.put(String.valueOf(entry.getKey()), normalizeTree(entry.getValue())); } return copy; } if (normalized instanceof List) { List copy = new ArrayList(); for (Object item : (List) normalized) { copy.add(normalizeTree(item)); } return copy; } return normalized; } private static Object invokeAccessor(Object source, String methodName) { try { Method method = source.getClass().getMethod(methodName); method.setAccessible(true); return method.invoke(source); } catch (Exception ignored) { // fall through } Class type = source.getClass(); while (type != null && type != Object.class) { try { Method method = type.getDeclaredMethod(methodName); method.setAccessible(true); return method.invoke(source); } catch (Exception ignored) { type = type.getSuperclass(); } } return null; } private static Object fieldValue(Object source, String name) { Class type = source.getClass(); while (type != null && type != Object.class) { try { Field field = type.getDeclaredField(name); field.setAccessible(true); return field.get(source); } catch (Exception ignored) { type = type.getSuperclass(); } } return null; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramStoredTask.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Map; @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class FlowGramStoredTask { private String taskId; private String creatorId; private String tenantId; private Long createdAt; private Long expiresAt; private String status; private Boolean terminated; private String error; private Map resultSnapshot; } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramTaskStore.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import java.util.Map; public interface FlowGramTaskStore { void save(FlowGramStoredTask task); FlowGramStoredTask find(String taskId); void updateState(String taskId, String status, Boolean terminated, String error, Map resultSnapshot); } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramTraceResponseEnricher.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; class FlowGramTraceResponseEnricher { FlowGramTaskReportResponse enrichReportResponse(FlowGramTaskReportResponse response) { if (response == null) { return null; } Map nodes = enrichNodes(response.getNodes()); return response.toBuilder() .nodes(nodes) .trace(enrichTrace(response.getTrace(), nodes)) .build(); } FlowGramTraceView enrichTrace(FlowGramTraceView trace, Map nodes) { if (trace == null || nodes == null || nodes.isEmpty()) { return trace; } Map sourceNodes = trace.getNodes() == null ? new LinkedHashMap() : trace.getNodes(); Map mergedNodes = new LinkedHashMap(); Long promptTokens = null; Long completionTokens = null; Long totalTokens = null; Double inputCost = null; Double outputCost = null; Double totalCost = null; String currency = trace.getSummary() == null || trace.getSummary().getMetrics() == null ? null : trace.getSummary().getMetrics().getCurrency(); int llmNodeCount = 0; for (Map.Entry entry : sourceNodes.entrySet()) { String nodeId = entry.getKey(); FlowGramTraceView.NodeView source = entry.getValue(); NodeMetrics nodeMetrics = extractNodeMetrics(nodes.get(nodeId)); FlowGramTraceView.MetricsView mergedMetrics = mergeMetrics(source == null ? null : source.getMetrics(), nodeMetrics); String model = firstNonBlank(source == null ? null : source.getModel(), nodeMetrics == null ? null : nodeMetrics.model); FlowGramTraceView.NodeView merged = source == null ? FlowGramTraceView.NodeView.builder().nodeId(nodeId).model(model).metrics(mergedMetrics).build() : source.toBuilder().model(model).metrics(mergedMetrics).build(); mergedNodes.put(nodeId, merged); if (model != null || hasMetrics(mergedMetrics)) { llmNodeCount += 1; promptTokens = sum(promptTokens, mergedMetrics == null ? null : mergedMetrics.getPromptTokens()); completionTokens = sum(completionTokens, mergedMetrics == null ? null : mergedMetrics.getCompletionTokens()); totalTokens = sum(totalTokens, mergedMetrics == null ? null : mergedMetrics.getTotalTokens()); inputCost = sum(inputCost, mergedMetrics == null ? null : mergedMetrics.getInputCost()); outputCost = sum(outputCost, mergedMetrics == null ? null : mergedMetrics.getOutputCost()); totalCost = sum(totalCost, mergedMetrics == null ? null : mergedMetrics.getTotalCost()); if (currency == null && mergedMetrics != null) { currency = mergedMetrics.getCurrency(); } } } FlowGramTraceView.SummaryView summary = trace.getSummary() == null ? FlowGramTraceView.SummaryView.builder().build() : trace.getSummary().toBuilder().build(); FlowGramTraceView.MetricsView existingSummaryMetrics = summary.getMetrics(); FlowGramTraceView.MetricsView summaryMetrics = (existingSummaryMetrics == null ? FlowGramTraceView.MetricsView.builder() : existingSummaryMetrics.toBuilder()) .promptTokens(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getPromptTokens(), promptTokens)) .completionTokens(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getCompletionTokens(), completionTokens)) .totalTokens(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getTotalTokens(), totalTokens)) .inputCost(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getInputCost(), inputCost)) .outputCost(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getOutputCost(), outputCost)) .totalCost(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getTotalCost(), totalCost)) .currency(firstNonBlank(existingSummaryMetrics == null ? null : existingSummaryMetrics.getCurrency(), currency)) .build(); return trace.toBuilder() .nodes(mergedNodes) .summary(summary.toBuilder() .llmNodeCount(summary.getLlmNodeCount() == null || summary.getLlmNodeCount().intValue() <= 0 ? Integer.valueOf(llmNodeCount) : summary.getLlmNodeCount()) .metrics(summaryMetrics) .build()) .build(); } private Map enrichNodes( Map nodes) { if (nodes == null || nodes.isEmpty()) { return nodes; } Map enriched = new LinkedHashMap(); for (Map.Entry entry : nodes.entrySet()) { FlowGramTaskReportResponse.NodeStatus node = entry.getValue(); if (node == null) { enriched.put(entry.getKey(), null); continue; } NodeMetrics metrics = extractNodeMetrics(node); enriched.put(entry.getKey(), node.toBuilder() .inputs(normalizeMap(node.getInputs())) .outputs(mergeOutputs(node.getOutputs(), metrics)) .build()); } return enriched; } private Map mergeOutputs(Map outputs, NodeMetrics metrics) { Map mergedOutputs = normalizeMap(outputs); if (metrics == null || !metrics.hasUsageLikeContent()) { return mergedOutputs; } Map metricMap = normalizeMap(mergedOutputs.get("metrics")); if (metricMap.isEmpty() && metrics.model == null && metrics.promptTokens == null && metrics.completionTokens == null && metrics.totalTokens == null && metrics.inputCost == null && metrics.outputCost == null && metrics.totalCost == null && metrics.currency == null) { return mergedOutputs; } putIfAbsent(metricMap, "model", metrics.model); putIfAbsent(metricMap, "promptTokens", metrics.promptTokens); putIfAbsent(metricMap, "completionTokens", metrics.completionTokens); putIfAbsent(metricMap, "totalTokens", metrics.totalTokens); putIfAbsent(metricMap, "inputCost", metrics.inputCost); putIfAbsent(metricMap, "outputCost", metrics.outputCost); putIfAbsent(metricMap, "totalCost", metrics.totalCost); putIfAbsent(metricMap, "currency", metrics.currency); mergedOutputs.put("metrics", metricMap); return mergedOutputs; } private FlowGramTraceView.MetricsView mergeMetrics(FlowGramTraceView.MetricsView existing, NodeMetrics metrics) { if ((existing == null || !hasMetrics(existing)) && (metrics == null || !metrics.hasUsageLikeContent())) { return existing; } FlowGramTraceView.MetricsView.MetricsViewBuilder builder = existing == null ? FlowGramTraceView.MetricsView.builder() : existing.toBuilder(); return builder .promptTokens(firstNonNull(existing == null ? null : existing.getPromptTokens(), metrics == null ? null : metrics.promptTokens)) .completionTokens(firstNonNull(existing == null ? null : existing.getCompletionTokens(), metrics == null ? null : metrics.completionTokens)) .totalTokens(firstNonNull(existing == null ? null : existing.getTotalTokens(), metrics == null ? null : metrics.totalTokens)) .inputCost(firstNonNull(existing == null ? null : existing.getInputCost(), metrics == null ? null : metrics.inputCost)) .outputCost(firstNonNull(existing == null ? null : existing.getOutputCost(), metrics == null ? null : metrics.outputCost)) .totalCost(firstNonNull(existing == null ? null : existing.getTotalCost(), metrics == null ? null : metrics.totalCost)) .currency(firstNonBlank(existing == null ? null : existing.getCurrency(), metrics == null ? null : metrics.currency)) .build(); } private NodeMetrics extractNodeMetrics(FlowGramTaskReportResponse.NodeStatus node) { if (node == null) { return null; } Object inputs = normalizeTree(node.getInputs()); Object outputs = normalizeTree(node.getOutputs()); Object metrics = value(outputs, "metrics"); Object rawResponse = value(outputs, "rawResponse"); Object usage = value(rawResponse, "usage"); return new NodeMetrics( firstNonBlank( stringValue(metrics, "model"), stringValue(rawResponse, "model"), stringValue(inputs, "model"), stringValue(inputs, "modelName")), longValue(firstNonNull(value(metrics, "promptTokens", "prompt_tokens"), value(usage, "promptTokens", "prompt_tokens", "input"))), longValue(firstNonNull(value(metrics, "completionTokens", "completion_tokens"), value(usage, "completionTokens", "completion_tokens", "output"))), longValue(firstNonNull(value(metrics, "totalTokens", "total_tokens"), value(usage, "totalTokens", "total_tokens", "total"))), doubleValue(value(metrics, "inputCost", "input_cost")), doubleValue(value(metrics, "outputCost", "output_cost")), doubleValue(value(metrics, "totalCost", "total_cost")), firstNonBlank(stringValue(metrics, "currency")) ); } private static boolean hasMetrics(FlowGramTraceView.MetricsView metrics) { return metrics != null && (metrics.getPromptTokens() != null || metrics.getCompletionTokens() != null || metrics.getTotalTokens() != null || metrics.getInputCost() != null || metrics.getOutputCost() != null || metrics.getTotalCost() != null || firstNonBlank(metrics.getCurrency()) != null); } private static void putIfAbsent(Map target, String key, Object value) { if (target != null && key != null && value != null && !target.containsKey(key)) { target.put(key, value); } } @SuppressWarnings("unchecked") private static Object value(Object source, String... keys) { if (source == null || keys == null) { return null; } Object normalized = normalizeTree(source); for (String key : keys) { if (key == null || key.trim().isEmpty()) { continue; } if (normalized instanceof Map && ((Map) normalized).containsKey(key)) { Object value = ((Map) normalized).get(key); if (value != null) { return value; } } } return null; } @SuppressWarnings("unchecked") private static Map normalizeMap(Object source) { Object normalized = normalizeTree(source); if (!(normalized instanceof Map)) { return new LinkedHashMap(); } Map copy = new LinkedHashMap(); Map sourceMap = (Map) normalized; for (Map.Entry entry : sourceMap.entrySet()) { copy.put(String.valueOf(entry.getKey()), entry.getValue()); } return copy; } @SuppressWarnings("unchecked") private static Object normalizeTree(Object source) { if (source == null || source instanceof String || source instanceof Number || source instanceof Boolean) { return source; } Object normalized = source; if (!(source instanceof Map) && !(source instanceof List)) { try { normalized = JSON.parse(JSON.toJSONString(source)); } catch (RuntimeException ignored) { return source; } } if (normalized instanceof Map) { Map copy = new LinkedHashMap(); Map sourceMap = (Map) normalized; for (Map.Entry entry : sourceMap.entrySet()) { copy.put(String.valueOf(entry.getKey()), normalizeTree(entry.getValue())); } return copy; } if (normalized instanceof List) { List copy = new ArrayList(); for (Object item : (List) normalized) { copy.add(normalizeTree(item)); } return copy; } return normalized; } private static String stringValue(Object source, String key) { Object value = value(source, key); return value == null ? null : String.valueOf(value); } private static Long longValue(Object value) { if (value == null) { return null; } if (value instanceof Number) { return Long.valueOf(((Number) value).longValue()); } try { return Long.valueOf(Long.parseLong(String.valueOf(value))); } catch (NumberFormatException ignored) { return null; } } private static Double doubleValue(Object value) { if (value == null) { return null; } if (value instanceof Number) { return Double.valueOf(((Number) value).doubleValue()); } try { return Double.valueOf(Double.parseDouble(String.valueOf(value))); } catch (NumberFormatException ignored) { return null; } } private static Long sum(Long left, Long right) { if (left == null) { return right; } if (right == null) { return left; } return Long.valueOf(left.longValue() + right.longValue()); } private static Double sum(Double left, Double right) { if (left == null) { return right; } if (right == null) { return left; } return Double.valueOf(left.doubleValue() + right.doubleValue()); } private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } private static T firstNonNull(T left, T right) { return left != null ? left : right; } private static final class NodeMetrics { private final String model; private final Long promptTokens; private final Long completionTokens; private final Long totalTokens; private final Double inputCost; private final Double outputCost; private final Double totalCost; private final String currency; private NodeMetrics(String model, Long promptTokens, Long completionTokens, Long totalTokens, Double inputCost, Double outputCost, Double totalCost, String currency) { this.model = model; this.promptTokens = promptTokens; this.completionTokens = completionTokens; this.totalTokens = totalTokens; this.inputCost = inputCost; this.outputCost = outputCost; this.totalCost = totalCost; this.currency = currency; } private boolean hasUsageLikeContent() { return firstNonBlank(model, currency) != null || promptTokens != null || completionTokens != null || totalTokens != null || inputCost != null || outputCost != null || totalCost != null; } } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/InMemoryFlowGramTaskStore.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class InMemoryFlowGramTaskStore implements FlowGramTaskStore { private final ConcurrentMap tasks = new ConcurrentHashMap(); @Override public void save(FlowGramStoredTask task) { if (task == null || task.getTaskId() == null || task.getTaskId().trim().isEmpty()) { return; } tasks.put(task.getTaskId(), copy(task)); } @Override public FlowGramStoredTask find(String taskId) { FlowGramStoredTask task = tasks.get(taskId); return task == null ? null : copy(task); } @Override public void updateState(String taskId, String status, Boolean terminated, String error, Map resultSnapshot) { if (taskId == null || taskId.trim().isEmpty()) { return; } tasks.compute(taskId, (key, existing) -> { FlowGramStoredTask target = existing == null ? FlowGramStoredTask.builder().taskId(taskId).build() : existing.toBuilder().build(); if (status != null) { target.setStatus(status); } if (terminated != null) { target.setTerminated(terminated); } if (error != null || target.getError() != null) { target.setError(error); } if (resultSnapshot != null) { target.setResultSnapshot(copyMap(resultSnapshot)); } return target; }); } private FlowGramStoredTask copy(FlowGramStoredTask task) { return task.toBuilder() .resultSnapshot(copyMap(task.getResultSnapshot())) .build(); } private Map copyMap(Map source) { Map copy = new LinkedHashMap(); if (source == null) { return copy; } for (Map.Entry entry : source.entrySet()) { copy.put(entry.getKey(), entry.getValue()); } return copy; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/JdbcFlowGramTaskStore.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.TypeReference; import javax.sql.DataSource; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; public class JdbcFlowGramTaskStore implements FlowGramTaskStore { private final DataSource dataSource; private final String tableName; public JdbcFlowGramTaskStore(DataSource dataSource, String tableName, boolean initializeSchema) { if (dataSource == null) { throw new IllegalArgumentException("dataSource is required"); } this.dataSource = dataSource; this.tableName = validIdentifier(tableName); if (initializeSchema) { initializeSchema(); } } @Override public void save(FlowGramStoredTask task) { if (task == null || isBlank(task.getTaskId())) { return; } upsert(copy(task)); } @Override public FlowGramStoredTask find(String taskId) { if (isBlank(taskId)) { return null; } String sql = "select task_id, creator_id, tenant_id, created_at, expires_at, status, terminated, error, result_snapshot " + "from " + tableName + " where task_id = ?"; try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, taskId); try (ResultSet resultSet = statement.executeQuery()) { if (!resultSet.next()) { return null; } return FlowGramStoredTask.builder() .taskId(resultSet.getString("task_id")) .creatorId(resultSet.getString("creator_id")) .tenantId(resultSet.getString("tenant_id")) .createdAt(longValue(resultSet, "created_at")) .expiresAt(longValue(resultSet, "expires_at")) .status(resultSet.getString("status")) .terminated(booleanValue(resultSet, "terminated")) .error(resultSet.getString("error")) .resultSnapshot(parseSnapshot(resultSet.getString("result_snapshot"))) .build(); } } catch (Exception e) { throw new IllegalStateException("Failed to load FlowGram task: " + taskId, e); } } @Override public void updateState(String taskId, String status, Boolean terminated, String error, Map resultSnapshot) { if (isBlank(taskId)) { return; } FlowGramStoredTask existing = find(taskId); FlowGramStoredTask target = existing == null ? FlowGramStoredTask.builder().taskId(taskId).build() : existing.toBuilder().build(); if (status != null) { target.setStatus(status); } if (terminated != null) { target.setTerminated(terminated); } if (error != null || target.getError() != null) { target.setError(error); } if (resultSnapshot != null) { target.setResultSnapshot(copyMap(resultSnapshot)); } upsert(target); } private void initializeSchema() { String sql = "create table if not exists " + tableName + " (" + "task_id varchar(191) not null, " + "creator_id varchar(191), " + "tenant_id varchar(191), " + "created_at bigint, " + "expires_at bigint, " + "status varchar(64), " + "terminated boolean, " + "error text, " + "result_snapshot text, " + "updated_at bigint not null, " + "primary key (task_id)" + ")"; try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { statement.executeUpdate(sql); } catch (Exception e) { throw new IllegalStateException("Failed to initialize FlowGram task store schema", e); } } private void upsert(FlowGramStoredTask task) { String deleteSql = "delete from " + tableName + " where task_id = ?"; String insertSql = "insert into " + tableName + " (task_id, creator_id, tenant_id, created_at, expires_at, status, terminated, error, result_snapshot, updated_at) " + "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; try (Connection connection = dataSource.getConnection()) { boolean autoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); try { try (PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) { deleteStatement.setString(1, task.getTaskId()); deleteStatement.executeUpdate(); } try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) { insertStatement.setString(1, task.getTaskId()); insertStatement.setString(2, task.getCreatorId()); insertStatement.setString(3, task.getTenantId()); setLong(insertStatement, 4, task.getCreatedAt()); setLong(insertStatement, 5, task.getExpiresAt()); insertStatement.setString(6, task.getStatus()); if (task.getTerminated() == null) { insertStatement.setObject(7, null); } else { insertStatement.setBoolean(7, task.getTerminated()); } insertStatement.setString(8, task.getError()); Map snapshot = task.getResultSnapshot() == null ? Collections.emptyMap() : task.getResultSnapshot(); insertStatement.setString(9, JSON.toJSONString(snapshot)); insertStatement.setLong(10, System.currentTimeMillis()); insertStatement.executeUpdate(); } connection.commit(); } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(autoCommit); } } catch (Exception e) { throw new IllegalStateException("Failed to persist FlowGram task: " + task.getTaskId(), e); } } private FlowGramStoredTask copy(FlowGramStoredTask task) { return task.toBuilder() .resultSnapshot(copyMap(task.getResultSnapshot())) .build(); } private Map copyMap(Map source) { Map copy = new LinkedHashMap(); if (source == null) { return copy; } copy.putAll(source); return copy; } private Map parseSnapshot(String json) { if (isBlank(json)) { return new LinkedHashMap(); } return JSON.parseObject(json, new TypeReference>() { }); } private Long longValue(ResultSet resultSet, String column) throws Exception { long value = resultSet.getLong(column); return resultSet.wasNull() ? null : value; } private Boolean booleanValue(ResultSet resultSet, String column) throws Exception { boolean value = resultSet.getBoolean(column); return resultSet.wasNull() ? null : value; } private void setLong(PreparedStatement statement, int index, Long value) throws Exception { if (value == null) { statement.setObject(index, null); } else { statement.setLong(index, value); } } private String validIdentifier(String value) { if (isBlank(value) || !value.matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?")) { throw new IllegalArgumentException("Invalid sql identifier: " + value); } return value.trim(); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/RegistryBackedFlowGramModelClientResolver.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import io.github.lnyocly.ai4j.agent.flowgram.Ai4jFlowGramLlmNodeRunner; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import io.github.lnyocly.ai4j.agent.model.AgentModelClient; import io.github.lnyocly.ai4j.agent.model.ChatModelClient; import io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties; import io.github.lnyocly.ai4j.service.factory.AiServiceRegistry; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class RegistryBackedFlowGramModelClientResolver implements Ai4jFlowGramLlmNodeRunner.ModelClientResolver { private final AiServiceRegistry aiServiceRegistry; private final FlowGramProperties properties; private final ConcurrentMap chatClients = new ConcurrentHashMap(); public RegistryBackedFlowGramModelClientResolver(AiServiceRegistry aiServiceRegistry, FlowGramProperties properties) { this.aiServiceRegistry = aiServiceRegistry; this.properties = properties; } @Override public AgentModelClient resolve(FlowGramNodeSchema node, Map inputs) { String serviceId = firstNonBlank( valueAsString(inputs == null ? null : inputs.get("serviceId")), valueAsString(inputs == null ? null : inputs.get("aiServiceId")), properties == null ? null : properties.getDefaultServiceId() ); if (serviceId == null) { throw new IllegalArgumentException("FlowGram LLM node requires serviceId/aiServiceId or ai4j.flowgram.default-service-id"); } return chatClients.computeIfAbsent(serviceId, key -> new ChatModelClient(aiServiceRegistry.getChatService(key))); } private String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (value != null && !value.trim().isEmpty()) { return value.trim(); } } return null; } private String valueAsString(Object value) { return value == null ? null : String.valueOf(value); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ================================================ io.github.lnyocly.ai4j.flowgram.springboot.autoconfigure.FlowGramAutoConfiguration ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/main/resources/META-INF/spring.factories ================================================ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ io.github.lnyocly.ai4j.flowgram.springboot.autoconfigure.FlowGramAutoConfiguration ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/FlowGramJdbcTaskStoreAutoConfigurationTest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot; import io.github.lnyocly.ai4j.AiConfigAutoConfiguration; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramLlmNodeRunner; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramTaskStore; import io.github.lnyocly.ai4j.flowgram.springboot.support.JdbcFlowGramTaskStore; import org.h2.jdbcx.JdbcDataSource; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import javax.sql.DataSource; import java.util.Map; import static org.junit.Assert.assertTrue; @RunWith(SpringRunner.class) @SpringBootTest( classes = FlowGramJdbcTaskStoreAutoConfigurationTest.TestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.MOCK ) @TestPropertySource(properties = { "org.springframework.boot.logging.LoggingSystem=none", "ai4j.flowgram.enabled=true", "ai4j.flowgram.task-store.type=jdbc", "ai4j.flowgram.task-store.table-name=ai4j_flowgram_task_autotest" }) public class FlowGramJdbcTaskStoreAutoConfigurationTest { @Autowired private FlowGramTaskStore taskStore; @Test public void shouldAutoConfigureJdbcTaskStoreWhenDataSourceIsPresent() { assertTrue(taskStore instanceof JdbcFlowGramTaskStore); } @SpringBootConfiguration @EnableAutoConfiguration(exclude = AiConfigAutoConfiguration.class) public static class TestApplication { @Bean public DataSource dataSource() { JdbcDataSource dataSource = new JdbcDataSource(); dataSource.setURL("jdbc:h2:mem:ai4j_flowgram_autoconfig;MODE=MYSQL;DB_CLOSE_DELAY=-1"); dataSource.setUser("sa"); dataSource.setPassword(""); return dataSource; } @Bean public FlowGramLlmNodeRunner flowGramLlmNodeRunner() { return new FlowGramLlmNodeRunner() { @Override public Map run(io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema node, Map inputs) { throw new UnsupportedOperationException("not used in auto configuration test"); } }; } } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/FlowGramRuntimeTraceCollectorTest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeEvent; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeTraceCollector; import io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import org.junit.Assert; import org.junit.Test; import java.util.LinkedHashMap; import java.util.Map; public class FlowGramRuntimeTraceCollectorTest { @Test public void shouldMergeReportMetricsIntoTraceProjection() { FlowGramRuntimeTraceCollector collector = new FlowGramRuntimeTraceCollector(); String taskId = "task-1"; collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_STARTED, 1000L, taskId, null, "processing", null)); collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_STARTED, 1100L, taskId, "llm_0", "processing", null)); collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_FINISHED, 1600L, taskId, "llm_0", "success", null)); collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_FINISHED, 2000L, taskId, null, "success", null)); FlowGramTaskReportOutput report = FlowGramTaskReportOutput.builder() .workflow(FlowGramTaskReportOutput.WorkflowStatus.builder() .status("success") .terminated(true) .startTime(1000L) .endTime(2000L) .build()) .nodes(singleNodeReport()) .build(); FlowGramTraceView trace = collector.getTrace(taskId, report); Assert.assertNotNull(trace); Assert.assertNotNull(trace.getSummary()); Assert.assertEquals(Long.valueOf(1000L), trace.getSummary().getDurationMillis()); Assert.assertEquals(Integer.valueOf(4), trace.getSummary().getEventCount()); Assert.assertEquals(Integer.valueOf(1), trace.getSummary().getLlmNodeCount()); Assert.assertEquals(Long.valueOf(120L), trace.getSummary().getMetrics().getPromptTokens()); Assert.assertEquals(Long.valueOf(45L), trace.getSummary().getMetrics().getCompletionTokens()); Assert.assertEquals(Long.valueOf(165L), trace.getSummary().getMetrics().getTotalTokens()); Assert.assertEquals(Double.valueOf(0.0006D), trace.getSummary().getMetrics().getTotalCost()); FlowGramTraceView.NodeView nodeView = trace.getNodes().get("llm_0"); Assert.assertNotNull(nodeView); Assert.assertEquals("glm-4.7", nodeView.getModel()); Assert.assertEquals(Long.valueOf(500L), nodeView.getDurationMillis()); Assert.assertNotNull(nodeView.getMetrics()); Assert.assertEquals(Long.valueOf(165L), nodeView.getMetrics().getTotalTokens()); Assert.assertEquals("USD", nodeView.getMetrics().getCurrency()); } @Test public void shouldExtractUsageFromObjectRawResponse() { FlowGramRuntimeTraceCollector collector = new FlowGramRuntimeTraceCollector(); String taskId = "task-2"; collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_STARTED, 1000L, taskId, null, "processing", null)); collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_STARTED, 1100L, taskId, "llm_0", "processing", null)); collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_FINISHED, 1600L, taskId, "llm_0", "success", null)); collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_FINISHED, 2000L, taskId, null, "success", null)); Map outputs = new LinkedHashMap(); outputs.put("rawResponse", new MinimaxChatCompletionResponse( "resp-2", "chat.completion", 1L, "MiniMax-M2.1", null, new Usage(159L, 232L, 391L))); Map nodes = new LinkedHashMap(); nodes.put("llm_0", FlowGramTaskReportOutput.NodeStatus.builder() .status("success") .terminated(true) .startTime(1100L) .endTime(1600L) .inputs(inputMap("MiniMax-M2.1")) .outputs(outputs) .build()); FlowGramTaskReportOutput report = FlowGramTaskReportOutput.builder() .workflow(FlowGramTaskReportOutput.WorkflowStatus.builder() .status("success") .terminated(true) .startTime(1000L) .endTime(2000L) .build()) .nodes(nodes) .build(); FlowGramTraceView trace = collector.getTrace(taskId, report); Assert.assertNotNull(trace); Assert.assertNotNull(trace.getSummary()); Assert.assertNotNull(trace.getSummary().getMetrics()); Assert.assertEquals(Long.valueOf(159L), trace.getSummary().getMetrics().getPromptTokens()); Assert.assertEquals(Long.valueOf(232L), trace.getSummary().getMetrics().getCompletionTokens()); Assert.assertEquals(Long.valueOf(391L), trace.getSummary().getMetrics().getTotalTokens()); Assert.assertEquals("MiniMax-M2.1", trace.getNodes().get("llm_0").getModel()); Assert.assertEquals(Long.valueOf(391L), trace.getNodes().get("llm_0").getMetrics().getTotalTokens()); } private Map singleNodeReport() { Map metrics = new LinkedHashMap(); metrics.put("promptTokens", 120L); metrics.put("completionTokens", 45L); metrics.put("totalTokens", 165L); metrics.put("inputCost", 0.00024D); metrics.put("outputCost", 0.00036D); metrics.put("totalCost", 0.0006D); metrics.put("currency", "USD"); Map outputs = new LinkedHashMap(); outputs.put("result", "hello"); outputs.put("metrics", metrics); Map nodes = new LinkedHashMap(); nodes.put("llm_0", FlowGramTaskReportOutput.NodeStatus.builder() .status("success") .terminated(true) .startTime(1100L) .endTime(1600L) .inputs(inputMap("glm-4.7")) .outputs(outputs) .build()); return nodes; } private Map inputMap(String modelName) { Map inputs = new LinkedHashMap(); inputs.put("modelName", modelName); return inputs; } private FlowGramRuntimeEvent event(FlowGramRuntimeEvent.Type type, long timestamp, String taskId, String nodeId, String status, String error) { return FlowGramRuntimeEvent.builder() .type(type) .timestamp(timestamp) .taskId(taskId) .nodeId(nodeId) .status(status) .error(error) .build(); } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/FlowGramTaskControllerIntegrationTest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.AiConfigAutoConfiguration; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramLlmNodeRunner; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeEvent; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeListener; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramEdgeSchema; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramWorkflowSchema; import io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse; import io.github.lnyocly.ai4j.platform.openai.usage.Usage; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest; import io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramStoredTask; import io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramTaskStore; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; import org.springframework.http.MediaType; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @RunWith(SpringRunner.class) @SpringBootTest( classes = FlowGramTaskControllerIntegrationTest.TestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.MOCK ) @AutoConfigureMockMvc @TestPropertySource(properties = { "ai4j.flowgram.enabled=true", "ai4j.flowgram.api.base-path=/flowgram" }) public class FlowGramTaskControllerIntegrationTest { @Autowired private MockMvc mockMvc; @Autowired private FlowGramTaskStore taskStore; @Autowired @Qualifier("testRuntimeEventCollector") private TestRuntimeEventCollector runtimeEventCollector; @Before public void setUp() { runtimeEventCollector.clear(); } @Test public void shouldRunTaskThroughControllerAndRegisterExtensionBeans() throws Exception { FlowGramTaskRunRequest request = FlowGramTaskRunRequest.builder() .schema(customExecutorWorkflow()) .inputs(mapOf("prompt", "hello-flowgram")) .build(); JSONObject runResponse = postForJson("/flowgram/tasks/run", request); String taskId = runResponse.getString("taskId"); assertNotNull(taskId); JSONObject result = awaitResult(taskId); assertEquals("success", result.getString("status")); assertTrue(result.getBooleanValue("terminated")); assertEquals("custom:HELLO-FLOWGRAM", result.getJSONObject("result").getString("result")); JSONObject report = getForJson("/flowgram/tasks/" + taskId + "/report"); assertEquals("hello-flowgram", report.getJSONObject("inputs").getString("prompt")); assertEquals("custom:HELLO-FLOWGRAM", report.getJSONObject("outputs").getString("result")); assertEquals("success", report.getJSONObject("workflow").getString("status")); assertEquals("success", report.getJSONObject("nodes").getJSONObject("transform_0").getString("status")); assertEquals("hello-flowgram", report.getJSONObject("nodes").getJSONObject("start_0") .getJSONObject("inputs").getString("prompt")); assertEquals("custom:HELLO-FLOWGRAM", report.getJSONObject("nodes").getJSONObject("transform_0") .getJSONObject("outputs").getString("result")); assertEquals("success", report.getJSONObject("trace").getString("status")); assertEquals(taskId, report.getJSONObject("trace").getString("taskId")); assertTrue(report.getJSONObject("trace").getJSONArray("events").size() >= 4); assertEquals(8, report.getJSONObject("trace").getJSONObject("summary").getIntValue("eventCount")); assertEquals(3, report.getJSONObject("trace").getJSONObject("summary").getIntValue("nodeCount")); assertEquals("success", report.getJSONObject("trace").getJSONObject("nodes") .getJSONObject("transform_0").getString("status")); FlowGramStoredTask storedTask = taskStore.find(taskId); assertNotNull(storedTask); assertEquals("success", storedTask.getStatus()); assertTrue(Boolean.TRUE.equals(storedTask.getTerminated())); assertEquals("custom:HELLO-FLOWGRAM", storedTask.getResultSnapshot().get("result")); assertEquals(taskId, result.getJSONObject("trace").getString("taskId")); assertEquals("success", result.getJSONObject("trace").getString("status")); assertEquals(3, result.getJSONObject("trace").getJSONObject("summary").getIntValue("nodeCount")); assertTrue(runtimeEventCollector.hasEventType(FlowGramRuntimeEvent.Type.TASK_FINISHED)); assertTrue(runtimeEventCollector.hasNodeEvent("transform_0", FlowGramRuntimeEvent.Type.NODE_FINISHED)); } @Test public void shouldValidateWorkflowRequest() throws Exception { FlowGramTaskValidateRequest request = FlowGramTaskValidateRequest.builder() .schema(invalidWorkflow()) .inputs(Collections.emptyMap()) .build(); JSONObject response = postForJson("/flowgram/tasks/validate", request); assertFalse(response.getBooleanValue("valid")); JSONArray errors = response.getJSONArray("errors"); assertTrue(errors.toJSONString().contains("FlowGram workflow must contain exactly one Start node")); assertTrue(errors.toJSONString().contains("FlowGram workflow must contain at least one End node")); } @Test public void shouldBackfillTraceMetricsFromSerializedLlmResponse() throws Exception { FlowGramTaskRunRequest request = FlowGramTaskRunRequest.builder() .schema(llmWorkflow()) .inputs(mapOf("prompt", "token-check")) .build(); JSONObject runResponse = postForJson("/flowgram/tasks/run", request); String taskId = runResponse.getString("taskId"); assertNotNull(taskId); JSONObject report = getForJson("/flowgram/tasks/" + taskId + "/report"); JSONObject llmOutputs = report.getJSONObject("nodes").getJSONObject("llm_0").getJSONObject("outputs"); JSONObject outputMetrics = llmOutputs.getJSONObject("metrics"); assertEquals(159L, outputMetrics.getLongValue("promptTokens")); assertEquals(280L, outputMetrics.getLongValue("completionTokens")); assertEquals(439L, outputMetrics.getLongValue("totalTokens")); JSONObject traceMetrics = report.getJSONObject("trace").getJSONObject("summary").getJSONObject("metrics"); assertEquals(159L, traceMetrics.getLongValue("promptTokens")); assertEquals(280L, traceMetrics.getLongValue("completionTokens")); assertEquals(439L, traceMetrics.getLongValue("totalTokens")); assertEquals(439L, report.getJSONObject("trace").getJSONObject("nodes") .getJSONObject("llm_0").getJSONObject("metrics").getLongValue("totalTokens")); JSONObject result = awaitResult(taskId); JSONObject resultTraceMetrics = result.getJSONObject("trace").getJSONObject("summary").getJSONObject("metrics"); assertEquals(439L, resultTraceMetrics.getLongValue("totalTokens")); } @Test public void shouldReturnNotFoundForUnknownTask() throws Exception { MvcResult mvcResult = mockMvc.perform(get("/flowgram/tasks/{taskId}/result", "missing-task")) .andExpect(status().isNotFound()) .andReturn(); JSONObject response = JSON.parseObject(mvcResult.getResponse().getContentAsString()); assertEquals("FLOWGRAM_TASK_NOT_FOUND", response.getString("code")); assertTrue(response.getString("message").contains("missing-task")); } private JSONObject awaitResult(String taskId) throws Exception { long deadline = System.currentTimeMillis() + 5000L; while (System.currentTimeMillis() < deadline) { JSONObject response = getForJson("/flowgram/tasks/" + taskId + "/result"); if (response.getBooleanValue("terminated")) { return response; } Thread.sleep(20L); } throw new AssertionError("Timed out waiting for FlowGram task result"); } private JSONObject postForJson(String path, Object body) throws Exception { MvcResult mvcResult = mockMvc.perform(post(path) .contentType(MediaType.APPLICATION_JSON) .content(JSON.toJSONString(body))) .andExpect(status().isOk()) .andReturn(); return JSON.parseObject(mvcResult.getResponse().getContentAsString()); } private JSONObject getForJson(String path) throws Exception { MvcResult mvcResult = mockMvc.perform(get(path)) .andExpect(status().isOk()) .andReturn(); return JSON.parseObject(mvcResult.getResponse().getContentAsString()); } private static FlowGramWorkflowSchema customExecutorWorkflow() { return FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startData()), node("transform_0", "Transform", transformData()), node("end_0", "End", endData(ref("transform_0", "result"))) )) .edges(Arrays.asList( edge("start_0", "transform_0"), edge("transform_0", "end_0") )) .build(); } private static FlowGramWorkflowSchema llmWorkflow() { return FlowGramWorkflowSchema.builder() .nodes(Arrays.asList( node("start_0", "Start", startData()), node("llm_0", "LLM", llmData()), node("end_0", "End", endData(ref("llm_0", "result"))) )) .edges(Arrays.asList( edge("start_0", "llm_0"), edge("llm_0", "end_0") )) .build(); } private static FlowGramWorkflowSchema invalidWorkflow() { return FlowGramWorkflowSchema.builder() .nodes(Collections.singletonList( node("transform_0", "Transform", transformData()) )) .edges(Collections.emptyList()) .build(); } private static FlowGramNodeSchema node(String id, String type, Map data) { return FlowGramNodeSchema.builder() .id(id) .type(type) .name(id) .data(data) .build(); } private static FlowGramEdgeSchema edge(String sourceNodeId, String targetNodeId) { return FlowGramEdgeSchema.builder() .sourceNodeID(sourceNodeId) .targetNodeID(targetNodeId) .build(); } private static Map startData() { return mapOf("outputs", objectSchema(required("prompt"), property("prompt", stringSchema()))); } private static Map transformData() { return mapOf( "inputs", objectSchema(required("text"), property("text", stringSchema())), "outputs", objectSchema(required("result"), property("result", stringSchema())), "inputsValues", mapOf("text", ref("start_0", "prompt")) ); } private static Map llmData() { return mapOf( "inputs", objectSchema( required("modelName", "prompt"), property("serviceId", stringSchema()), property("modelName", stringSchema()), property("prompt", stringSchema()) ), "outputs", objectSchema(required("result"), property("result", stringSchema())), "inputsValues", mapOf( "serviceId", constant("minimax-coding"), "modelName", constant("MiniMax-M2.1"), "prompt", ref("start_0", "prompt") ) ); } private static Map endData(Map resultValue) { return mapOf( "inputs", objectSchema(required("result"), property("result", stringSchema())), "inputsValues", mapOf("result", resultValue) ); } private static Map stringSchema() { return mapOf("type", "string"); } private static Map objectSchema(List required, Map... properties) { Map object = new LinkedHashMap(); object.put("type", "object"); object.put("required", required); Map values = new LinkedHashMap(); if (properties != null) { for (Map property : properties) { values.putAll(property); } } object.put("properties", values); return object; } private static Map property(String name, Map schema) { return mapOf(name, schema); } private static List required(String... names) { return Arrays.asList(names); } private static Map ref(String... path) { return mapOf("type", "ref", "content", Arrays.asList(path)); } private static Map constant(Object value) { return mapOf("type", "constant", "content", value); } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } @SpringBootConfiguration @EnableAutoConfiguration(exclude = AiConfigAutoConfiguration.class) public static class TestApplication { @Bean public FlowGramLlmNodeRunner flowGramLlmNodeRunner() { return new FlowGramLlmNodeRunner() { @Override public Map run(FlowGramNodeSchema node, Map inputs) { Map outputs = new LinkedHashMap(); outputs.put("result", "llm:" + String.valueOf(inputs.get("prompt"))); outputs.put("outputText", "llm:" + String.valueOf(inputs.get("prompt"))); outputs.put("rawResponse", new MinimaxChatCompletionResponse( "resp-trace", "chat.completion", 1L, String.valueOf(inputs.get("modelName")), null, new Usage(159L, 280L, 439L))); outputs.put("metrics", mapOf( "durationMillis", 1234L, "model", String.valueOf(inputs.get("modelName")) )); return outputs; } }; } @Bean public FlowGramNodeExecutor transformNodeExecutor() { return new FlowGramNodeExecutor() { @Override public String getType() { return "TRANSFORM"; } @Override public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) { String text = String.valueOf(context.getInputs().get("text")); return FlowGramNodeExecutionResult.builder() .outputs(mapOf("result", "custom:" + text.toUpperCase(Locale.ROOT))) .build(); } }; } @Bean public TestRuntimeEventCollector testRuntimeEventCollector() { return new TestRuntimeEventCollector(); } @Bean public FlowGramRuntimeListener flowGramRuntimeListener(TestRuntimeEventCollector collector) { return collector; } } public static class TestRuntimeEventCollector implements FlowGramRuntimeListener { private final List events = new CopyOnWriteArrayList(); @Override public void onEvent(FlowGramRuntimeEvent event) { if (event != null) { events.add(event); } } public void clear() { events.clear(); } public boolean hasEventType(FlowGramRuntimeEvent.Type type) { for (FlowGramRuntimeEvent event : events) { if (event != null && type == event.getType()) { return true; } } return false; } public boolean hasNodeEvent(String nodeId, FlowGramRuntimeEvent.Type type) { for (FlowGramRuntimeEvent event : events) { if (event != null && type == event.getType() && nodeId.equals(event.getNodeId())) { return true; } } return false; } } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramBuiltinNodeExecutorTest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import org.junit.Assert; import org.junit.Assume; import org.junit.Test; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import javax.script.ScriptEngineManager; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; public class FlowGramBuiltinNodeExecutorTest { @Test public void shouldResolveVariableAssignments() throws Exception { FlowGramVariableNodeExecutor executor = new FlowGramVariableNodeExecutor(); FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder() .taskId("task-variable") .node(node("variable_0", "Variable", mapOf( "assign", Arrays.asList( mapOf( "left", "summary", "right", mapOf("type", "template", "content", "hello ${start_0.result}") ) ) ))) .nodeOutputs(mapOf("start_0", mapOf("result", "flowgram"))) .taskInputs(Collections.emptyMap()) .inputs(Collections.emptyMap()) .locals(Collections.emptyMap()) .build()); Assert.assertEquals("hello flowgram", result.getOutputs().get("summary")); } @Test public void shouldRunCodeNodeScript() throws Exception { Assume.assumeTrue("Nashorn is not available", isNashornAvailable()); FlowGramCodeNodeExecutor executor = new FlowGramCodeNodeExecutor(); FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder() .taskId("task-code") .node(node("code_0", "Code", mapOf( "script", mapOf( "language", "javascript", "content", "function main(input) { var params = input && input.params ? input.params : {}; return { result: params.input + '-ok' }; }" ) ))) .inputs(mapOf("input", "hello")) .taskInputs(Collections.emptyMap()) .nodeOutputs(Collections.emptyMap()) .locals(Collections.emptyMap()) .build()); Assert.assertEquals("hello-ok", result.getOutputs().get("result")); } @Test public void shouldInvokeToolNode() throws Exception { FlowGramToolNodeExecutor executor = new FlowGramToolNodeExecutor(); FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder() .taskId("task-tool") .node(node("tool_0", "Tool", Collections.emptyMap())) .inputs(mapOf( "toolName", "queryTrainInfo", "argumentsJson", "{\"type\":40}" )) .taskInputs(Collections.emptyMap()) .nodeOutputs(Collections.emptyMap()) .locals(Collections.emptyMap()) .build()); Assert.assertTrue(String.valueOf(result.getOutputs().get("result")).contains("允许发车")); } @Test public void shouldInvokeHttpNode() throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/echo", new HttpHandler() { @Override public void handle(HttpExchange exchange) { try { byte[] response = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, response.length); OutputStream outputStream = exchange.getResponseBody(); try { outputStream.write(response); } finally { outputStream.close(); } } catch (Exception ex) { throw new RuntimeException(ex); } } }); server.start(); try { int port = server.getAddress().getPort(); FlowGramHttpNodeExecutor executor = new FlowGramHttpNodeExecutor(); FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder() .taskId("task-http") .node(node("http_0", "HTTP", mapOf( "api", mapOf( "method", "GET", "url", mapOf("type", "constant", "content", "http://127.0.0.1:" + port + "/echo") ), "headersValues", mapOf( "X-Test", mapOf("type", "constant", "content", "yes") ), "paramsValues", Collections.emptyMap(), "timeout", mapOf( "timeout", 2000, "retryTimes", 1 ), "body", mapOf( "bodyType", "none" ) ))) .inputs(Collections.emptyMap()) .taskInputs(Collections.emptyMap()) .nodeOutputs(Collections.emptyMap()) .locals(Collections.emptyMap()) .build()); Assert.assertEquals(200, result.getOutputs().get("statusCode")); Assert.assertEquals("{\"ok\":true}", result.getOutputs().get("body")); } finally { server.stop(0); } } private static FlowGramNodeSchema node(String id, String type, Map data) { return FlowGramNodeSchema.builder() .id(id) .type(type) .name(id) .data(data) .build(); } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } private boolean isNashornAvailable() { return new ScriptEngineManager().getEngineByName("nashorn") != null; } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramKnowledgeRetrieveNodeExecutorTest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.node; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext; import io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult; import io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject; import io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse; import io.github.lnyocly.ai4j.rag.DefaultRagContextAssembler; import io.github.lnyocly.ai4j.rag.NoopReranker; import io.github.lnyocly.ai4j.service.IAudioService; import io.github.lnyocly.ai4j.service.IChatService; import io.github.lnyocly.ai4j.service.IEmbeddingService; import io.github.lnyocly.ai4j.service.IImageService; import io.github.lnyocly.ai4j.service.IRealtimeService; import io.github.lnyocly.ai4j.service.IResponsesService; import io.github.lnyocly.ai4j.service.factory.AiServiceRegistration; import io.github.lnyocly.ai4j.service.factory.AiServiceRegistry; import io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchRequest; import io.github.lnyocly.ai4j.vector.store.VectorSearchResult; import io.github.lnyocly.ai4j.vector.store.VectorStore; import io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities; import io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest; import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; public class FlowGramKnowledgeRetrieveNodeExecutorTest { @Test public void shouldProduceStructuredKnowledgeOutputs() throws Exception { FlowGramKnowledgeRetrieveNodeExecutor executor = new FlowGramKnowledgeRetrieveNodeExecutor( new FakeRegistry(), new FakeVectorStore(), new NoopReranker(), new DefaultRagContextAssembler() ); FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder() .taskId("task-knowledge") .node(node("knowledge_0", "KNOWLEDGE", Collections.emptyMap())) .inputs(mapOf( "serviceId", "main", "embeddingModel", "text-embedding-3-small", "namespace", "tenant_docs", "query", "vacation policy", "topK", 3, "filter", "{\"tenant\":\"acme\"}" )) .taskInputs(Collections.emptyMap()) .nodeOutputs(Collections.emptyMap()) .locals(Collections.emptyMap()) .build()); Assert.assertEquals(1, result.getOutputs().get("count")); Assert.assertTrue(String.valueOf(result.getOutputs().get("context")).contains("[S1]")); Assert.assertTrue(result.getOutputs().containsKey("matches")); Assert.assertTrue(result.getOutputs().containsKey("hits")); Assert.assertTrue(result.getOutputs().containsKey("citations")); Assert.assertTrue(result.getOutputs().containsKey("sources")); Assert.assertTrue(result.getOutputs().containsKey("trace")); Assert.assertTrue(result.getOutputs().containsKey("retrievedHits")); Assert.assertTrue(result.getOutputs().containsKey("rerankedHits")); List> hits = (List>) result.getOutputs().get("hits"); Assert.assertEquals(1, hits.size()); Assert.assertEquals(1, ((Number) hits.get(0).get("rank")).intValue()); Assert.assertEquals("dense", String.valueOf(hits.get(0).get("retrieverSource"))); Assert.assertEquals(0.91d, ((Number) hits.get(0).get("retrievalScore")).doubleValue(), 0.0001d); } private static FlowGramNodeSchema node(String id, String type, Map data) { return FlowGramNodeSchema.builder() .id(id) .type(type) .name(id) .data(data) .build(); } private static Map mapOf(Object... keyValues) { Map map = new LinkedHashMap(); for (int i = 0; i < keyValues.length; i += 2) { map.put(String.valueOf(keyValues[i]), keyValues[i + 1]); } return map; } private static class FakeRegistry implements AiServiceRegistry { @Override public AiServiceRegistration find(String id) { return null; } @Override public Set ids() { return Collections.singleton("main"); } @Override public IEmbeddingService getEmbeddingService(String id) { return new IEmbeddingService() { @Override public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) { return embedding(embeddingReq); } @Override public EmbeddingResponse embedding(Embedding embeddingReq) { return EmbeddingResponse.builder() .object("list") .model(embeddingReq.getModel()) .data(Collections.singletonList(EmbeddingObject.builder() .index(0) .embedding(Arrays.asList(0.1f, 0.2f)) .object("embedding") .build())) .build(); } }; } @Override public IChatService getChatService(String id) { return null; } @Override public IAudioService getAudioService(String id) { return null; } @Override public IRealtimeService getRealtimeService(String id) { return null; } @Override public IImageService getImageService(String id) { return null; } @Override public IResponsesService getResponsesService(String id) { return null; } } private static class FakeVectorStore implements VectorStore { @Override public int upsert(VectorUpsertRequest request) { return 0; } @Override public List search(VectorSearchRequest request) { return Collections.singletonList(VectorSearchResult.builder() .id("chunk-1") .score(0.91f) .content("Employees receive 15 days of annual leave.") .metadata(mapOf( "sourceName", "employee-handbook.pdf", "sourcePath", "/docs/employee-handbook.pdf", "pageNumber", "6", "sectionTitle", "Vacation Policy" )) .build()); } @Override public boolean delete(VectorDeleteRequest request) { return false; } @Override public VectorStoreCapabilities capabilities() { return VectorStoreCapabilities.builder().dataset(true).metadataFilter(true).build(); } } } ================================================ FILE: ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/support/JdbcFlowGramTaskStoreTest.java ================================================ package io.github.lnyocly.ai4j.flowgram.springboot.support; import org.h2.jdbcx.JdbcDataSource; import org.junit.Test; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class JdbcFlowGramTaskStoreTest { @Test public void shouldPersistAndUpdateTaskState() { JdbcFlowGramTaskStore store = new JdbcFlowGramTaskStore(dataSource("store"), "ai4j_flowgram_task_test", true); store.save(FlowGramStoredTask.builder() .taskId("task-1") .creatorId("user-1") .tenantId("tenant-1") .createdAt(100L) .expiresAt(200L) .status("pending") .terminated(false) .resultSnapshot(Collections.singletonMap("step", "start")) .build()); Map result = new LinkedHashMap(); result.put("answer", "ok"); store.updateState("task-1", "success", true, null, result); FlowGramStoredTask task = store.find("task-1"); assertNotNull(task); assertEquals("user-1", task.getCreatorId()); assertEquals("tenant-1", task.getTenantId()); assertEquals("success", task.getStatus()); assertTrue(Boolean.TRUE.equals(task.getTerminated())); assertEquals("ok", task.getResultSnapshot().get("answer")); } private JdbcDataSource dataSource(String suffix) { JdbcDataSource dataSource = new JdbcDataSource(); dataSource.setURL("jdbc:h2:mem:ai4j_flowgram_" + suffix + ";MODE=MYSQL;DB_CLOSE_DELAY=-1"); dataSource.setUser("sa"); dataSource.setPassword(""); return dataSource; } } ================================================ FILE: ai4j-flowgram-webapp-demo/.eslintrc.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineConfig({ preset: 'web', packageRoot: __dirname, rules: { 'no-console': 'off', 'react/prop-types': 'off', }, settings: { react: { version: 'detect', // 自动检测 React 版本 }, }, }); ================================================ FILE: ai4j-flowgram-webapp-demo/.gitignore ================================================ node_modules/ dist/ .rsbuild/ ================================================ FILE: ai4j-flowgram-webapp-demo/README.md ================================================ # FlowGram.AI - Demo Free Layout Best-practice demo for free layout ## Installation ```shell npx @flowgram.ai/create-app@latest free-layout ``` ## Project Overview ### Core Tech Stack - **Frontend framework**: React 18 + TypeScript - **Build tool**: Rsbuild (a modern build tool based on Rspack) - **Styling**: Less + Styled Components + CSS Variables - **UI library**: Semi Design (@douyinfe/semi-ui) - **State management**: Flowgram’s in-house editor framework - **Dependency injection**: Inversify ### Core Dependencies - **@flowgram.ai/free-layout-editor**: Core dependency for the free layout editor - **@flowgram.ai/free-snap-plugin**: Auto-alignment and guide-lines plugin - **@flowgram.ai/free-lines-plugin**: Connection line rendering plugin - **@flowgram.ai/free-node-panel-plugin**: Node add-panel rendering plugin - **@flowgram.ai/minimap-plugin**: Minimap plugin - **@flowgram.ai/export-plugin**: Download/export plugin - **@flowgram.ai/free-container-plugin**: Sub-canvas plugin - **@flowgram.ai/free-group-plugin**: Grouping plugin - **@flowgram.ai/form-materials**: Form materials - **@flowgram.ai/runtime-interface**: Runtime interfaces - **@flowgram.ai/runtime-js**: JS runtime module - **@flowgram.ai/panel-manager-plugin**: Sidebar panel management ## Code Guide ### Directory Structure ``` src/ ├── app.tsx # Application entry file ├── editor.tsx # Main editor component ├── initial-data.ts # Initial data configuration ├── assets/ # Static assets ├── components/ # Component library │ ├── index.ts │ ├── add-node/ # Add-node component │ ├── base-node/ # Base node components │ ├── comment/ # Comment components │ ├── group/ # Group components │ ├── line-add-button/ # Connection add button │ ├── node-menu/ # Node menu │ ├── node-panel/ # Node add panel │ ├── selector-box-popover/ # Selection box popover │ ├── sidebar/ # Sidebar │ ├── testrun/ # Test-run module │ │ ├── hooks/ # Test-run hooks │ │ ├── node-status-bar/ # Node status bar │ │ ├── testrun-button/ # Test-run button │ │ ├── testrun-form/ # Test-run form │ │ ├── testrun-json-input/ # JSON input component │ │ └── testrun-panel/ # Test-run panel │ └── tools/ # Utility components ├── context/ # React Context │ ├── node-render-context.ts # Current rendering node context │ ├── sidebar-context # Sidebar context ├── form-components/ # Form component library │ ├── form-content/ # Form content │ ├── form-header/ # Form header │ ├── form-inputs/ # Form inputs │ └── form-item/ # Form item │ └── feedback.tsx # Validation error rendering ├── hooks/ │ ├── index.ts │ ├── use-editor-props.tsx # Editor props hook │ ├── use-is-sidebar.ts # Sidebar state hook │ ├── use-node-render-context.ts # Node render context hook │ └── use-port-click.ts # Port click hook ├── nodes/ # Node definitions │ ├── index.ts │ ├── constants.ts # Node constants │ ├── default-form-meta.ts # Default form metadata │ ├── block-end/ # Block end node │ ├── block-start/ # Block start node │ ├── break/ # Break node │ ├── code/ # Code node │ ├── comment/ # Comment node │ ├── condition/ # Condition node │ ├── continue/ # Continue node │ ├── end/ # End node │ ├── group/ # Group node │ ├── http/ # HTTP node │ ├── llm/ # LLM node │ ├── loop/ # Loop node │ ├── start/ # Start node │ └── variable/ # Variable node ├── plugins/ # Plugin system │ ├── index.ts │ ├── context-menu-plugin/ # Right-click context menu plugin │ ├── runtime-plugin/ # Runtime plugin │ │ ├── client/ # Client │ │ │ ├── browser-client/ # Browser client │ │ │ └── server-client/ # Server client │ │ └── runtime-service/ # Runtime service │ └── variable-panel-plugin/ # Variable panel plugin │ └── components/ # Variable panel components ├── services/ # Service layer │ ├── index.ts │ └── custom-service.ts # Custom service ├── shortcuts/ # Shortcuts system │ ├── index.ts │ ├── constants.ts # Shortcut constants │ ├── shortcuts.ts # Shortcut definitions │ ├── type.ts # Type definitions │ ├── collapse/ # Collapse shortcut │ ├── copy/ # Copy shortcut │ ├── delete/ # Delete shortcut │ ├── expand/ # Expand shortcut │ ├── paste/ # Paste shortcut │ ├── select-all/ # Select-all shortcut │ ├── zoom-in/ # Zoom-in shortcut │ └── zoom-out/ # Zoom-out shortcut ├── styles/ # Styles ├── typings/ # Type definitions │ ├── index.ts │ ├── json-schema.ts # JSON Schema types │ └── node.ts # Node type definitions └── utils/ # Utility functions ├── index.ts └── on-drag-line-end.ts # Handle end of drag line ``` ### Key Directory Functions #### 1. `/components` - Component Library - **base-node**: Base rendering components for all nodes - **testrun**: Complete test-run module, including status bar, form, and panel - **sidebar**: Sidebar components providing tools and property panels - **node-panel**: Node add panel with drag-to-add capability #### 2. `/nodes` - Node System Each node type has its own directory, including: - Node registration (`index.ts`) - Form metadata (`form-meta.ts`) - Node-specific components and logic #### 3. `/plugins` - Plugin System - **runtime-plugin**: Supports both browser and server modes - **context-menu-plugin**: Right-click context menu - **variable-panel-plugin**: Variable management panel #### 4. `/shortcuts` - Shortcuts System Complete keyboard shortcut support, including: - Basic actions: copy, paste, delete, select-all - View actions: zoom-in, zoom-out, collapse, expand - Each shortcut has its own implementation module ## Application Architecture ### Core Design Patterns #### 1. Plugin Architecture Highly modular plugin system; each feature is an independent plugin: ```typescript plugins: () => [ createFreeLinesPlugin({ renderInsideLine: LineAddButton }), createMinimapPlugin({ /* config */ }), createFreeSnapPlugin({ /* alignment config */ }), createFreeNodePanelPlugin({ renderer: NodePanel }), createContainerNodePlugin({}), createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }), createContextMenuPlugin({}), createRuntimePlugin({ mode: 'browser' }), createVariablePanelPlugin({}) ] ``` #### 2. Node Registry Pattern Manage different workflow node types via a registry: ```typescript export const nodeRegistries: FlowNodeRegistry[] = [ ConditionNodeRegistry, // Condition node StartNodeRegistry, // Start node EndNodeRegistry, // End node LLMNodeRegistry, // LLM node LoopNodeRegistry, // Loop node CommentNodeRegistry, // Comment node HTTPNodeRegistry, // HTTP node CodeNodeRegistry, // Code node // ... more node types ]; ``` #### 3. Dependency Injection Use Inversify for service DI: ```typescript onBind: ({ bind }) => { bind(CustomService).toSelf().inSingletonScope(); } ``` ## Core Features ### 1. Editor Configuration System `useEditorProps` is the configuration center of the editor: ```typescript export function useEditorProps( initialData: FlowDocumentJSON, nodeRegistries: FlowNodeRegistry[] ): FreeLayoutProps { return useMemo(() => ({ background: true, // Background grid readonly: false, // Readonly mode initialData, // Initial data nodeRegistries, // Node registries // Core feature configs playground: { preventGlobalGesture: true /* Prevent Mac browser swipe gestures */ }, nodeEngine: { enable: true }, variableEngine: { enable: true }, history: { enable: true, enableChangeNode: true }, // Business rules canAddLine: (ctx, fromPort, toPort) => { /* Connection rules */ }, canDeleteLine: (ctx, line) => { /* Line deletion rules */ }, canDeleteNode: (ctx, node) => { /* Node deletion rules */ }, canDropToNode: (ctx, params) => { /* Drag-and-drop rules */ }, // Plugins plugins: () => [/* Plugin list */], // Events onContentChange: debounce((ctx, event) => { /* Auto save */ }, 1000), onInit: (ctx) => { /* Initialization */ }, onAllLayersRendered: (ctx) => { /* After render */ } }), []); } ``` ### 2. Node Type System The app supports multiple workflow node types: ```typescript export enum WorkflowNodeType { Start = 'start', // Start node End = 'end', // End node LLM = 'llm', // Large language model node HTTP = 'http', // HTTP request node Code = 'code', // Code execution node Variable = 'variable', // Variable node Condition = 'condition', // Conditional node Loop = 'loop', // Loop node BlockStart = 'block-start', // Sub-canvas start node BlockEnd = 'block-end', // Sub-canvas end node Comment = 'comment', // Comment node Continue = 'continue', // Continue node Break = 'break', // Break node } ``` Each node follows a unified registration pattern: ```typescript export const StartNodeRegistry: FlowNodeRegistry = { type: WorkflowNodeType.Start, meta: { isStart: true, deleteDisable: true, // Not deletable copyDisable: true, // Not copyable nodePanelVisible: false, // Hidden in node panel defaultPorts: [{ type: 'output' }], size: { width: 360, height: 211 } }, info: { icon: iconStart, description: 'The starting node of the workflow, used to set up information needed to launch the workflow.' }, formMeta, // Form configuration canAdd() { return false; } // Disallow multiple start nodes }; ``` ### 3. Plugin Architecture App features are modularized via the plugin system: #### Core Plugin List 1. **FreeLinesPlugin** - Connection rendering and interaction 2. **MinimapPlugin** - Minimap navigation 3. **FreeSnapPlugin** - Auto-alignment and guide-lines 4. **FreeNodePanelPlugin** - Node add panel 5. **ContainerNodePlugin** - Container nodes (e.g., loop nodes) 6. **FreeGroupPlugin** - Node grouping 7. **ContextMenuPlugin** - Right-click context menu 8. **RuntimePlugin** - Workflow runtime 9. **VariablePanelPlugin** - Variable management panel ### 4. Runtime System Two run modes are supported: ```typescript createRuntimePlugin({ mode: 'browser', // Browser mode // mode: 'server', // Server mode // serverConfig: { // domain: 'localhost', // port: 4000, // protocol: 'http', // }, }) ``` ## Design Philosophy and Advantages ### 1. Highly Modular - **Plugin architecture**: Each feature is an independent plugin, easy to extend and maintain - **Node registry system**: Add new node types without changing core code - **Componentized UI**: Highly reusable components with clear responsibilities ### 2. Type Safety - **Full TypeScript support**: End-to-end type safety from configuration to runtime - **JSON Schema integration**: Node data validated by schemas - **Strongly typed plugin interfaces**: Clear type constraints for plugin development ### 3. User Experience - **Real-time preview**: Run and debug workflows live - **Rich interactions**: Dragging, zooming, snapping, shortcuts for a complete editing experience - **Visual feedback**: Minimap, status indicators, line animations ### 4. Extensibility - **Open plugin system**: Third parties can easily develop custom plugins - **Flexible node system**: Custom node types and form configurations supported - **Multiple runtimes**: Both browser and server modes ### 5. Performance - **On-demand loading**: Components and plugins support lazy loading - **Debounce**: Performance optimizations for high-frequency operations like auto-save ## Technical Highlights ### 1. In-house Editor Framework Based on `@flowgram.ai/free-layout-editor`, providing: - Free-layout canvas system - Full undo/redo functionality - Lifecycle management for nodes and connections - Variable engine and expression system ### 2. Advanced Build Configuration Using Rsbuild as the build tool: ```typescript export default defineConfig({ plugins: [pluginReact(), pluginLess()], source: { entry: { index: './src/app.tsx' }, decorators: { version: 'legacy' } // Enable decorators }, tools: { rspack: { ignoreWarnings: [/Critical dependency/] // Ignore specific warnings } } }); ``` ### 3. Internationalization Built-in multilingual support: ```typescript i18n: { locale: navigator.language, languages: { 'zh-CN': { 'Never Remind': '不再提示', 'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出', }, 'en-US': {}, } } ``` ================================================ FILE: ai4j-flowgram-webapp-demo/README.zh_CN.md ================================================ # FlowGram.AI - Demo Free Layout 自由布局最佳实践 demo ## 安装 ```shell npx @flowgram.ai/create-app@latest free-layout ``` ## 项目概览 ### 核心技术栈 - **前端框架**: React 18 + TypeScript - **构建工具**: Rsbuild (基于 Rspack 的现代构建工具) - **样式方案**: Less + Styled Components + CSS Variables - **UI 组件库**: Semi Design (@douyinfe/semi-ui) - **状态管理**: 基于 Flowgram 自研的编辑器框架 - **依赖注入**: Inversify ### 核心依赖包 - **@flowgram.ai/free-layout-editor**: 自由布局编辑器核心依赖 - **@flowgram.ai/free-snap-plugin**: 自动对齐及辅助线插件 - **@flowgram.ai/free-lines-plugin**: 连线渲染插件 - **@flowgram.ai/free-node-panel-plugin**: 节点添加面板渲染插件 - **@flowgram.ai/minimap-plugin**: 缩略图插件 - **@flowgram.ai/export-plugin**: 下载导出插件 - **@flowgram.ai/free-container-plugin**: 子画布插件 - **@flowgram.ai/free-group-plugin**: 分组插件 - **@flowgram.ai/form-materials**: 表单物料 - **@flowgram.ai/runtime-interface**: 运行时接口 - **@flowgram.ai/runtime-js**: js 运行时模块 - **@flowgram.ai/panel-manager-plugin**: 侧边栏面板管理 ## 代码说明 ### 目录结构 ``` src/ ├── app.tsx # 应用入口文件 ├── editor.tsx # 编辑器主组件 ├── initial-data.ts # 初始化数据配置 ├── assets/ # 静态资源 ├── components/ # 组件库 │ ├── index.ts │ ├── add-node/ # 添加节点组件 │ ├── base-node/ # 基础节点组件 │ ├── comment/ # 注释组件 │ ├── group/ # 分组组件 │ ├── line-add-button/ # 连线添加按钮 │ ├── node-menu/ # 节点菜单 │ ├── node-panel/ # 节点添加面板 │ ├── selector-box-popover/ # 选择框弹窗 │ ├── sidebar/ # 侧边栏 │ ├── testrun/ # 测试运行组件 │ │ ├── hooks/ # 测试运行钩子 │ │ ├── node-status-bar/ # 节点状态栏 │ │ ├── testrun-button/ # 测试运行按钮 │ │ ├── testrun-form/ # 测试运行表单 │ │ ├── testrun-json-input/ # JSON输入组件 │ │ └── testrun-panel/ # 测试运行面板 │ └── tools/ # 工具组件 ├── context/ # React Context │ ├── node-render-context.ts # 当前渲染节点 Context │ ├── sidebar-context # 侧边栏 Context ├── form-components/ # 表单组件库 │ ├── form-content/ # 表单内容 │ ├── form-header/ # 表单头部 │ ├── form-inputs/ # 表单输入 │ └── form-item/ # 表单项 │ └── feedback.tsx # 表单校验错误渲染 ├── hooks/ │ ├── index.ts │ ├── use-editor-props.tsx # 编辑器属性钩子 │ ├── use-is-sidebar.ts # 侧边栏状态钩子 │ ├── use-node-render-context.ts # 节点渲染上下文钩子 │ └── use-port-click.ts # 端口点击钩子 ├── nodes/ # 节点定义 │ ├── index.ts │ ├── constants.ts # 节点常量定义 │ ├── default-form-meta.ts # 默认表单元数据 │ ├── block-end/ # 块结束节点 │ ├── block-start/ # 块开始节点 │ ├── break/ # 中断节点 │ ├── code/ # 代码节点 │ ├── comment/ # 注释节点 │ ├── condition/ # 条件节点 │ ├── continue/ # 继续节点 │ ├── end/ # 结束节点 │ ├── group/ # 分组节点 │ ├── http/ # HTTP节点 │ ├── llm/ # LLM节点 │ ├── loop/ # 循环节点 │ ├── start/ # 开始节点 │ └── variable/ # 变量节点 ├── plugins/ # 插件系统 │ ├── index.ts │ ├── context-menu-plugin/ # 右键菜单插件 │ ├── runtime-plugin/ # 运行时插件 │ │ ├── client/ # 客户端 │ │ │ ├── browser-client/ # 浏览器客户端 │ │ │ └── server-client/ # 服务器客户端 │ │ └── runtime-service/ # 运行时服务 │ └── variable-panel-plugin/ # 变量面板插件 │ └── components/ # 变量面板组件 ├── services/ # 服务层 │ ├── index.ts │ └── custom-service.ts # 自定义服务 ├── shortcuts/ # 快捷键系统 │ ├── index.ts │ ├── constants.ts # 快捷键常量 │ ├── shortcuts.ts # 快捷键定义 │ ├── type.ts # 类型定义 │ ├── collapse/ # 折叠快捷键 │ ├── copy/ # 复制快捷键 │ ├── delete/ # 删除快捷键 │ ├── expand/ # 展开快捷键 │ ├── paste/ # 粘贴快捷键 │ ├── select-all/ # 全选快捷键 │ ├── zoom-in/ # 放大快捷键 │ └── zoom-out/ # 缩小快捷键 ├── styles/ # 样式文件 ├── typings/ # 类型定义 │ ├── index.ts │ ├── json-schema.ts # JSON Schema类型 │ └── node.ts # 节点类型定义 └── utils/ # 工具函数 ├── index.ts └── on-drag-line-end.ts # 拖拽连线结束处理 ``` ### 关键目录功能说明 #### 1. `/components` - 组件库 - **base-node**: 所有节点的基础渲染组件 - **testrun**: 完整的测试运行功能模块,包含状态栏、表单、面板等 - **sidebar**: 侧边栏组件,提供工具和属性面板 - **node-panel**: 节点添加面板,支持拖拽添加新节点 #### 2. `/nodes` - 节点系统 每个节点类型都有独立的目录,包含: - 节点注册信息 (`index.ts`) - 表单元数据定义 (`form-meta.ts`) - 节点特定的组件和逻辑 #### 3. `/plugins` - 插件系统 - **runtime-plugin**: 支持浏览器和服务器两种运行模式 - **context-menu-plugin**: 右键菜单功能 - **variable-panel-plugin**: 变量管理面板 #### 4. `/shortcuts` - 快捷键系统 完整的快捷键支持,包括: - 基础操作:复制、粘贴、删除、全选 - 视图操作:放大、缩小、折叠、展开 - 每个快捷键都有独立的实现模块 ## 应用架构设计 ### 核心设计模式 #### 1. 插件化架构 (Plugin Architecture) 应用采用高度模块化的插件系统,每个功能都作为独立插件存在: ```typescript plugins: () => [ createFreeLinesPlugin({ renderInsideLine: LineAddButton }), createMinimapPlugin({ /* 配置 */ }), createFreeSnapPlugin({ /* 对齐配置 */ }), createFreeNodePanelPlugin({ renderer: NodePanel }), createContainerNodePlugin({}), createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }), createContextMenuPlugin({}), createRuntimePlugin({ mode: 'browser' }), createVariablePanelPlugin({}) ] ``` #### 2. 节点注册系统 (Node Registry Pattern) 通过注册表模式管理不同类型的工作流节点: ```typescript export const nodeRegistries: FlowNodeRegistry[] = [ ConditionNodeRegistry, // 条件节点 StartNodeRegistry, // 开始节点 EndNodeRegistry, // 结束节点 LLMNodeRegistry, // LLM节点 LoopNodeRegistry, // 循环节点 CommentNodeRegistry, // 注释节点 HTTPNodeRegistry, // HTTP节点 CodeNodeRegistry, // 代码节点 // ... 更多节点类型 ]; ``` #### 3. 依赖注入模式 (Dependency Injection) 使用 Inversify 框架实现服务的依赖注入: ```typescript onBind: ({ bind }) => { bind(CustomService).toSelf().inSingletonScope(); } ``` ## 核心功能分析 ### 1. 编辑器配置系统 `useEditorProps` 是整个编辑器的配置中心,包含: ```typescript export function useEditorProps( initialData: FlowDocumentJSON, nodeRegistries: FlowNodeRegistry[] ): FreeLayoutProps { return useMemo(() => ({ background: true, // 背景网格 readonly: false, // 是否只读 initialData, // 初始数据 nodeRegistries, // 节点注册表 // 核心功能配置 playground: { preventGlobalGesture: true /* 阻止 mac 浏览器手势翻页 */ }, nodeEngine: { enable: true }, variableEngine: { enable: true }, history: { enable: true, enableChangeNode: true }, // 业务逻辑配置 canAddLine: (ctx, fromPort, toPort) => { /* 连线规则 */ }, canDeleteLine: (ctx, line) => { /* 删除连线规则 */ }, canDeleteNode: (ctx, node) => { /* 删除节点规则 */ }, canDropToNode: (ctx, params) => { /* 拖拽规则 */ }, // 插件配置 plugins: () => [/* 插件列表 */], // 事件处理 onContentChange: debounce((ctx, event) => { /* 自动保存 */ }, 1000), onInit: (ctx) => { /* 初始化 */ }, onAllLayersRendered: (ctx) => { /* 渲染完成 */ } }), []); } ``` ### 2. 节点类型系统 应用支持多种工作流节点类型: ```typescript export enum WorkflowNodeType { Start = 'start', // 开始节点 End = 'end', // 结束节点 LLM = 'llm', // 大语言模型节点 HTTP = 'http', // HTTP请求节点 Code = 'code', // 代码执行节点 Variable = 'variable', // 变量节点 Condition = 'condition', // 条件判断节点 Loop = 'loop', // 循环节点 BlockStart = 'block-start', // 子画布开始节点 BlockEnd = 'block-end', // 子画布结束节点 Comment = 'comment', // 注释节点 Continue = 'continue', // 继续节点 Break = 'break', // 中断节点 } ``` 每个节点都遵循统一的注册模式: ```typescript export const StartNodeRegistry: FlowNodeRegistry = { type: WorkflowNodeType.Start, meta: { isStart: true, deleteDisable: true, // 不可删除 copyDisable: true, // 不可复制 nodePanelVisible: false, // 不在节点面板显示 defaultPorts: [{ type: 'output' }], size: { width: 360, height: 211 } }, info: { icon: iconStart, description: '工作流的起始节点,用于设置启动工作流所需的信息。' }, formMeta, // 表单配置 canAdd() { return false; } // 不允许添加多个开始节点 }; ``` ### 3. 插件化架构 应用的功能通过插件系统实现模块化: #### 核心插件列表 1. **FreeLinesPlugin** - 连线渲染和交互 2. **MinimapPlugin** - 缩略图导航 3. **FreeSnapPlugin** - 自动对齐和辅助线 4. **FreeNodePanelPlugin** - 节点添加面板 5. **ContainerNodePlugin** - 容器节点(如循环节点) 6. **FreeGroupPlugin** - 节点分组功能 7. **ContextMenuPlugin** - 右键菜单 8. **RuntimePlugin** - 工作流运行时 9. **VariablePanelPlugin** - 变量管理面板 ### 4. 运行时系统 应用支持两种运行模式: ```typescript createRuntimePlugin({ mode: 'browser', // 浏览器模式 // mode: 'server', // 服务器模式 // serverConfig: { // domain: 'localhost', // port: 4000, // protocol: 'http', // }, }) ``` ## 设计理念与架构优势 ### 1. 高度模块化 - **插件化架构**: 每个功能都是独立插件,易于扩展和维护 - **节点注册系统**: 新节点类型可以轻松添加,无需修改核心代码 - **组件化设计**: UI组件高度复用,职责清晰 ### 2. 类型安全 - **完整的TypeScript支持**: 从配置到运行时的全链路类型保护 - **JSON Schema集成**: 节点数据结构通过Schema验证 - **强类型的插件接口**: 插件开发有明确的类型约束 ### 3. 用户体验优化 - **实时预览**: 支持工作流的实时运行和调试 - **丰富的交互**: 拖拽、缩放、对齐、快捷键等完整的编辑体验 - **可视化反馈**: 缩略图、状态指示、连线动画等视觉反馈 ### 4. 扩展性设计 - **开放的插件系统**: 第三方可以轻松开发自定义插件 - **灵活的节点系统**: 支持自定义节点类型和表单配置 - **多运行时支持**: 浏览器和服务器双模式运行 ### 5. 性能优化 - **按需加载**: 组件和插件支持按需加载 - **防抖处理**: 自动保存等高频操作的性能优化 ## 技术亮点 ### 1. 自研编辑器框架 基于 `@flowgram.ai/free-layout-editor` 自研框架,提供: - 自由布局的画布系统 - 完整的撤销/重做功能 - 节点和连线的生命周期管理 - 变量引擎和表达式系统 ### 2. 先进的构建配置 使用 Rsbuild 作为构建工具: ```typescript export default defineConfig({ plugins: [pluginReact(), pluginLess()], source: { entry: { index: './src/app.tsx' }, decorators: { version: 'legacy' } // 支持装饰器 }, tools: { rspack: { ignoreWarnings: [/Critical dependency/] // 忽略特定警告 } } }); ``` ### 3. 国际化支持 内置多语言支持: ```typescript i18n: { locale: navigator.language, languages: { 'zh-CN': { 'Never Remind': '不再提示', 'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出', }, 'en-US': {}, } } ``` ================================================ FILE: ai4j-flowgram-webapp-demo/index.html ================================================ AI4J FlowGram Workbench Demo
================================================ FILE: ai4j-flowgram-webapp-demo/package.json ================================================ { "name": "ai4j-flowgram-webapp-demo", "version": "0.0.1", "private": true, "description": "FlowGram workbench demo for ai4j Spring Boot integration", "keywords": [ "ai4j", "flowgram", "demo", "react", "rsbuild" ], "license": "MIT", "main": "./src/index.ts", "overrides": { "@codemirror/state": "6.6.0" }, "files": [ "src/", ".eslintrc.js", ".gitignore", "index.html", "package.json", "rsbuild.config.ts", "tsconfig.json", "README.md", "README.zh_CN.md" ], "dependencies": { "@douyinfe/semi-icons": "^2.80.0", "@douyinfe/semi-ui": "^2.80.0", "lodash-es": "^4.17.21", "nanoid": "^5.0.9", "react": "^18", "react-dom": "^18", "styled-components": "^5", "classnames": "^2.5.1", "@flowgram.ai/runtime-interface": "1.0.9", "@flowgram.ai/free-layout-editor": "1.0.9", "@flowgram.ai/free-snap-plugin": "1.0.9", "@flowgram.ai/free-lines-plugin": "1.0.9", "@flowgram.ai/free-node-panel-plugin": "1.0.9", "@flowgram.ai/export-plugin": "1.0.9", "@flowgram.ai/minimap-plugin": "1.0.9", "@flowgram.ai/free-container-plugin": "1.0.9", "@flowgram.ai/free-group-plugin": "1.0.9", "@flowgram.ai/panel-manager-plugin": "1.0.9", "@flowgram.ai/form-materials": "1.0.9", "@flowgram.ai/free-stack-plugin": "1.0.9", "@flowgram.ai/runtime-js": "1.0.9" }, "devDependencies": { "@rsbuild/core": "^1.2.16", "@rsbuild/plugin-react": "^1.1.1", "@rsbuild/plugin-less": "^1.1.1", "@types/lodash-es": "^4.17.12", "@types/node": "^18", "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5", "typescript": "^5.8.3", "eslint": "^8.54.0", "cross-env": "~7.0.3", "@flowgram.ai/ts-config": "1.0.9", "@flowgram.ai/eslint-config": "1.0.9" }, "scripts": { "build": "cross-env MODE=app NODE_ENV=production rsbuild build", "build:analyze": "BUNDLE_ANALYZE=true rsbuild build", "dev": "cross-env MODE=app NODE_ENV=development rsbuild dev", "lint": "eslint ./src --cache", "lint:fix": "eslint ./src --fix", "ts-check": "tsc --noEmit", "start": "cross-env MODE=app NODE_ENV=development rsbuild dev", "test": "exit", "test:cov": "exit", "watch": "exit 0" } } ================================================ FILE: ai4j-flowgram-webapp-demo/rsbuild.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { pluginReact } from '@rsbuild/plugin-react'; import { pluginLess } from '@rsbuild/plugin-less'; import { defineConfig } from '@rsbuild/core'; export default defineConfig({ plugins: [pluginReact(), pluginLess()], resolve: { dedupe: ['@codemirror/state', '@codemirror/view'], }, source: { entry: { index: './src/app.tsx', }, /** * support inversify @injectable() and @inject decorators */ decorators: { version: 'legacy', }, }, html: { title: 'AI4J FlowGram Workbench Demo', }, server: { host: '127.0.0.1', port: 5173, open: false, proxy: { '/flowgram': 'http://127.0.0.1:18080', }, }, tools: { rspack: { /** * ignore warnings from @coze-editor/editor/language-typescript */ ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/], }, }, }); ================================================ FILE: ai4j-flowgram-webapp-demo/src/app.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { createRoot } from 'react-dom/client'; import { unstableSetCreateRoot } from '@flowgram.ai/form-materials'; import { Editor } from './editor'; /** * React 18/19 polyfill for form-materials */ unstableSetCreateRoot(createRoot); const app = createRoot(document.getElementById('root')!); app.render(); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-auto-layout.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const IconAutoLayout = ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-cancel.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ interface Props { className?: string; style?: React.CSSProperties; } export const IconCancel = ({ className, style }: Props) => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-comment.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { CSSProperties, FC } from 'react'; interface IconCommentProps { style?: CSSProperties; } export const IconComment: FC = ({ style }) => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-minimap.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const IconMinimap = () => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-mouse.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export function IconMouse(props: { width?: number; height?: number }) { const { width, height } = props; return ( ); } export const IconMouseTool = () => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-pad.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export function IconPad(props: { width?: number; height?: number }) { const { width, height } = props; return ( ); } export const IconPadTool = () => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-success.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ interface Props { className?: string; style?: React.CSSProperties; } export const IconSuccessFill = ({ className, style }: Props) => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-switch-line.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const IconSwitchLine = ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/assets/icon-warning.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ interface Props { className?: string; style?: React.CSSProperties; } export const IconWarningFill = ({ className, style }: Props) => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/add-node/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Button } from '@douyinfe/semi-ui'; import { IconPlus } from '@douyinfe/semi-icons'; import { useAddNode } from './use-add-node'; export const AddNode = (props: { disabled: boolean }) => { const addNode = useAddNode(); return ( ); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/add-node/use-add-node.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useCallback } from 'react'; import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin'; import { useService, WorkflowDocument, usePlayground, PositionSchema, WorkflowNodeEntity, WorkflowSelectService, WorkflowNodeJSON, getAntiOverlapPosition, WorkflowNodeMeta, FlowNodeBaseType, } from '@flowgram.ai/free-layout-editor'; // hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook const useGetPanelPosition = () => { const playground = usePlayground(); return useCallback( (targetBoundingRect: DOMRect): PositionSchema => // convert mouse position to canvas position - 将鼠标位置转换为画布位置 playground.config.getPosFromMouseEvent({ clientX: targetBoundingRect.left + 64, clientY: targetBoundingRect.top - 7, }), [playground] ); }; // hook to handle node selection - 处理节点选择的 hook const useSelectNode = () => { const selectService = useService(WorkflowSelectService); return useCallback( (node?: WorkflowNodeEntity) => { if (!node) { return; } // select the target node - 选择目标节点 selectService.selectNode(node); }, [selectService] ); }; const getContainerNode = (selectService: WorkflowSelectService) => { const { activatedNode } = selectService; if (!activatedNode) { return; } const { isContainer } = activatedNode.getNodeMeta(); if (isContainer) { return activatedNode; } const parentNode = activatedNode.parent; if (!parentNode || parentNode.flowNodeType === FlowNodeBaseType.ROOT) { return; } return parentNode; }; // main hook for adding new nodes - 添加新节点的主 hook export const useAddNode = () => { const workflowDocument = useService(WorkflowDocument); const nodePanelService = useService(WorkflowNodePanelService); const selectService = useService(WorkflowSelectService); const playground = usePlayground(); const getPanelPosition = useGetPanelPosition(); const select = useSelectNode(); return useCallback( async (targetBoundingRect: DOMRect): Promise => { // calculate panel position based on target element - 根据目标元素计算面板位置 const panelPosition = getPanelPosition(targetBoundingRect); const containerNode = getContainerNode(selectService); await new Promise((resolve) => { // call the node panel service to show the panel - 调用节点面板服务来显示面板 nodePanelService.callNodePanel({ position: panelPosition, enableMultiAdd: true, containerNode, panelProps: {}, // handle node selection from panel - 处理从面板中选择节点 onSelect: async (panelParams?: NodePanelResult) => { if (!panelParams) { return; } const { nodeType, nodeJSON } = panelParams; const position = Boolean(containerNode) ? getAntiOverlapPosition(workflowDocument, { x: 0, y: 200, }) : undefined; // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点 const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType( nodeType, position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点 nodeJSON ?? ({} as WorkflowNodeJSON), containerNode?.id ); select(node); }, // handle panel close - 处理面板关闭 onClose: () => { resolve(); }, }); }); }, [getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select] ); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/base-node/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useCallback } from 'react'; import { FlowNodeEntity, useClientContext, useNodeRender } from '@flowgram.ai/free-layout-editor'; import { ConfigProvider } from '@douyinfe/semi-ui'; import { NodeStatusBar } from '../testrun/node-status-bar'; import { NodeRenderContext } from '../../context'; import { ErrorIcon } from './styles'; import { NodeWrapper } from './node-wrapper'; export const BaseNode = ({ node }: { node: FlowNodeEntity }) => { /** * Provides methods related to node rendering * 提供节点渲染相关的方法 */ const nodeRender = useNodeRender(); const ctx = useClientContext(); /** * It can only be used when nodeEngine is enabled * 只有在节点引擎开启时候才能使用表单 */ const form = nodeRender.form; /** * Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library * 用于让 Tooltip 跟随节点缩放, 这个可以根据不同的 ui 库自己实现 */ const getPopupContainer = useCallback( () => ctx.playground.node.querySelector('.gedit-flow-render-layer') as HTMLDivElement, [] ); return ( {form?.state.invalid && } {form?.render()} ); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/base-node/node-wrapper.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState } from 'react'; import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor'; import { useClientContext } from '@flowgram.ai/free-layout-editor'; import { FlowNodeMeta } from '../../typings'; import { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks'; import { useNodeRenderContext, usePortClick } from '../../hooks'; import { scrollToView } from './utils'; import { NodeWrapperStyle } from './styles'; export interface NodeWrapperProps { isScrollToView?: boolean; children: React.ReactNode; } /** * Used for drag-and-drop/click events and ports rendering of nodes * 用于节点的拖拽/点击事件和点位渲染 */ export const NodeWrapper: React.FC = (props) => { const { children, isScrollToView = false } = props; const nodeRender = useNodeRenderContext(); const { node, selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur, readonly } = nodeRender; const [isDragging, setIsDragging] = useState(false); const form = nodeRender.form; const ctx = useClientContext(); const onPortClick = usePortClick(); const meta = node.getNodeMeta(); const { open } = useNodeFormPanel(); const portsRender = ports.map((p) => ( )); return ( <> { startDrag(e); setIsDragging(true); }} onTouchStart={(e) => { startDrag(e as unknown as React.MouseEvent); setIsDragging(true); }} onClick={(e) => { selectNode(e); if (!isDragging) { open({ nodeId: nodeRender.node.id, }); // 可选:将 isScrollToView 设为 true,可以让节点选中后滚动到画布中间 // Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected. if (isScrollToView) { scrollToView(ctx, nodeRender.node); } } }} onMouseUp={() => setIsDragging(false)} onFocus={onFocus} onBlur={onBlur} data-node-selected={String(selected)} style={{ ...meta.wrapperStyle, outline: form?.state.invalid ? '1px solid red' : 'none', }} > {children} {portsRender} ); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/base-node/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; import { IconInfoCircle } from '@douyinfe/semi-icons'; export const NodeWrapperStyle = styled.div` align-items: flex-start; background-color: #fff; border: 1px solid rgba(6, 7, 9, 0.15); border-radius: 8px; box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02); display: flex; flex-direction: column; justify-content: center; position: relative; width: 360px; height: auto; &.selected { border: 1px solid #4e40e5; } `; export const ErrorIcon = () => ( ); ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/base-node/utils.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FreeLayoutPluginContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor'; export function scrollToView( ctx: FreeLayoutPluginContext, node: FlowNodeEntity, sidebarWidth = 448 ) { const bounds = node.transform.bounds; ctx.playground.scrollToView({ bounds, scrollDelta: { x: sidebarWidth / 2, y: 0, }, zoom: 1, scrollToCenter: true, }); } ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/comment/components/blank-area.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { FC } from 'react'; import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor'; import type { CommentEditorModel } from '../model'; import { DragArea } from './drag-area'; interface IBlankArea { model: CommentEditorModel; } export const BlankArea: FC = (props) => { const { model } = props; const playground = usePlayground(); const { selectNode } = useNodeRender(); return (
{ e.preventDefault(); e.stopPropagation(); model.setFocus(false); selectNode(e); playground.node.focus(); // 防止节点无法被删除 }} onClick={(e) => { model.setFocus(true); model.selectEnd(); }} >
); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/comment/components/border-area.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type FC } from 'react'; import type { CommentEditorModel } from '../model'; import { ResizeArea } from './resize-area'; import { DragArea } from './drag-area'; interface IBorderArea { model: CommentEditorModel; overflow: boolean; onResize?: () => { resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void; resizeEnd: () => void; }; } export const BorderArea: FC = (props) => { const { model, overflow, onResize } = props; return (
{/* 左边 */} {/* 右边 */} {/* 上边 */} {/* 下边 */} {/** 左上角 */} ({ top: y, right: 0, bottom: 0, left: x })} onResize={onResize} /> {/** 右上角 */} ({ top: y, right: x, bottom: 0, left: 0 })} onResize={onResize} /> {/** 右下角 */} ({ top: 0, right: x, bottom: y, left: 0 })} onResize={onResize} /> {/** 左下角 */} ({ top: 0, right: 0, bottom: y, left: x })} onResize={onResize} />
); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/comment/components/container.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { ReactNode, FC, CSSProperties } from 'react'; interface ICommentContainer { focused: boolean; children?: ReactNode; style?: React.CSSProperties; } export const CommentContainer: FC = (props) => { const { focused, children, style } = props; const scrollbarStyle = { // 滚动条样式 scrollbarWidth: 'thin', scrollbarColor: 'rgb(159 159 158 / 65%) transparent', // 针对 WebKit 浏览器(如 Chrome、Safari)的样式 '&:WebkitScrollbar': { width: '4px', }, '&::WebkitScrollbarTrack': { background: 'transparent', }, '&::WebkitScrollbarThumb': { backgroundColor: 'rgb(159 159 158 / 65%)', borderRadius: '20px', border: '2px solid transparent', }, } as unknown as CSSProperties; return (
{children}
); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/comment/components/content-drag-area.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type FC, useState, useEffect, type WheelEventHandler } from 'react'; import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor'; import type { CommentEditorModel } from '../model'; import { DragArea } from './drag-area'; interface IContentDragArea { model: CommentEditorModel; focused: boolean; overflow: boolean; } export const ContentDragArea: FC = (props) => { const { model, focused, overflow } = props; const playground = usePlayground(); const { selectNode } = useNodeRender(); const [active, setActive] = useState(false); useEffect(() => { // 当编辑器失去焦点时,取消激活状态 if (!focused) { setActive(false); } }, [focused]); const handleWheel: WheelEventHandler = (e) => { const editorElement = model.element; if (active || !overflow || !editorElement) { return; } e.stopPropagation(); const maxScroll = editorElement.scrollHeight - editorElement.clientHeight; const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll); editorElement.scroll(0, newScrollTop); }; const handleMouseDown = (mouseDownEvent: React.MouseEvent) => { if (active) { return; } mouseDownEvent.preventDefault(); mouseDownEvent.stopPropagation(); model.setFocus(false); selectNode(mouseDownEvent); playground.node.focus(); // 防止节点无法被删除 const startX = mouseDownEvent.clientX; const startY = mouseDownEvent.clientY; const handleMouseUp = (mouseMoveEvent: MouseEvent) => { const deltaX = mouseMoveEvent.clientX - startX; const deltaY = mouseMoveEvent.clientY - startY; // 判断是拖拽还是点击 const delta = 5; if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) { // 点击后隐藏 setActive(true); } document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('click', handleMouseUp); }; document.addEventListener('mouseup', handleMouseUp); document.addEventListener('click', handleMouseUp); }; return (
); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/comment/components/drag-area.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { CSSProperties, MouseEvent, TouchEvent, type FC } from 'react'; import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor'; import { type CommentEditorModel } from '../model'; interface IDragArea { model: CommentEditorModel; stopEvent?: boolean; style?: CSSProperties; } export const DragArea: FC = (props) => { const { model, stopEvent = true, style } = props; const playground = usePlayground(); const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender(); const handleDrag = (e: MouseEvent | TouchEvent) => { if (stopEvent) { e.preventDefault(); e.stopPropagation(); } model.setFocus(false); onStartDrag(e as MouseEvent); selectNode(e as MouseEvent); playground.node.focus(); // 防止节点无法被删除 }; return (
); }; ================================================ FILE: ai4j-flowgram-webapp-demo/src/components/comment/components/editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type FC, type CSSProperties, useEffect, useRef } from 'react'; import { usePlayground } from '@flowgram.ai/free-layout-editor'; import { CommentEditorModel } from '../model'; import { usePlaceholder } from '../hooks'; import { CommentEditorEvent } from '../constant'; interface ICommentEditor { model: CommentEditorModel; style?: CSSProperties; value?: string; onChange?: (value: string) => void; } export const CommentEditor: FC = (props) => { const { model, style, onChange } = props; const playground = usePlayground(); const placeholder = usePlaceholder({ model }); const editorRef = useRef(null); // 同步编辑器内部值变化 useEffect(() => { const disposer = model.on((params) => { if (params.type !== CommentEditorEvent.Change) { return; } onChange?.(model.value); }); return () => disposer.dispose(); }, [model, onChange]); useEffect(() => { if (!editorRef.current) { return; } model.element = editorRef.current; }, [editorRef]); return (

{placeholder}