Compare commits

..

No commits in common. "main" and "v1.0.0-beta2" have entirely different histories.

269 changed files with 2084 additions and 32924 deletions

4
.gitignore vendored
View file

@ -83,7 +83,3 @@ agent/robot/ROBOT-CACHE-IMPROVEMENT.md
sandbox/v2/PID-KILL-UPGRADE.md sandbox/v2/PID-KILL-UPGRADE.md
sandbox/v2/*.md sandbox/v2/*.md
POSTGRESQL_COMPAT.md POSTGRESQL_COMPAT.md
openapi/setting/*.md
agent/docs/design/*.md
tools/README.md
tools/TOOL-REGISTRATION.md

61
COMMERCIAL_LICENSE.md Normal file
View file

@ -0,0 +1,61 @@
> **DEPRECATED**: This license is no longer in effect. Please refer to the [LICENSE](LICENSE) file for current licensing terms.
# Commercial License for Yao
This document outlines the terms for the commercial license of the **Yao** project. While the Yao project is primarily licensed under the **Apache License, Version 2.0**, certain commercial use cases require a separate commercial license.
## 1. Commercial License Requirements
The following use cases require a commercial license:
1. **Application Hosting Services**
If you use Yao, or any derivative product (such as a forked or modified version of Yao), to provide Yao-based application hosting services (e.g., Software-as-a-Service (SaaS) or Platform-as-a-Service (PaaS)) to users, you must obtain a commercial license. This restriction applies regardless of whether the original Yao code or a modified version is used to host and manage applications on behalf of third-party users for commercial purposes.
**In addition**, if you provide hosting services for applications that are built using Yao (even if they are customized or modified versions of Yao), a commercial license is required.
### Definition: Application Hosting Services
"Application Hosting Services" refers to any service that involves hosting Yao-based applications or web applications created with Yao (including modified versions of Yao) for third-party users. This includes, but is not limited to:
- **Hosting platforms** providing software or services built on top of Yao for third-party users.
- **SaaS or PaaS offerings** where you manage and host applications that are based on or utilize Yao, either in their original or modified form.
- **Managed hosting services** where Yao is used as the underlying technology for applications deployed for external clients.
In these cases, a commercial license is required, whether you are using the original Yao code or a fork/modified version.
2. **AI Web Application Generation Services**
If you provide services that generate AI-driven web applications using Yao, or any derivative product (such as a fork or modified version of Yao), to third-party users, you are required to purchase a commercial license.
### Definition: AI Web Application Generation Services
"AI Web Application Generation Services" refers to any service or functionality that utilizes Yao (or any forked or modified version of Yao) to automate the creation of web applications with AI capabilities. This includes, but is not limited to, providing third-party users with:
- **Automated web application development** driven by AI, where the service generates complete or partial web applications.
- **Customizable web solutions** that are powered by AI and built using Yao as the core technology.
- **On-demand application generation** for specific client needs, using Yao to dynamically build, configure, or deploy applications for users.
In these cases, whether Yao is directly used, forked, or modified, a commercial license is required to operate legally.
## 2. Use Under Apache License 2.0
For all other uses, the **Apache License, Version 2.0** applies. You are free to use, modify, and distribute the Yao project under the terms of Apache 2.0 as long as your usage does not fall within the restricted scenarios outlined above.
## 3. Obtaining a Commercial License
To inquire about or obtain a commercial license, please contact us at:
- **Email**: [friends@iqka.com]
- **Website**: [https://moapi.ai/contact]
Pricing and terms for commercial licenses vary based on usage scenarios, user scale, and other factors.
## 4. Compliance and Auditing
If you have any questions about whether your use case requires a commercial license, please contact us for clarification. We reserve the right to audit usage for compliance and enforce commercial licensing terms where necessary.
## 5. Disclaimer
Failure to comply with these licensing terms may result in a violation of the Yao licensing agreement and could lead to legal action.
---
**Note:** This commercial license is supplementary to the Apache 2.0 license and only applies in specific commercial scenarios outlined above.

View file

@ -0,0 +1,61 @@
> **已废弃**: 本许可证已不再生效。请参考 [LICENSE](LICENSE) 文件获取当前的许可条款。
# Yao 商业许可证
本文件概述了 **Yao** 项目的商业许可证条款。虽然 Yao 项目主要使用 **Apache 许可证 2.0 版** 授权,但某些商业使用场景需要单独的商业许可证。
## 1. 商业许可证要求
以下使用场景需要商业许可证:
1. **应用托管服务**
如果您使用 Yao 或其衍生产品(如 Yao 的分支版本或修改版本)为用户提供基于 Yao 的应用托管服务例如软件即服务SaaS或平台即服务PaaS您必须获得商业许可证。此限制适用于无论是否使用原始 Yao 代码或修改版 Yao 代码,托管和管理应用程序的行为只要是为第三方用户提供的商业目的。
**此外**,如果您提供的托管服务是为使用 Yao 构建的应用程序提供托管服务(即使它们是定制或修改版的 Yao也需要获得商业许可证。
### 定义:应用托管服务
"应用托管服务"指任何涉及托管基于 Yao 的应用程序或使用 Yao 创建的 WEB 应用程序(包括 Yao 的修改版本)的服务,服务对象为第三方用户。包括但不限于:
- **托管平台** 提供基于 Yao 的软件或服务给第三方用户。
- **SaaS 或 PaaS 服务**,在这些服务中,您管理并托管基于或利用 Yao 的应用程序,可能是原版或修改版。
- **托管服务**,其中 Yao 被用作为客户外部部署应用程序的基础技术。
在这些情况下,无论是使用原始 Yao 代码还是修改版 Yao都需要获得商业许可证。
2. **AI WEB 应用生成服务**
如果您提供利用 Yao 或其衍生产品(如 Yao 的分支版本或修改版本)为第三方用户生成 AI 驱动的 WEB 应用程序的服务,您需要购买商业许可证。
### 定义AI WEB 应用生成服务
"AI WEB 应用生成服务"指任何利用 Yao或任何分支版本或修改版本的 Yao自动化创建具有 AI 功能的 WEB 应用程序的服务或功能。包括但不限于,为第三方用户提供以下服务:
- **AI 驱动的自动化 WEB 应用开发**,该服务生成完整或部分 WEB 应用程序。
- **可定制的 WEB 解决方案**,这些解决方案由 AI 提供支持,并以 Yao 作为核心技术构建。
- **按需应用生成**,根据特定客户需求,使用 Yao 动态构建、配置或部署应用程序。
在这些情况下,无论是直接使用 Yao还是使用其分支或修改版均需要获得商业许可证。
## 2. 使用 Apache 许可证 2.0
对于所有其他用途,**Apache 许可证 2.0 版** 适用。只要您的使用不属于上述限制的商业场景,您可以自由地根据 Apache 2.0 许可证使用、修改和分发 Yao 项目。
## 3. 获取商业许可证
如需咨询或获取商业许可证,请通过以下方式联系我们:
- **电子邮件**[friends@iqka.com]
- **网站**[https://moapi.ai/contact](https://moapi.ai/contact)
商业许可证的定价和条款会根据使用场景、用户规模及其他因素有所不同。
## 4. 合规与审计
如果您对您的使用场景是否需要商业许可证有任何疑问,请联系我们以获取澄清。我们保留审核使用情况以确保合规,并在必要时执行商业许可条款的权利。
## 5. 免责声明
未遵守这些许可条款可能会导致违反 Yao 许可证协议,并可能导致法律诉讼。
---
**注意:** 此商业许可证是 Apache 2.0 许可证的补充,仅适用于上述特定的商业场景。

18
LICENSE
View file

@ -1,13 +1,13 @@
# Open Source License # Open Source License
Yao Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions: Yao App Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
1. Commercial Usage Terms: 1. Commercial Usage Terms:
Yao Engine may be utilized commercially, A commercial license from the producer is required if: Yao App Engine may be utilized commercially, A commercial license from the producer is required if:
a. Trademark and Branding Requirements a. Trademark and Branding Requirements
- The Yao Engine / Yao Agents / Tai / Tai Link console/application logo and copyright information must not be removed or modified - The Yao App Engine console/application logo and copyright information must not be removed or modified
- Logo and copyright information can only be changed with an authorization certificate issued through Yao Developer Certificate - Logo and copyright information can only be changed with an authorization certificate issued through Yao Developer Certificate
b. Authorization Verification Requirements b. Authorization Verification Requirements
@ -15,16 +15,10 @@ Yao Engine is licensed under a modified version of the Apache License 2.0, with
- The Yao certificate verification logic, processes, and related pages (marked in code comments) must be preserved - The Yao certificate verification logic, processes, and related pages (marked in code comments) must be preserved
- The complete Yao certificate verification system must be maintained regardless of usage purpose - The complete Yao certificate verification system must be maintained regardless of usage purpose
c. Enterprise Scale Requirements
- Organizations with 50 or more employees, or with annual revenue exceeding USD 1,000,000, must obtain a commercial license from Infinite Wisdom Software.
- To obtain a commercial license, please contact us at https://yaoagents.com/enterprise
2. Contributor Agreement: 2. Contributor Agreement:
As a contributor, you should agree that: - The producer reserves the right to modify the open-source agreement terms
a. Infinite Wisdom Software can adjust the open-source agreement to be more strict or relaxed as deemed necessary. - Contributed code may be used for commercial purposes, including cloud business operations
b. Your contributed code may be used for commercial purposes, including but not limited to its cloud business operations.
All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0). All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
© 2026 Infinite Wisdom Software. © 2025 Infinite Wisdom Software.

View file

@ -1,30 +0,0 @@
# 开源许可证
Yao Engine 基于修改版 Apache License 2.0 授权,并附加以下额外条款:
1. 商业使用条款:
Yao Engine 可用于商业用途,但在以下情况下须向 Infinite Wisdom Software 获取商业授权许可:
a. 商标与品牌要求
- 不得删除或修改 Yao Engine / Yao Agents / Tai / Tai Link 控制台/应用程序的徽标及版权信息
- 徽标和版权信息仅可在持有通过 Yao 开发者证书颁发的授权证书时方可更改
b. 授权验证要求
- 必须保留 Yao 证书验证逻辑、流程及相关页面(已在代码注释中标注)
- 无论使用目的如何,必须维持完整的 Yao 证书验证系统
c. 企业规模要求
- 员工人数达到 50 人及以上,或年收入超过 100 万美元的企业,须向 Infinite Wisdom Software 购买商业授权许可。
- 如需获取商业授权,请访问 https://yaoagents.com/enterprise 联系我们。
2. 贡献者协议:
作为贡献者,您需同意以下条款:
a. Infinite Wisdom Software 可视需要对本开源协议进行更严格或更宽松的调整。
b. 您贡献的代码可被用于商业用途,包括但不限于云服务业务运营。
其他所有权利与限制遵循 Apache License 2.0http://www.apache.org/licenses/LICENSE-2.0)。
© 2026 Infinite Wisdom Software.

View file

@ -11,7 +11,6 @@ OS := $(shell uname)
# ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) # ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry|agent/sandbox/v2' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)') TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry|agent/sandbox/v2' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
# Sandbox setting tests (openapi/tests/setting/sandbox_test.go) require Docker + Tai — skipped in CI, run locally only
# Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, grpc, and integrations which require external services) # Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, grpc, and integrations which require external services)
TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry|tai|grpc' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)') TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry|tai|grpc' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
# Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys), robot packages (tested in robot job), and agent/sandbox/v2 (WIP, has its own job) # Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys), robot packages (tested in robot job), and agent/sandbox/v2 (WIP, has its own job)
@ -37,7 +36,7 @@ TESTTAGS ?= ""
unit-test: unit-test:
echo "mode: count" > coverage.out echo "mode: count" > coverage.out
for d in $(TESTFOLDER); do \ for d in $(TESTFOLDER); do \
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \ $(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' $$d > tmp.out; \
cat tmp.out; \ cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \ if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \ rm tmp.out; \
@ -69,7 +68,7 @@ unit-test:
unit-test-core: unit-test-core:
echo "mode: count" > coverage.out echo "mode: count" > coverage.out
for d in $(TESTFOLDER_CORE); do \ for d in $(TESTFOLDER_CORE); do \
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \ $(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' $$d > tmp.out; \
cat tmp.out; \ cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \ if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \ rm tmp.out; \

View file

@ -2,19 +2,20 @@ package assistant
import ( import (
"fmt" "fmt"
"log"
"time" "time"
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm" goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/assistant/handlers" "github.com/yaoapp/yao/agent/assistant/handlers"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/llm" "github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox" agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/llmprovider" sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
infraV2 "github.com/yaoapp/yao/sandbox/v2"
) )
// Stream stream the agent // Stream stream the agent
@ -166,25 +167,30 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
var sandboxLoadingMsgID string var sandboxLoadingMsgID string
// V2 sandbox state // V2 sandbox state
var v2Init *sandboxV2InitResult var v2Runner sandboxTypes.Runner
var v2Computer infraV2.Computer
var v2LoadingMsgID string
var v2Cfg *sandboxTypes.SandboxConfig
if ast.HasSandboxV2() { if ast.HasSandboxV2() {
ctx.Logger.Phase("Sandbox V2") ctx.Logger.Phase("Sandbox V2")
var err error var err error
v2Init, err = ast.initSandboxV2(ctx, opts) var v2Cleanup func()
v2Runner, v2Computer, v2Cfg, v2Cleanup, v2LoadingMsgID, err = ast.initSandboxV2(ctx, opts)
if err != nil { if err != nil {
ast.traceAgentFail(agentNode, err) ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err return nil, err
} }
sandboxCleanup = v2Init.Cleanup sandboxCleanup = v2Cleanup
ctx.Logger.PhaseComplete("Sandbox V2") ctx.Logger.PhaseComplete("Sandbox V2")
if v2Init.Computer != nil { if v2Computer != nil {
ci := v2Init.Computer.ComputerInfo() ci := v2Computer.ComputerInfo()
ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind) ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind)
if ci.BoxID != "" { if ci.BoxID != "" {
ctx.Logger.Trace("Computer: %s", ci.BoxID) ctx.Logger.Trace("Computer: %s", ci.BoxID)
} }
ctx.Logger.Trace("Workspace: %s", v2Init.Config.WorkspaceID) ctx.Logger.Trace("Workspace: %s", v2Cfg.WorkspaceID)
if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil { if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil {
ctx.Logger.Trace("Connector: %s", conn.ID()) ctx.Logger.Trace("Connector: %s", conn.ID())
} }
@ -324,23 +330,22 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Execute the LLM streaming call // Execute the LLM streaming call
// Choose between sandbox execution or direct LLM execution // Choose between sandbox execution or direct LLM execution
if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Computer != nil && v2Init.Runner.Name() != "yao" { if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.Name() != "yao" {
// V2 Sandbox execution path (non-yao runners replace LLM.Stream) // V2 Sandbox execution path (non-yao runners replace LLM.Stream)
completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{ completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{
Messages: completionMessages, Messages: completionMessages,
AgentNode: agentNode, AgentNode: agentNode,
Handler: streamHandler, Handler: streamHandler,
Runner: v2Init.Runner, Runner: v2Runner,
Computer: v2Init.Computer, Computer: v2Computer,
Config: v2Init.Config, Config: v2Cfg,
LoadingMsgID: v2Init.LoadingMsgID, LoadingMsgID: v2LoadingMsgID,
Options: opts, Options: opts,
Roles: v2Init.Roles,
}) })
} else if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Runner.Name() == "yao" { } else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" {
// V2 yao runner: Prepare is done, close loading, fall through to LLM // V2 yao runner: Prepare is done, close loading, fall through to LLM
if v2Init.LoadingMsgID != "" { if v2LoadingMsgID != "" {
closeLoadingV2(ctx, v2Init.LoadingMsgID, "") closeLoadingV2(ctx, v2LoadingMsgID, "")
} }
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
} else if ast.HasSandbox() { } else if ast.HasSandbox() {
@ -565,47 +570,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err return nil, err
} }
} else if len(toolCallResponses) > 0 && !ast.HasSandbox() && !ast.isToolLoopDisabled() { } else {
// No Next hook + has tool results + not sandbox → tool loop // No Next hook: use standard response
ctx.Logger.Debug("Entering tool loop for tool result processing")
loopResponse, loopCompletion, loopTools, err := ast.executeToolLoop(ctx, &ToolLoopParams{
CompletionMessages: completionMessages,
CompletionOptions: completionOptions,
CompletionResponse: completionResponse,
ToolCallResponses: toolCallResponses,
FullMessages: fullMessages,
AgentNode: agentNode,
StreamHandler: streamHandler,
CreateResponse: createResponse,
Opts: opts,
})
if err != nil {
// Fallback to __yao.loop_fallback delegation
ctx.Logger.Warn("Tool loop failed: %v, falling back to loop_fallback", err)
fallbackDelegate := ast.buildLoopFallbackDelegate(ctx, fullMessages, completionResponse, toolCallResponses)
delegateResponse, delegateErr := ast.handleDelegation(ctx, fallbackDelegate, streamHandler)
if delegateErr != nil {
ctx.Logger.Warn("loop_fallback also failed: %v, using standard response", delegateErr)
finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
StreamHandler: streamHandler,
CreateResponse: createResponse,
})
} else {
finalResponse = delegateResponse
}
} else {
completionResponse = loopCompletion
toolCallResponses = loopTools
finalResponse = loopResponse
}
} else {
// No tool calls, sandbox mode, or loop disabled: standard response
finalResponse = ast.buildStandardResponse(&NextProcessContext{ finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx, Context: ctx,
NextResponse: nil,
CompletionResponse: completionResponse, CompletionResponse: completionResponse,
FullMessages: fullMessages, FullMessages: fullMessages,
ToolCallResponses: toolCallResponses, ToolCallResponses: toolCallResponses,
@ -658,41 +627,35 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
return finalResponse, nil return finalResponse, nil
} }
// GetConnector get the connector object, capabilities, and error. // GetConnector get the connector object, capabilities, and error with priority:
// Priority: opts.Connector > ast.Connector (may be "use::<role>") > "default" role > legacy fallback // opts.Connector > ast.Connector > defaultConnector (fallback)
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments // Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
// Returns: (connector, capabilities, error)
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) { func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) {
cid := ast.Connector connectorID := ast.Connector
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" { if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
cid = opts[0].Connector connectorID = opts[0].Connector
} }
// Extract identity for role-based resolution if connectorID == "" {
var identity llmprovider.Identity connectorID = defaultConnector
if ctx != nil && ctx.Authorized != nil {
identity = ctx.Authorized
}
// Unified resolution: explicit connector / use:: prefix / empty → all handled
conn, caps, err := llm.ResolveConnector(cid, identity)
if err == nil {
return conn, caps, nil
}
// Legacy fallback
if defaultConnector != "" {
if conn, err := connector.Select(defaultConnector); err == nil {
log.Warn("[LLM] Connector %s resolve failed, fallback to %s", cid, defaultConnector)
return conn, llm.GetCapabilitiesFromConn(conn), nil
}
}
if fallback := findCapableConnector(); fallback != "" {
if conn, err := connector.Select(fallback); err == nil {
log.Warn("[LLM] Connector %s resolve failed, fallback to %s (auto-detected)", cid, fallback)
return conn, llm.GetCapabilitiesFromConn(conn), nil
}
} }
if connectorID == "" {
return nil, nil, fmt.Errorf("connector not specified") return nil, nil, fmt.Errorf("connector not specified")
}
conn, err := connector.Select(connectorID)
if err != nil && connectorID != defaultConnector && defaultConnector != "" {
log.Printf("[Assistant] connector %q not found, falling back to default %q", connectorID, defaultConnector)
conn, err = connector.Select(defaultConnector)
}
if err != nil {
return nil, nil, err
}
capabilities := llm.GetCapabilitiesFromConn(conn)
return conn, capabilities, nil
} }
// Info get the assistant information // Info get the assistant information
@ -833,7 +796,6 @@ func (ast *Assistant) buildToolRetryMessages(
assistantMsg := context.Message{ assistantMsg := context.Message{
Role: context.RoleAssistant, Role: context.RoleAssistant,
Content: completionResponse.Content, Content: completionResponse.Content,
ReasoningContent: completionResponse.ReasoningContent,
ToolCalls: completionResponse.ToolCalls, ToolCalls: completionResponse.ToolCalls,
} }
retryMessages = append(retryMessages, assistantMsg) retryMessages = append(retryMessages, assistantMsg)

View file

@ -3,7 +3,6 @@ package assistant
import ( import (
"fmt" "fmt"
"path" "path"
"strings"
"github.com/yaoapp/gou/fs" "github.com/yaoapp/gou/fs"
"github.com/yaoapp/yao/agent/caller" "github.com/yaoapp/yao/agent/caller"
@ -28,28 +27,6 @@ func init() {
return &agentCallerWrapper{ast: ast}, nil return &agentCallerWrapper{ast: ast}, nil
} }
// Initialize AssistantReloadFunc for hot-reload after deploy
caller.AssistantReloadFunc = func(id string) error {
p := "/assistants/" + strings.Replace(id, ".", "/", 1)
ast, err := LoadPath(p)
if err != nil {
return err
}
ast.BuiltIn = true
ast.Readonly = true
if ast.Tags == nil {
ast.Tags = []string{}
}
if err := ast.Save(); err != nil {
return err
}
if err := ast.initialize(); err != nil {
return err
}
loaded.Put(ast)
return nil
}
// Initialize Agent JSAPI factory for ctx.agent.* methods // Initialize Agent JSAPI factory for ctx.agent.* methods
caller.SetJSAPIFactory() caller.SetJSAPIFactory()
@ -184,6 +161,9 @@ func (ast *Assistant) Validate() error {
if ast.Name == "" { if ast.Name == "" {
return fmt.Errorf("name is required") return fmt.Errorf("name is required")
} }
if ast.Connector == "" {
return fmt.Errorf("connector is required")
}
return nil return nil
} }

View file

@ -18,7 +18,6 @@ import (
) )
func TestGetChatKBID(t *testing.T) { func TestGetChatKBID(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
t.Run("WithTeamAndUser", func(t *testing.T) { t.Run("WithTeamAndUser", func(t *testing.T) {
teamID := "5659-5504-2879" teamID := "5659-5504-2879"
userID := "4287-9400-2030-0504" userID := "4287-9400-2030-0504"
@ -82,7 +81,6 @@ func TestGetChatKBID(t *testing.T) {
} }
func TestPrepareKBCollection(t *testing.T) { func TestPrepareKBCollection(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)

View file

@ -881,11 +881,20 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
// Init init the assistant // Init init the assistant
// Choose the connector and initialize the assistant // Choose the connector and initialize the assistant
func (ast *Assistant) initialize() error { func (ast *Assistant) initialize() error {
conn := defaultConnector
if ast.Connector != "" {
conn = ast.Connector
}
ast.Connector = conn
// Register scripts as process handlers
if len(ast.Scripts) > 0 { if len(ast.Scripts) > 0 {
if err := ast.RegisterScripts(); err != nil { if err := ast.RegisterScripts(); err != nil {
return fmt.Errorf("failed to register scripts: %w", err) return fmt.Errorf("failed to register scripts: %w", err)
} }
} }
return nil return nil
} }

View file

@ -29,20 +29,12 @@ var systemAgents = []string{
"entity", "entity",
"vision", "vision",
"fetch", "fetch",
"loop_fallback",
} }
// SystemConfig holds the system agents connector configuration // SystemConfig holds the system agents connector configuration
// This is set from agent.yml system block // This is set from agent.yml system block
type SystemConfig struct { type SystemConfig struct {
// Role-level defaults (consumed by buildSystemRoles → SetDefaults) Default string // Default connector for all system agents
Default string // Default connector for the "default" role
Light string // Default connector for the "light" role
Vision string // Default connector for the "vision" role
Audio string // Default connector for the "audio" role
Heavy string // Default connector for the "heavy" role (complex reasoning)
// Per-agent overrides (consumed by resolveSystemConnector → ast.Connector)
Keyword string // Connector for __yao.keyword agent Keyword string // Connector for __yao.keyword agent
QueryDSL string // Connector for __yao.querydsl agent QueryDSL string // Connector for __yao.querydsl agent
Title string // Connector for __yao.title agent Title string // Connector for __yao.title agent
@ -50,7 +42,8 @@ type SystemConfig struct {
RobotPrompt string // Connector for __yao.robot_prompt agent RobotPrompt string // Connector for __yao.robot_prompt agent
NeedSearch string // Connector for __yao.needsearch agent NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent Entity string // Connector for __yao.entity agent
LoopFallback string // Connector for __yao.loop_fallback agent Vision string // Connector for vision capabilities
Voice string // Connector for voice/STT capabilities
} }
// systemConfig holds the system agents configuration (global variable like others in load.go) // systemConfig holds the system agents configuration (global variable like others in load.go)
@ -167,9 +160,10 @@ func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
pkgData["type"] = "assistant" pkgData["type"] = "assistant"
} }
// Override connector only if agent.yml has an explicit per-agent setting // Resolve connector for this system agent
if override := resolveSystemConnector(id); override != "" { connectorID := resolveSystemConnector(id)
pkgData["connector"] = override if connectorID != "" {
pkgData["connector"] = connectorID
} }
// Read prompts.yml from bindata (default prompts) // Read prompts.yml from bindata (default prompts)
@ -213,36 +207,97 @@ func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
return loadMap(pkgData) return loadMap(pkgData)
} }
// resolveSystemConnector returns an explicit per-agent connector override from agent.yml. // resolveSystemConnector resolves the connector for a system agent
// Returns empty string if no override exists, so the connector declared in package.yao // Priority: specific agent config > system.default > defaultConnector > fallback to first capable connector
// (e.g. "use::light") is preserved as-is.
func resolveSystemConnector(agentID string) string { func resolveSystemConnector(agentID string) string {
if systemConfig == nil { // Try specific agent config first
return "" if systemConfig != nil {
}
switch agentID { switch agentID {
case "__yao.keyword": case "__yao.keyword":
if systemConfig.Keyword != "" {
return systemConfig.Keyword return systemConfig.Keyword
case "__yao.querydsl":
return systemConfig.QueryDSL
case "__yao.title":
return systemConfig.Title
case "__yao.prompt":
return systemConfig.Prompt
case "__yao.robot_prompt":
return systemConfig.RobotPrompt
case "__yao.needsearch":
return systemConfig.NeedSearch
case "__yao.entity":
return systemConfig.Entity
case "__yao.vision":
return systemConfig.Vision
case "__yao.audio":
return systemConfig.Audio
case "__yao.loop_fallback":
return systemConfig.LoopFallback
} }
return "" case "__yao.querydsl":
if systemConfig.QueryDSL != "" {
return systemConfig.QueryDSL
}
case "__yao.title":
if systemConfig.Title != "" {
return systemConfig.Title
}
case "__yao.prompt":
if systemConfig.Prompt != "" {
return systemConfig.Prompt
}
case "__yao.robot_prompt":
if systemConfig.RobotPrompt != "" {
return systemConfig.RobotPrompt
}
case "__yao.needsearch":
if systemConfig.NeedSearch != "" {
return systemConfig.NeedSearch
}
case "__yao.entity":
if systemConfig.Entity != "" {
return systemConfig.Entity
}
case "__yao.vision":
if systemConfig.Vision != "" {
return systemConfig.Vision
}
case "__yao.voice":
if systemConfig.Voice != "" {
return systemConfig.Voice
}
}
// Try system default
if systemConfig.Default != "" {
return systemConfig.Default
}
}
// Try global default connector
if defaultConnector != "" {
return defaultConnector
}
// Fallback: find first connector that supports tool calling
return findCapableConnector()
}
// GetVisionConnector returns the connector for vision capabilities.
// Priority: system.vision > system.default > defaultConnector > findCapableConnector
func GetVisionConnector() string {
if systemConfig != nil {
if systemConfig.Vision != "" {
return systemConfig.Vision
}
if systemConfig.Default != "" {
return systemConfig.Default
}
}
if defaultConnector != "" {
return defaultConnector
}
return findCapableConnector()
}
// GetVoiceConnector returns the connector for voice/STT capabilities.
// Priority: system.voice > system.default > defaultConnector > findCapableConnector
func GetVoiceConnector() string {
if systemConfig != nil {
if systemConfig.Voice != "" {
return systemConfig.Voice
}
if systemConfig.Default != "" {
return systemConfig.Default
}
}
if defaultConnector != "" {
return defaultConnector
}
return findCapableConnector()
} }
// findCapableConnector finds the first connector that supports tool calling // findCapableConnector finds the first connector that supports tool calling

View file

@ -1,58 +0,0 @@
package assistant
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolveSystemConnector_NoConfig(t *testing.T) {
saved := systemConfig
systemConfig = nil
defer func() { systemConfig = saved }()
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_PerAgentOverride(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Title: "openai.gpt-4o",
}
defer func() { systemConfig = saved }()
assert.Equal(t, "openai.gpt-4o", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_RoleLevelOnly(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Default: "openai.gpt-4o",
Light: "openai.gpt-4o-mini",
}
defer func() { systemConfig = saved }()
// Role-level keys don't produce per-agent overrides
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_UnknownAgent(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Default: "openai.gpt-4o",
Title: "openai.gpt-4o",
}
defer func() { systemConfig = saved }()
assert.Equal(t, "", resolveSystemConnector("__yao.nonexistent"))
assert.Equal(t, "", resolveSystemConnector("custom.agent"))
}

View file

@ -1,295 +0,0 @@
package assistant
import (
"fmt"
"strings"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/trace/types"
)
// ToolLoopParams holds all parameters needed by executeToolLoop.
type ToolLoopParams struct {
CompletionMessages []context.Message
CompletionOptions *context.CompletionOptions
CompletionResponse *context.CompletionResponse
ToolCallResponses []context.ToolCallResponse
FullMessages []context.Message
AgentNode types.Node
StreamHandler message.StreamFunc
CreateResponse *context.HookCreateResponse
Opts *context.Options
}
// executeToolLoop feeds tool results back to the LLM in a loop until
// the LLM produces a final text response (no more tool_calls) or
// the maximum number of turns is reached.
//
// Returns the final Response, the last CompletionResponse (for tracing),
// accumulated ToolCallResponses, and any error.
func (ast *Assistant) executeToolLoop(
ctx *context.Context,
params *ToolLoopParams,
) (*context.Response, *context.CompletionResponse, []context.ToolCallResponse, error) {
maxTurns := ast.getMaxToolLoopTurns()
currentMessages := params.CompletionMessages
currentCompletion := params.CompletionResponse
allToolResponses := make([]context.ToolCallResponse, 0, len(params.ToolCallResponses))
allToolResponses = append(allToolResponses, params.ToolCallResponses...)
for turn := 0; turn < maxTurns; turn++ {
ctx.Logger.Debug("Tool loop turn %d/%d", turn+1, maxTurns)
// Build messages: previous messages + assistant(tool_calls) + tool results
loopMessages := buildToolLoopMessages(currentMessages, currentCompletion, allToolResponses[len(allToolResponses)-len(params.ToolCallResponses):])
// Step tracking: LLM call
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
"messages": loopMessages,
"loop_turn": turn + 1,
})
// Call LLM with tool results included
newCompletion, err := ast.executeLLMStream(ctx, loopMessages, params.CompletionOptions, params.AgentNode, params.StreamHandler, params.Opts)
if err != nil {
return nil, nil, nil, fmt.Errorf("tool loop LLM call failed (turn %d): %w", turn+1, err)
}
ast.CompleteStep(ctx, map[string]interface{}{
"content": newCompletion.Content,
"tool_calls": newCompletion.ToolCalls,
})
// No tool_calls → LLM gave final text response
if newCompletion.ToolCalls == nil || len(newCompletion.ToolCalls) == 0 {
finalResponse := ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: newCompletion,
FullMessages: params.FullMessages,
ToolCallResponses: allToolResponses,
StreamHandler: params.StreamHandler,
CreateResponse: params.CreateResponse,
})
return finalResponse, newCompletion, allToolResponses, nil
}
// Has tool_calls → execute them
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
"tool_calls": newCompletion.ToolCalls,
"loop_turn": turn + 1,
})
toolResults, _ := ast.executeToolCalls(ctx, newCompletion.ToolCalls, 0)
// Convert ToolCallResult → ToolCallResponse
toolCallArgsMap := make(map[string]interface{})
for _, tc := range newCompletion.ToolCalls {
toolCallArgsMap[tc.ID] = tc.Function.Arguments
}
turnResponses := make([]context.ToolCallResponse, len(toolResults))
for i, result := range toolResults {
parsedContent, _ := result.ParsedContent()
turnResponses[i] = context.ToolCallResponse{
ToolCallID: result.ToolCallID,
Server: result.Server(),
Tool: result.Tool(),
Arguments: toolCallArgsMap[result.ToolCallID],
Result: parsedContent,
Error: "",
}
if result.Error != nil {
turnResponses[i].Error = result.Error.Error()
}
}
ast.CompleteStep(ctx, map[string]interface{}{
"results": turnResponses,
"loop_turn": turn + 1,
})
// Accumulate and prepare next iteration
allToolResponses = append(allToolResponses, turnResponses...)
currentMessages = loopMessages
currentCompletion = newCompletion
params.ToolCallResponses = turnResponses
}
return nil, nil, allToolResponses, fmt.Errorf("tool loop reached max turns (%d)", maxTurns)
}
// buildToolLoopMessages constructs the message sequence for the next LLM call:
// previous messages + assistant message (with tool_calls) + tool result messages.
// Unlike buildToolRetryMessages, this does NOT append a retry system prompt.
func buildToolLoopMessages(
previousMessages []context.Message,
completion *context.CompletionResponse,
toolResponses []context.ToolCallResponse,
) []context.Message {
messages := make([]context.Message, 0, len(previousMessages)+len(toolResponses)+2)
messages = append(messages, previousMessages...)
// Assistant message with tool_calls
messages = append(messages, context.Message{
Role: context.RoleAssistant,
Content: completion.Content,
ReasoningContent: completion.ReasoningContent,
ToolCalls: completion.ToolCalls,
})
// One tool-role message per tool call result
for _, tr := range toolResponses {
var content string
if tr.Error != "" {
content = fmt.Sprintf("Error: %s", tr.Error)
} else if tr.Result != nil {
raw, _ := jsoniter.MarshalToString(tr.Result)
content = raw
}
toolCallID := tr.ToolCallID
messages = append(messages, context.Message{
Role: context.RoleTool,
Content: content,
ToolCallID: &toolCallID,
})
}
return messages
}
// isToolLoopDisabled checks mcp.options.tool_loop.
// Default is enabled (returns false). Only disabled when explicitly set to false.
func (ast *Assistant) isToolLoopDisabled() bool {
if ast.MCP == nil || ast.MCP.Options == nil {
return false
}
if v, ok := ast.MCP.Options["tool_loop"]; ok {
if enabled, ok := v.(bool); ok {
return !enabled
}
}
return false
}
// getMaxToolLoopTurns reads mcp.options.max_turn. Default is 5.
func (ast *Assistant) getMaxToolLoopTurns() int {
const defaultMaxTurns = 5
if ast.MCP == nil || ast.MCP.Options == nil {
return defaultMaxTurns
}
if v, ok := ast.MCP.Options["max_turn"]; ok {
switch n := v.(type) {
case float64:
if n > 0 {
return int(n)
}
case int:
if n > 0 {
return n
}
}
}
return defaultMaxTurns
}
// ---------------------------------------------------------------------------
// Fallback: __yao.loop_fallback delegation (used when tool loop fails/maxes out)
// ---------------------------------------------------------------------------
// buildLoopFallbackDelegate constructs a DelegateConfig for __yao.loop_fallback.
// It packages conversation context and tool results into a Markdown user message.
func (ast *Assistant) buildLoopFallbackDelegate(
ctx *context.Context,
fullMessages []context.Message,
completion *context.CompletionResponse,
toolResults []context.ToolCallResponse,
) *context.DelegateConfig {
content := buildLoopFallbackMarkdown(fullMessages, toolResults)
return &context.DelegateConfig{
AgentID: "__yao.loop_fallback",
Messages: []context.Message{
{Role: context.RoleUser, Content: content},
},
}
}
// buildLoopFallbackMarkdown formats context into a Markdown string for the fallback agent.
func buildLoopFallbackMarkdown(
fullMessages []context.Message,
toolResults []context.ToolCallResponse,
) string {
var sb strings.Builder
sb.WriteString("## Assistant Context\n\n")
for _, msg := range fullMessages {
if msg.Role == context.RoleSystem {
if text := messageText(msg); text != "" {
sb.WriteString(text)
sb.WriteString("\n\n")
}
}
}
sb.WriteString("## Conversation\n\n")
for _, msg := range fullMessages {
text := messageText(msg)
switch msg.Role {
case context.RoleUser:
if text != "" {
sb.WriteString(fmt.Sprintf("**User**: %s\n\n", text))
}
case context.RoleAssistant:
if text != "" {
sb.WriteString(fmt.Sprintf("**Assistant**: %s\n\n", text))
}
}
}
sb.WriteString("## Tool Results\n\n")
for _, tr := range toolResults {
toolName := tr.Tool
if tr.Server != "" {
toolName = tr.Server + "." + tr.Tool
}
sb.WriteString(fmt.Sprintf("### %s\n", toolName))
if tr.Error != "" {
sb.WriteString(fmt.Sprintf("Error: %s\n\n", tr.Error))
} else {
raw, _ := jsoniter.MarshalToString(tr.Result)
sb.WriteString(fmt.Sprintf("```json\n%s\n```\n\n", raw))
}
}
sb.WriteString("---\nPlease answer the user's question based on the above context and tool results.\n")
sb.WriteString("Respond in the same language as the user.\n")
return sb.String()
}
// messageText extracts text content from a message's Content field.
// Content can be a string or an array of content parts (multimodal).
func messageText(msg context.Message) string {
if msg.Content == nil {
return ""
}
if str, ok := msg.Content.(string); ok {
return str
}
if parts, ok := msg.Content.([]interface{}); ok {
var texts []string
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
if partMap["type"] == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
}
return fmt.Sprintf("%v", msg.Content)
}

View file

@ -375,6 +375,12 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
return []ToolCallResult{result}, true return []ToolCallResult{result}, true
} }
// Check if result is an error
if callResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
}
// Serialize the Content field only ([]ToolContent) // Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(callResult.Content) contentBytes, err := jsoniter.Marshal(callResult.Content)
if err != nil { if err != nil {
@ -390,19 +396,6 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
} }
result.Content = string(contentBytes) result.Content = string(contentBytes)
// Check if result is an error — include actual content so LLM can see the details
if callResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolCall.Function.Name, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
ctx.Logger.ToolComplete(toolCall.Function.Name, true) ctx.Logger.ToolComplete(toolCall.Function.Name, true)
if toolNode != nil { if toolNode != nil {
@ -552,7 +545,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
// Prepare parallel trace inputs // Prepare parallel trace inputs
var parallelInputs []types.TraceParallelInput var parallelInputs []types.TraceParallelInput
mcpCalls := make([]mcpTypes.ToolCall, 0, len(toolCalls)) mcpCalls := make([]mcpTypes.ToolCall, 0, len(toolCalls))
orderedCalls := make([]agentContext.ToolCall, 0, len(toolCalls)) callMap := make(map[string]agentContext.ToolCall)
for _, tc := range toolCalls { for _, tc := range toolCalls {
_, toolName, ok := ParseMCPToolName(tc.Function.Name) _, toolName, ok := ParseMCPToolName(tc.Function.Name)
@ -572,7 +565,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
Name: toolName, Name: toolName,
Arguments: args, Arguments: args,
}) })
orderedCalls = append(orderedCalls, tc) callMap[toolName] = tc
ctx.Logger.ToolStart(tc.Function.Name) ctx.Logger.ToolStart(tc.Function.Name)
// Add trace input for this tool // Add trace input for this tool
@ -613,8 +606,10 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
if node != nil { if node != nil {
node.Fail(err) node.Fail(err)
} }
if i < len(orderedCalls) { if i < len(mcpCalls) {
ctx.Logger.ToolComplete(orderedCalls[i].Function.Name, false) if tc, ok := callMap[mcpCalls[i].Name]; ok {
ctx.Logger.ToolComplete(tc.Function.Name, false)
}
} }
} }
return nil, true return nil, true
@ -626,7 +621,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
for i, mcpResult := range mcpResponse.Results { for i, mcpResult := range mcpResponse.Results {
toolName := mcpCalls[i].Name toolName := mcpCalls[i].Name
originalCall := orderedCalls[i] originalCall := callMap[toolName]
var toolNode types.Node var toolNode types.Node
if i < len(toolNodes) { if i < len(toolNodes) {
toolNode = toolNodes[i] toolNode = toolNodes[i]
@ -814,12 +809,19 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
toolNode.Fail(err) toolNode.Fail(err)
} }
} else { } else {
// Check if result is an error
if mcpResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
hasErrors = true
}
// Serialize the Content field only ([]ToolContent) // Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(mcpResult.Content) contentBytes, err := jsoniter.Marshal(mcpResult.Content)
if err != nil { if err != nil {
result.Error = err result.Error = err
result.Content = fmt.Sprintf("Failed to serialize result: %v", err) result.Content = fmt.Sprintf("Failed to serialize result: %v", err)
result.IsRetryableError = false result.IsRetryableError = false // Serialization error is not retryable
hasErrors = true hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false) ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil { if toolNode != nil {
@ -827,19 +829,7 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
} }
} else { } else {
result.Content = string(contentBytes) result.Content = string(contentBytes)
ctx.Logger.ToolComplete(tc.Function.Name, !mcpResult.IsError)
// Check if result is an error — include actual content so LLM can see the details
if mcpResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
hasErrors = true
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
} else {
ctx.Logger.ToolComplete(tc.Function.Name, true)
if toolNode != nil { if toolNode != nil {
toolNode.Complete(map[string]any{ toolNode.Complete(map[string]any{
"result": mcpResult.Content, "result": mcpResult.Content,
@ -847,7 +837,6 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
} }
} }
} }
}
results = append(results, result) results = append(results, result)
} }

View file

@ -10,7 +10,6 @@ import (
"time" "time"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
gouMCP "github.com/yaoapp/gou/mcp" gouMCP "github.com/yaoapp/gou/mcp"
mcpProcess "github.com/yaoapp/gou/mcp/process" mcpProcess "github.com/yaoapp/gou/mcp/process"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
@ -267,13 +266,7 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
execOpts.ConnectorType = "openai" execOpts.ConnectorType = "openai"
} }
// Extract standard fields via LLMConnector when available, fallback to Setting()
setting := conn.Setting() setting := conn.Setting()
if lc, ok := conn.(goullm.LLMConnector); ok {
execOpts.ConnectorHost = lc.GetURL()
execOpts.ConnectorKey = lc.GetKey()
execOpts.Model = lc.GetModel()
} else {
if host, ok := setting["host"].(string); ok { if host, ok := setting["host"].(string); ok {
execOpts.ConnectorHost = host execOpts.ConnectorHost = host
} }
@ -283,10 +276,20 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
if model, ok := setting["model"].(string); ok { if model, ok := setting["model"].(string); ok {
execOpts.Model = model execOpts.Model = model
} }
}
// Whitelist-filter remaining settings for sandbox proxy options // Extract extra connector options (thinking, max_tokens, temperature, etc.)
connectorOptions := connector.FilterRequestBodyParams(setting, conn) // These are backend-specific parameters that need to be passed through to the proxy
connectorOptions := make(map[string]interface{})
for k, v := range setting {
// Skip standard fields that are already handled
switch k {
case "host", "key", "model", "azure", "capabilities":
continue
default:
// Include all other fields as extra options
connectorOptions[k] = v
}
}
if len(connectorOptions) > 0 { if len(connectorOptions) > 0 {
execOpts.ConnectorOptions = connectorOptions execOpts.ConnectorOptions = connectorOptions
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions) ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)

View file

@ -8,7 +8,6 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
@ -16,7 +15,6 @@ import (
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
store "github.com/yaoapp/yao/agent/store/types" store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
infraV2 "github.com/yaoapp/yao/sandbox/v2" infraV2 "github.com/yaoapp/yao/sandbox/v2"
traceTypes "github.com/yaoapp/yao/trace/types" traceTypes "github.com/yaoapp/yao/trace/types"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
@ -27,22 +25,15 @@ func (ast *Assistant) HasSandboxV2() bool {
return ast.SandboxV2 != nil return ast.SandboxV2 != nil
} }
// sandboxV2InitResult bundles everything returned by initSandboxV2.
type sandboxV2InitResult struct {
Runner sandboxTypes.Runner
Computer infraV2.Computer
Config *sandboxTypes.SandboxConfig
Cleanup func()
LoadingMsgID string
Roles map[string]connector.Connector
}
// initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner, // initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner,
// resolves the role matrix, runs Prepare, and returns the result. // runs Prepare, and returns the runner, computer, a per-request copy of the
// SandboxConfig, cleanup closure, loading message ID, and any error.
// //
// A shallow copy of ast.SandboxV2 is made so that concurrent requests to the // A shallow copy of ast.SandboxV2 is made so that concurrent requests to the
// same assistant each get their own mutable config (Owner, ID, NodeID, etc.). // same assistant each get their own mutable config (Owner, ID, NodeID, etc.).
func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (*sandboxV2InitResult, error) { func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (
sandboxTypes.Runner, infraV2.Computer, *sandboxTypes.SandboxConfig, func(), string, error,
) {
cfgCopy := *ast.SandboxV2 cfgCopy := *ast.SandboxV2
cfg := &cfgCopy cfg := &cfgCopy
manager := infraV2.M() manager := infraV2.M()
@ -61,12 +52,9 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
conn, _, err := ast.GetConnector(ctx, opts) conn, _, err := ast.GetConnector(ctx, opts)
if err != nil && cfg.Runner.Name != "yao" { if err != nil && cfg.Runner.Name != "yao" {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("get connector: %w", err) return nil, nil, nil, nil, "", fmt.Errorf("get connector: %w", err)
} }
// 1b. Resolve role matrix once; passed to both Prepare and Stream.
roles := resolveRoles(conn, ctx.Authorized)
// 2. Build human-readable DisplayName from real Agent name + Workspace name. // 2. Build human-readable DisplayName from real Agent name + Workspace name.
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name) cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name)
@ -101,7 +89,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager) computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
if err != nil { if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("getComputer failed: %w", err) return nil, nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err)
} }
_ = identifier _ = identifier
@ -110,7 +98,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
if err != nil { if err != nil {
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err) return nil, nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
} }
// 5. Resolve assistant directory and skills subdirectory. // 5. Resolve assistant directory and skills subdirectory.
@ -141,7 +129,6 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
Computer: computer, Computer: computer,
Config: cfg, Config: cfg,
Connector: conn, Connector: conn,
Roles: roles,
AssistantID: ast.ID, AssistantID: ast.ID,
SkillsDir: skillsDir, SkillsDir: skillsDir,
AssistantDir: assistantDir, AssistantDir: assistantDir,
@ -153,9 +140,11 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
runner.Cleanup(stdCtx, computer) runner.Cleanup(stdCtx, computer)
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("runner.Prepare: %w", err) return nil, nil, nil, nil, "", fmt.Errorf("runner.Prepare: %w", err)
} }
// Inject computer + workspace into context so Create/Next hooks
// can access ctx.computer and ctx.workspace.
ctx.SetComputer(computer) ctx.SetComputer(computer)
cleanup := func() { cleanup := func() {
@ -165,14 +154,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager) sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager)
} }
return &sandboxV2InitResult{ return runner, computer, cfg, cleanup, loadingMsgID, nil
Runner: runner,
Computer: computer,
Config: cfg,
Cleanup: cleanup,
LoadingMsgID: loadingMsgID,
Roles: roles,
}, nil
} }
// sandboxV2StreamParams groups arguments for executeSandboxV2Stream. // sandboxV2StreamParams groups arguments for executeSandboxV2Stream.
@ -185,7 +167,6 @@ type sandboxV2StreamParams struct {
Config *sandboxTypes.SandboxConfig Config *sandboxTypes.SandboxConfig
LoadingMsgID string LoadingMsgID string
Options *context.Options Options *context.Options
Roles map[string]connector.Connector
} }
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the // executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
@ -227,7 +208,6 @@ func (ast *Assistant) executeSandboxV2Stream(
Computer: p.Computer, Computer: p.Computer,
Config: cfg, Config: cfg,
Connector: conn, Connector: conn,
Roles: p.Roles,
AssistantID: ast.ID, AssistantID: ast.ID,
Messages: p.Messages, Messages: p.Messages,
SystemPrompt: systemPrompt, SystemPrompt: systemPrompt,
@ -235,7 +215,6 @@ func (ast *Assistant) executeSandboxV2Stream(
Token: tok, Token: tok,
Logger: ctx.Logger, Logger: ctx.Logger,
UserExplicit: p.Options != nil && p.Options.Connector != "", UserExplicit: p.Options != nil && p.Options.Connector != "",
Locale: ctx.Locale,
} }
execReq := &sandboxv2.ExecuteRequest{ execReq := &sandboxv2.ExecuteRequest{
@ -250,25 +229,6 @@ func (ast *Assistant) executeSandboxV2Stream(
return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler) return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler)
} }
// resolveRoles builds the role → connector map using the llmprovider role system.
// The primary connector (user-selected or system default) becomes "default";
// other roles (heavy, light, vision) are fetched from llmprovider settings.
func resolveRoles(conn connector.Connector, identity llmprovider.Identity) map[string]connector.Connector {
roles := map[string]connector.Connector{}
if conn != nil {
roles["default"] = conn
}
if llmprovider.Global == nil || identity == nil {
return roles
}
for _, role := range []string{"heavy", "light", "vision"} {
if c, err := llmprovider.Global.GetRoleModelBy(role, identity); err == nil {
roles[role] = c
}
}
return roles
}
// initStandaloneWorkspace loads the workspace FS into context when no sandbox // initStandaloneWorkspace loads the workspace FS into context when no sandbox
// is configured but the user selected a workspace (metadata["workspace_id"]). // is configured but the user selected a workspace (metadata["workspace_id"]).
func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) { func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {

View file

@ -91,7 +91,7 @@ func parseSearchField(search any) *SearchIntent {
if v { if v {
return &SearchIntent{ return &SearchIntent{
NeedSearch: true, NeedSearch: true,
SearchTypes: []string{"web"}, // TODO: 恢复 KB/DB 搜索时改回 []string{"web", "kb", "db"} SearchTypes: []string{"web", "kb", "db"},
Confidence: 1.0, Confidence: 1.0,
Reason: "enabled by hook", Reason: "enabled by hook",
} }

View file

@ -62,7 +62,6 @@ func (c *authTestCollections) cleanup(ctx context.Context, t *testing.T) {
// FilterKBCollectionsByAuth filters collections based on user authorization. // FilterKBCollectionsByAuth filters collections based on user authorization.
func TestKBCollectionAuthFilter(t *testing.T) { func TestKBCollectionAuthFilter(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)
@ -156,7 +155,6 @@ func TestKBCollectionAuthFilter(t *testing.T) {
// ========== DB Auth Wheres Tests ========== // ========== DB Auth Wheres Tests ==========
func TestDBAuthWheresFilter(t *testing.T) { func TestDBAuthWheresFilter(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
// Note: This test doesn't need KB, just tests the BuildDBAuthWheres function // Note: This test doesn't need KB, just tests the BuildDBAuthWheres function
t.Run("TeamOnlyGeneratesCorrectWheres", func(t *testing.T) { t.Run("TeamOnlyGeneratesCorrectWheres", func(t *testing.T) {
ctx := createAuthContext(TestUserA, TestTeam1, true, false) ctx := createAuthContext(TestUserA, TestTeam1, true, false)
@ -275,7 +273,6 @@ func TestDBAuthWheresFilter(t *testing.T) {
// ========== KB Search Integration Tests ========== // ========== KB Search Integration Tests ==========
func TestKBSearchIntegration(t *testing.T) { func TestKBSearchIntegration(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)

View file

@ -52,28 +52,23 @@ func TestSearchAutoFull(t *testing.T) {
assert.Equal(t, 3, ast.Search.Web.MaxResults) assert.Equal(t, 3, ast.Search.Web.MaxResults)
}) })
// KB/DB search temporarily disabled
t.Run("ShouldHaveKBSearchConfig", func(t *testing.T) { t.Run("ShouldHaveKBSearchConfig", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.Search.KB, "kb search config should be set") assert.NotNil(t, ast.Search.KB, "kb search config should be set")
assert.Equal(t, 0.7, ast.Search.KB.Threshold) assert.Equal(t, 0.7, ast.Search.KB.Threshold)
assert.False(t, ast.Search.KB.Graph) assert.False(t, ast.Search.KB.Graph)
}) })
t.Run("ShouldHaveDBSearchConfig", func(t *testing.T) { t.Run("ShouldHaveDBSearchConfig", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.Search.DB, "db search config should be set") assert.NotNil(t, ast.Search.DB, "db search config should be set")
assert.Equal(t, 10, ast.Search.DB.MaxResults) assert.Equal(t, 10, ast.Search.DB.MaxResults)
}) })
t.Run("ShouldHaveKBCollections", func(t *testing.T) { t.Run("ShouldHaveKBCollections", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.KB, "kb config should be set") assert.NotNil(t, ast.KB, "kb config should be set")
assert.Contains(t, ast.KB.Collections, "test-collection") assert.Contains(t, ast.KB.Collections, "test-collection")
}) })
t.Run("ShouldHaveDBModels", func(t *testing.T) { t.Run("ShouldHaveDBModels", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.DB, "db config should be set") assert.NotNil(t, ast.DB, "db config should be set")
assert.Contains(t, ast.DB.Models, "user") assert.Contains(t, ast.DB.Models, "user")
assert.Contains(t, ast.DB.Models, "article") assert.Contains(t, ast.DB.Models, "article")
@ -92,7 +87,6 @@ func TestSearchAutoFull(t *testing.T) {
}) })
t.Run("StreamShouldExecuteMultipleSearchTypes", func(t *testing.T) { t.Run("StreamShouldExecuteMultipleSearchTypes", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
// Get agent via assistant.Get (required for Stream) // Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-full") agent, err := assistant.Get("tests.search-auto-full")
require.NoError(t, err) require.NoError(t, err)

View file

@ -15,7 +15,3 @@ type AgentCaller interface {
// AgentGetterFunc is a function type that gets an agent by ID // AgentGetterFunc is a function type that gets an agent by ID
// This should be set by the assistant package during initialization // This should be set by the assistant package during initialization
var AgentGetterFunc func(agentID string) (AgentCaller, error) var AgentGetterFunc func(agentID string) (AgentCaller, error)
// AssistantReloadFunc reloads a single assistant from disk after deploy.
// Set by the assistant package during initialization.
var AssistantReloadFunc func(id string) error

View file

@ -273,14 +273,14 @@ func TestProcessCall_Timeout_Short(t *testing.T) {
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)
// Set timeout=1 second — LLM round-trip will certainly exceed this. // Set timeout=2 seconds — LLM round-trip will certainly exceed this.
// Verifies that the timeout parameter is respected and produces an error. // Verifies that the timeout parameter is respected and produces an error.
proc := newLLMProcess(t, "agent.call", map[string]interface{}{ proc := newLLMProcess(t, "agent.call", map[string]interface{}{
"assistant_id": "tests.simple-greeting", "assistant_id": "tests.simple-greeting",
"messages": []interface{}{ "messages": []interface{}{
map[string]interface{}{"role": "user", "content": "Tell me a very long story about the history of computing."}, map[string]interface{}{"role": "user", "content": "Tell me a very long story about the history of computing."},
}, },
"timeout": 1, "timeout": 2,
}) })
err := proc.Execute() err := proc.Execute()

View file

@ -77,15 +77,7 @@ func parseContentParts(ctx *agentContext.Context, message agentContext.Message,
for _, part := range content { for _, part := range content {
parsedPart, refs, err := parseContentPart(ctx, part, options) parsedPart, refs, err := parseContentPart(ctx, part, options)
if err != nil { if err != nil {
if part.Type == agentContext.ContentImageURL {
parts = append(parts, agentContext.ContentPart{
Type: agentContext.ContentText,
Text: "[Image content could not be processed]",
})
} else {
parts = append(parts, part) parts = append(parts, part)
}
log.Error("Failed to parse content part type=%s: %v", part.Type, err)
continue continue
} }
parts = append(parts, parsedPart) parts = append(parts, parsedPart)

View file

@ -12,7 +12,6 @@ import (
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
searchTypes "github.com/yaoapp/yao/agent/search/types" searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/attachment" "github.com/yaoapp/yao/attachment"
toolsImage "github.com/yaoapp/yao/tools/image"
) )
// Image handles image content // Image handles image content
@ -66,27 +65,24 @@ func (h *Image) Parse(ctx *agentContext.Context, content agentContext.ContentPar
return h.base64(ctx, content, visionFormat) return h.base64(ctx, content, visionFormat)
} }
// Model doesn't support vision - fallback chain: // Model doesn't support vision - check cache first, then use vision agent/MCP
// 1. Cache -> 2. Uses.Vision (explicit config) -> 3. tools/vision (auto) -> 4. Placeholder text // Try to get cached text (from attachment's content_preview)
cachedText, found, err := h.readFromCache(ctx, content.ImageURL.URL) cachedText, found, err := h.readFromCache(ctx, content.ImageURL.URL)
if err == nil && found { if err == nil && found {
// Cache hit! Return as text content
return agentContext.ContentPart{ return agentContext.ContentPart{
Type: agentContext.ContentText, Type: agentContext.ContentText,
Text: cachedText, Text: cachedText,
}, nil, nil }, nil, nil
} }
// No cache, try to use vision agent/MCP
if h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil && h.options.CompletionOptions.Uses.Vision != "" { if h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil && h.options.CompletionOptions.Uses.Vision != "" {
return h.agent(ctx, content) return h.agent(ctx, content)
} }
if text, err := h.readImageWithTools(ctx, content); err == nil { // No vision support and no vision tool specified, return error
h.saveToCache(ctx, content.ImageURL.URL, text) return content, nil, fmt.Errorf("model doesn't support vision and no vision tool specified in uses.Vision")
return agentContext.ContentPart{Type: agentContext.ContentText, Text: text}, nil, nil
}
return agentContext.ContentPart{Type: agentContext.ContentText, Text: "[Image content - vision model not available]"}, nil, nil
} }
// base64 encodes image content to base64 (for vision support) // base64 encodes image content to base64 (for vision support)
@ -364,37 +360,6 @@ func (h *Image) callMCPVisionTool(ctx *agentContext.Context, serverID string, co
return result, err return result, err
} }
// readImageWithTools calls tools/vision.ReadImage to convert image to text
// using a vision-capable model resolved via llmprovider.
func (h *Image) readImageWithTools(ctx *agentContext.Context, content agentContext.ContentPart) (string, error) {
if ctx.Authorized == nil {
return "", fmt.Errorf("no auth info available for vision model resolution")
}
src := wrapperToAttachURI(content.ImageURL.URL)
loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing"))
resp, err := toolsImage.ReadImage(ctx.Context, src, "Please describe this image in detail.", 1080, ctx.Authorized, "")
h.sendLoadingDone(ctx, loadingID)
if err != nil {
return "", err
}
return resp.Content, nil
}
// wrapperToAttachURI converts __uploader://fileID to attach://uploader/fileID
// format expected by tools/vision.readBytes.
func wrapperToAttachURI(url string) string {
uploaderName, fileID, ok := attachment.Parse(url)
if !ok {
return url
}
return "attach://" + uploaderName + "/" + fileID
}
// sendLoading sends a loading message and returns the message ID // sendLoading sends a loading message and returns the message ID
// Returns empty string if SilentLoading is enabled // Returns empty string if SilentLoading is enabled
func (h *Image) sendLoading(ctx *agentContext.Context, msg string) string { func (h *Image) sendLoading(ctx *agentContext.Context, msg string) string {

View file

@ -117,12 +117,11 @@ func TestParseWithoutVisionSupport(t *testing.T) {
} }
handler := image.New(options) handler := image.New(options)
result, _, err := handler.Parse(ctx, content) _, _, err := handler.Parse(ctx, content)
// Should return placeholder text (no error) when no vision support // Should return error because no vision support and no vision tool specified
assert.NoError(t, err) assert.Error(t, err)
assert.Equal(t, agentContext.ContentText, result.Type) assert.Contains(t, err.Error(), "no vision tool specified")
assert.Contains(t, result.Text, "Image content")
} }
// TestParseWithEmptyURL tests parsing image with empty URL // TestParseWithEmptyURL tests parsing image with empty URL

View file

@ -382,14 +382,9 @@ func (b *ChatBuffer) GetStepsForResume(finalStatus string) []*BufferedStep {
b.currentStep.Status = finalStatus b.currentStep.Status = finalStatus
} }
// Only return steps with valid resume status (failed or interrupted) // Return all steps (they will all have the context for recovery)
result := make([]*BufferedStep, 0, len(b.steps)) result := make([]*BufferedStep, len(b.steps))
for _, step := range b.steps { copy(result, b.steps)
if step.Status != ResumeStatusFailed && step.Status != ResumeStatusInterrupted {
continue
}
result = append(result, step)
}
return result return result
} }

View file

@ -498,10 +498,10 @@ func TestBufferGetStepsForResume(t *testing.T) {
steps := buffer.GetStepsForResume(context.ResumeStatusFailed) steps := buffer.GetStepsForResume(context.ResumeStatusFailed)
require.NotNil(t, steps) require.NotNil(t, steps)
assert.Len(t, steps, 1) assert.Len(t, steps, 2)
// Only the failed step should be returned // Current step should be marked as failed
assert.Equal(t, context.ResumeStatusFailed, steps[0].Status) assert.Equal(t, context.ResumeStatusFailed, steps[1].Status)
}) })
t.Run("InterruptedRequest", func(t *testing.T) { t.Run("InterruptedRequest", func(t *testing.T) {
@ -516,8 +516,8 @@ func TestBufferGetStepsForResume(t *testing.T) {
steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted) steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted)
require.NotNil(t, steps) require.NotNil(t, steps)
assert.Len(t, steps, 1) assert.Len(t, steps, 3)
assert.Equal(t, context.ResumeStatusInterrupted, steps[0].Status) assert.Equal(t, context.ResumeStatusInterrupted, steps[2].Status)
}) })
} }
@ -1014,10 +1014,10 @@ func TestBufferCompleteWorkflow(t *testing.T) {
// Get steps for resume // Get steps for resume
steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted) steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted)
require.NotNil(t, steps) require.NotNil(t, steps)
assert.Len(t, steps, 1) assert.Len(t, steps, 2)
// Only the interrupted step should be returned // Last step should be interrupted with space snapshot
lastStep := steps[0] lastStep := steps[len(steps)-1]
assert.Equal(t, context.ResumeStatusInterrupted, lastStep.Status) assert.Equal(t, context.ResumeStatusInterrupted, lastStep.Status)
assert.NotNil(t, lastStep.SpaceSnapshot) assert.NotNil(t, lastStep.SpaceSnapshot)
assert.Equal(t, "previous conversation", lastStep.SpaceSnapshot["user_context"]) assert.Equal(t, "previous conversation", lastStep.SpaceSnapshot["user_context"])

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store" "github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/oauth/types"
) )
@ -62,8 +63,10 @@ func GetGRPCAgentRequest(parent context.Context, input GRPCAgentInput) ([]Messag
} }
if connectorID := getStringOpt(rawOpts, "connector"); connectorID != "" { if connectorID := getStringOpt(rawOpts, "connector"); connectorID != "" {
if _, err := connector.Select(connectorID); err == nil {
opts.Connector = connectorID opts.Connector = connectorID
} }
}
ctx.Interrupt = NewInterruptController() ctx.Interrupt = NewInterruptController()
if err := Register(ctx); err != nil { if err := Register(ctx); err != nil {

View file

@ -14,9 +14,6 @@ type LlmAPI interface {
// Returns *llm.Result or error information // Returns *llm.Result or error information
Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{} Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{}
// GenerateImage generates an image from a text prompt using an image generation model
GenerateImage(connector string, prompt string, opts map[string]interface{}) interface{}
// Parallel LLM call methods - inspired by JavaScript Promise // Parallel LLM call methods - inspired by JavaScript Promise
// All waits for all LLM calls to complete (like Promise.all) // All waits for all LLM calls to complete (like Promise.all)
All(requests []interface{}) []interface{} All(requests []interface{}) []interface{}
@ -71,9 +68,6 @@ func (ctx *Context) newLlmObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
// Single LLM call method // Single LLM call method
llmObj.Set("Stream", ctx.llmStreamMethod(iso)) llmObj.Set("Stream", ctx.llmStreamMethod(iso))
// Image generation method
llmObj.Set("GenerateImage", ctx.llmGenerateImageMethod(iso))
// Parallel LLM call methods - inspired by JavaScript Promise // Parallel LLM call methods - inspired by JavaScript Promise
llmObj.Set("All", ctx.llmAllMethod(iso)) llmObj.Set("All", ctx.llmAllMethod(iso))
llmObj.Set("Any", ctx.llmAnyMethod(iso)) llmObj.Set("Any", ctx.llmAnyMethod(iso))
@ -169,54 +163,6 @@ func (ctx *Context) llmStreamMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
}) })
} }
// llmGenerateImageMethod implements ctx.llm.GenerateImage(connector, prompt, options?)
// Usage: const result = ctx.llm.GenerateImage("dall-e-3", "A sunset over mountains", { size: "1024x1024" })
// Returns: { connector, image (base64), format, error }
func (ctx *Context) llmGenerateImageMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if len(args) < 2 {
return bridge.JsException(v8ctx, "GenerateImage requires connector and prompt parameters")
}
if !args[0].IsString() {
return bridge.JsException(v8ctx, "connector must be a string")
}
connectorID := args[0].String()
if !args[1].IsString() {
return bridge.JsException(v8ctx, "prompt must be a string")
}
prompt := args[1].String()
var opts map[string]interface{}
if len(args) >= 3 && !args[2].IsUndefined() && !args[2].IsNull() {
goVal, err := bridge.GoValue(args[2], v8ctx)
if err == nil {
if optsMap, ok := goVal.(map[string]interface{}); ok {
opts = optsMap
}
}
}
llmAPI := ctx.Llm()
if llmAPI == nil {
return bridge.JsException(v8ctx, "LLM API not available")
}
result := llmAPI.GenerateImage(connectorID, prompt, opts)
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
}
return jsVal
})
}
// llmAllMethod implements ctx.llm.All(requests, options?) // llmAllMethod implements ctx.llm.All(requests, options?)
// Usage: const results = ctx.llm.All([ // Usage: const results = ctx.llm.All([
// //

View file

@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store" "github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/authorized"
) )
@ -69,10 +70,20 @@ func GetCompletionRequest(c *gin.Context, cache store.Store) (*CompletionRequest
Mode: GetMode(c, completionReq), Mode: GetMode(c, completionReq),
} }
// Pass model as connector ID; downstream ResolveConnector handles validation + lazy loading // Try to extract custom connector from model field
if completionReq != nil && completionReq.Model != "" && !strings.Contains(completionReq.Model, "-yao_") { // If model is a valid connector ID, set it to opts.Connector
// Otherwise, keep the standard OpenAI-compatible behavior (model as assistant ID)
if completionReq != nil && completionReq.Model != "" {
// Check if model is a valid connector (not containing "-yao_" which indicates assistant ID format)
if !strings.Contains(completionReq.Model, "-yao_") {
// Try to validate if it's a real connector
if _, err := connector.Select(completionReq.Model); err == nil {
// It's a valid connector, use it
opts.Connector = completionReq.Model opts.Connector = completionReq.Model
} }
// If not a valid connector, ignore it (keep opts.Connector empty to use assistant's default)
}
}
// Initialize interrupt controller // Initialize interrupt controller
ctx.Interrupt = NewInterruptController() ctx.Interrupt = NewInterruptController()

View file

@ -566,7 +566,6 @@ type Message struct {
ToolCallID *string `json:"tool_call_id,omitempty"` // Required for tool messages: tool call that this message is responding to ToolCallID *string `json:"tool_call_id,omitempty"` // Required for tool messages: tool call that this message is responding to
// Assistant message specific fields // Assistant message specific fields
ReasoningContent string `json:"reasoning_content,omitempty"` // Optional for assistant: reasoning/thinking content (DeepSeek, OpenAI o-series)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Optional for assistant: tool calls generated by the model ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Optional for assistant: tool calls generated by the model
Refusal *string `json:"refusal,omitempty"` // Optional for assistant: refusal message (null when not refusing) Refusal *string `json:"refusal,omitempty"` // Optional for assistant: refusal message (null when not refusing)
} }

View file

@ -53,28 +53,19 @@ func (a *VisionAdapter) removeImageContent(messages []context.Message) []context
for _, msg := range messages { for _, msg := range messages {
processedMsg := msg processedMsg := msg
if contentParts, ok := msg.Content.([]context.ContentPart); ok { // Handle multimodal content (array of map)
filtered := make([]context.ContentPart, 0) if contentParts, ok := msg.Content.([]map[string]interface{}); ok {
for _, part := range contentParts {
if part.Type != context.ContentImageURL {
filtered = append(filtered, part)
}
}
if len(filtered) == 0 {
processedMsg.Content = "[Image content not supported by this model]"
} else {
processedMsg.Content = filtered
}
} else if contentParts, ok := msg.Content.([]map[string]interface{}); ok {
filteredParts := make([]map[string]interface{}, 0) filteredParts := make([]map[string]interface{}, 0)
for _, part := range contentParts { for _, part := range contentParts {
partType, _ := part["type"].(string) partType, _ := part["type"].(string)
// Skip image content
if partType != "image_url" && partType != "image" { if partType != "image_url" && partType != "image" {
filteredParts = append(filteredParts, part) filteredParts = append(filteredParts, part)
} }
} }
// If all parts were filtered out, add placeholder text
if len(filteredParts) == 0 { if len(filteredParts) == 0 {
processedMsg.Content = "[Image content not supported by this model]" processedMsg.Content = "[Image content not supported by this model]"
} else if len(filteredParts) == 1 { } else if len(filteredParts) == 1 {

View file

@ -20,21 +20,12 @@ func GetCapabilities(connectorID string) *goullm.Capabilities {
return GetCapabilitiesFromConn(conn) return GetCapabilitiesFromConn(conn)
} }
// GetCapabilitiesFromConn get the capabilities from a connector instance. // GetCapabilitiesFromConn get the capabilities from a connector instance
// Prefers LLMConnector.GetCapabilities() when available, falls back to Setting() parsing.
func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities { func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if conn == nil { if conn == nil {
return getDefaultCapabilities() return getDefaultCapabilities()
} }
// Prefer typed LLMConnector interface
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
return caps
}
}
// Fallback to Setting() parsing for non-LLMConnector or nil capabilities
settings := conn.Setting() settings := conn.Setting()
if settings != nil { if settings != nil {
if caps, ok := settings["capabilities"]; ok { if caps, ok := settings["capabilities"]; ok {
@ -44,54 +35,12 @@ func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if capabilities, ok := caps.(goullm.Capabilities); ok { if capabilities, ok := caps.(goullm.Capabilities); ok {
return &capabilities return &capabilities
} }
if capsMap, ok := caps.(map[string]interface{}); ok {
return capabilitiesFromMap(capsMap)
}
} }
} }
return getDefaultCapabilities() return getDefaultCapabilities()
} }
// capabilitiesFromMap converts a JSON-deserialized map into goullm.Capabilities.
func capabilitiesFromMap(m map[string]interface{}) *goullm.Capabilities {
caps := getDefaultCapabilities()
if v, ok := m["streaming"].(bool); ok {
caps.Streaming = v
}
if v, ok := m["tool_calls"].(bool); ok {
caps.ToolCalls = v
}
if v, ok := m["vision"]; ok {
caps.Vision = v
}
if v, ok := m["audio"].(bool); ok {
caps.Audio = v
}
if v, ok := m["stt"].(bool); ok {
caps.STT = v
}
if v, ok := m["reasoning"].(bool); ok {
caps.Reasoning = v
}
if v, ok := m["json"].(bool); ok {
caps.JSON = v
}
if v, ok := m["multimodal"].(bool); ok {
caps.Multimodal = v
}
if v, ok := m["temperature_adjustable"].(bool); ok {
caps.TemperatureAdjustable = v
}
if v, ok := m["embedding"].(bool); ok {
caps.Embedding = v
}
if v, ok := m["image_generation"].(bool); ok {
caps.ImageGeneration = v
}
return caps
}
// getDefaultCapabilities returns minimal default capabilities // getDefaultCapabilities returns minimal default capabilities
func getDefaultCapabilities() *goullm.Capabilities { func getDefaultCapabilities() *goullm.Capabilities {
return &goullm.Capabilities{ return &goullm.Capabilities{
@ -116,8 +65,26 @@ func GetCapabilitiesMap(connectorID string) map[string]interface{} {
return ToMap(caps) return ToMap(caps)
} }
// ToMap converts Capabilities to map[string]interface{}. // ToMap converts Capabilities to map[string]interface{}
// Delegates to the canonical Capabilities.ToMap() method in gou/llm.
func ToMap(caps *goullm.Capabilities) map[string]interface{} { func ToMap(caps *goullm.Capabilities) map[string]interface{} {
return caps.ToMap() if caps == nil {
return nil
}
result := make(map[string]interface{})
if caps.Vision != nil {
result["vision"] = caps.Vision
}
result["audio"] = caps.Audio
result["stt"] = caps.STT
result["tool_calls"] = caps.ToolCalls
result["reasoning"] = caps.Reasoning
result["streaming"] = caps.Streaming
result["json"] = caps.JSON
result["multimodal"] = caps.Multimodal
result["temperature_adjustable"] = caps.TemperatureAdjustable
return result
} }

View file

@ -23,22 +23,3 @@ entries:
return: return:
type: object type: object
desc: "OpenAI-compatible response: { id, object, created, model, choices: [{ index, message: { role, content, tool_calls? }, finish_reason }], usage? }" desc: "OpenAI-compatible response: { id, object, created, model, choices: [{ index, message: { role, content, tool_calls? }, finish_reason }], usage? }"
- name: ImageGeneration
desc: Generate an image from a text prompt using an image generation model
args:
- name: connector
type: string
required: true
desc: Connector ID for an image generation model (e.g. dall-e-3)
- name: prompt
type: string
required: true
desc: Text description of the image to generate
- name: opts
type: object
required: false
desc: "Generation options: size (1024x1024), quality, style, n, etc."
return:
type: object
desc: "Image generation result: { image (base64), format (png) }"

View file

@ -1,183 +0,0 @@
package llm
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yaoapp/gou/connector"
gouhttp "github.com/yaoapp/gou/http"
goullm "github.com/yaoapp/gou/llm"
)
// ImageGenResponse holds the result of an image generation call.
// Image is always base64 encoded; if the provider returns a URL, it is downloaded and converted.
type ImageGenResponse struct {
Image string `json:"image"` // base64 encoded image data
Format string `json:"format"` // image format, e.g. "png", "jpeg"
}
// GenerateImage calls the /images/generations endpoint through the connector.
// options may include: size, n, quality, style, model, etc.
func GenerateImage(conn connector.Connector, prompt string, options map[string]interface{}) (*ImageGenResponse, error) {
host, key, authMode := resolveConnSettings(conn)
if host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
if key == "" {
return nil, fmt.Errorf("API key is not set")
}
if options == nil {
options = map[string]interface{}{}
}
options["prompt"] = prompt
if _, ok := options["model"]; !ok {
if lc, ok := conn.(goullm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
options["model"] = m
}
}
}
url := connector.BuildAPIURL(host, "/images/generations")
req := gouhttp.New(url)
req.SetHeader("Content-Type", "application/json")
setImageAuthHeaders(req, authMode, key)
resp := req.Post(options)
if resp.Status != 200 {
errMsg := extractAPIError(resp.Data)
return nil, fmt.Errorf("image generation failed (status %d, url %s): %s", resp.Status, url, errMsg)
}
return extractImageFromResponse(resp.Data)
}
func resolveConnSettings(conn connector.Connector) (host, key string, authMode goullm.AuthMode) {
authMode = goullm.AuthBearer
if lc, ok := conn.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
authMode = lc.GetAuthMode()
}
if host == "" || key == "" {
setting := conn.Setting()
if host == "" {
host, _ = setting["host"].(string)
}
if key == "" {
key, _ = setting["key"].(string)
}
}
return
}
func setImageAuthHeaders(req *gouhttp.Request, authMode goullm.AuthMode, key string) {
switch authMode {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
case goullm.AuthXAPIKey:
req.SetHeader("x-api-key", key)
default:
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
}
}
func extractImageFromResponse(data interface{}) (*ImageGenResponse, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal response: %w", err)
}
var parsed struct {
Data []struct {
B64JSON *string `json:"b64_json"`
URL *string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
if len(parsed.Data) == 0 {
return nil, fmt.Errorf("provider returned empty data array, no image was generated")
}
item := parsed.Data[0]
if item.B64JSON != nil && *item.B64JSON != "" {
return &ImageGenResponse{Image: *item.B64JSON, Format: "png"}, nil
}
if item.URL != nil && *item.URL != "" {
b64, format, err := downloadImageAsBase64(*item.URL)
if err != nil {
return nil, fmt.Errorf("provider returned url but download failed: %w", err)
}
return &ImageGenResponse{Image: b64, Format: format}, nil
}
return nil, fmt.Errorf("provider returned data but neither b64_json nor url field is present, the model may not support image generation")
}
func downloadImageAsBase64(imageURL string) (b64 string, format string, err error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(imageURL)
if err != nil {
return "", "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("download returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read body: %w", err)
}
if len(body) == 0 {
return "", "", fmt.Errorf("downloaded image is empty")
}
format = "png"
ct := resp.Header.Get("Content-Type")
switch {
case strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg"):
format = "jpeg"
case strings.Contains(ct, "webp"):
format = "webp"
case strings.Contains(ct, "gif"):
format = "gif"
default:
if strings.Contains(imageURL, ".jpeg") || strings.Contains(imageURL, ".jpg") {
format = "jpeg"
} else if strings.Contains(imageURL, ".webp") {
format = "webp"
}
}
b64 = base64.StdEncoding.EncodeToString(body)
return b64, format, nil
}
func extractAPIError(data interface{}) string {
raw, err := json.Marshal(data)
if err != nil {
return fmt.Sprintf("%v", data)
}
var parsed struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &parsed); err == nil && parsed.Error.Message != "" {
return parsed.Error.Message
}
return string(raw)
}

View file

@ -1,197 +0,0 @@
package llm
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExtractImageFromResponse_B64(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": "iVBORw0KGgoAAAANS...",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Image != "iVBORw0KGgoAAAANS..." {
t.Errorf("got Image=%q, want %q", resp.Image, "iVBORw0KGgoAAAANS...")
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
}
func TestExtractImageFromResponse_URL(t *testing.T) {
fakeImage := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10} // fake JPEG header bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": srv.URL + "/image_0.jpeg",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := base64.StdEncoding.EncodeToString(fakeImage)
if resp.Image != expected {
t.Errorf("got Image=%q, want %q", resp.Image, expected)
}
if resp.Format != "jpeg" {
t.Errorf("got Format=%q, want %q", resp.Format, "jpeg")
}
}
func TestExtractImageFromResponse_URLPng(t *testing.T) {
fakeImage := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/output.png",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
if resp.Image == "" {
t.Error("expected non-empty base64 Image")
}
}
func TestExtractImageFromResponse_URLDownloadFail(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/missing.png",
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for failed download")
}
}
func TestExtractImageFromResponse_Empty(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for empty data array")
}
}
func TestExtractImageFromResponse_NoData(t *testing.T) {
data := map[string]interface{}{}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for missing data field")
}
}
func TestExtractImageFromResponse_NullBoth(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": nil,
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error when both b64_json and url are null")
}
}
func TestDownloadImageAsBase64(t *testing.T) {
payload := []byte("fake-png-data")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(payload)
}))
defer srv.Close()
b64, format, err := downloadImageAsBase64(srv.URL + "/test.png")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "png" {
t.Errorf("got format=%q, want %q", format, "png")
}
decoded, _ := base64.StdEncoding.DecodeString(b64)
if string(decoded) != string(payload) {
t.Errorf("decoded content mismatch")
}
}
func TestDownloadImageAsBase64_FormatFromURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("data"))
}))
defer srv.Close()
_, format, err := downloadImageAsBase64(srv.URL + "/image.webp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "webp" {
t.Errorf("got format=%q, want %q (from URL fallback)", format, "webp")
}
}
func TestExtractAPIError_WithMessage(t *testing.T) {
data := map[string]interface{}{
"error": map[string]interface{}{
"message": "insufficient quota",
},
}
msg := extractAPIError(data)
if msg != "insufficient quota" {
t.Errorf("got %q, want %q", msg, "insufficient quota")
}
}
func TestExtractAPIError_NoMessage(t *testing.T) {
data := map[string]interface{}{
"something": "else",
}
msg := extractAPIError(data)
raw, _ := json.Marshal(data)
if msg != string(raw) {
t.Errorf("got %q, want raw JSON fallback", msg)
}
}

View file

@ -31,37 +31,6 @@ func SetJSAPIFactory() {
} }
} }
// GenerateImage implements LlmAPI.GenerateImage - generates an image from a text prompt
func (api *JSAPI) GenerateImage(connectorID string, prompt string, opts map[string]interface{}) interface{} {
result := &ImageGenResult{
Connector: connectorID,
}
conn, err := connector.Select(connectorID)
if err != nil {
result.Error = fmt.Sprintf("failed to select connector %s: %v", connectorID, err)
return result
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
result.Error = fmt.Sprintf("image generation failed: %v", err)
return result
}
result.Image = resp.Image
result.Format = resp.Format
return result
}
// ImageGenResult is the return type for GenerateImage JSAPI
type ImageGenResult struct {
Connector string `json:"connector"`
Image string `json:"image,omitempty"`
Format string `json:"format,omitempty"`
Error string `json:"error,omitempty"`
}
// Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer // Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer
func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} { func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} {
return api.StreamWithHandler(connectorID, messages, opts, nil) return api.StreamWithHandler(connectorID, messages, opts, nil)

View file

@ -16,7 +16,6 @@ import (
func init() { func init() {
process.Register("llm.ChatCompletions", ProcessChatCompletions) process.Register("llm.ChatCompletions", ProcessChatCompletions)
process.Register("llm.ImageGeneration", ProcessImageGeneration)
} }
// ProcessChatCompletions implements the llm.ChatCompletions Process. // ProcessChatCompletions implements the llm.ChatCompletions Process.
@ -156,55 +155,6 @@ func ProcessChatCompletions(p *process.Process) interface{} {
return toOpenAIFormat(response) return toOpenAIFormat(response)
} }
// ProcessImageGeneration implements the llm.ImageGeneration Process.
//
// Usage:
//
// Process("llm.ImageGeneration", connectorID, prompt)
// Process("llm.ImageGeneration", connectorID, prompt, opts)
//
// Args:
// - connectorID (string): Connector ID for an image generation model
// - prompt (string): Text description of the image to generate
// - opts (map): Optional. size, quality, style, n, etc.
//
// Returns: { image (base64), format (png) }
func ProcessImageGeneration(p *process.Process) interface{} {
p.ValidateArgNums(2)
connectorID := p.ArgsString(0)
if connectorID == "" {
return newErrorResponse("llm.ImageGeneration: connector is required")
}
prompt := p.ArgsString(1)
if prompt == "" {
return newErrorResponse("llm.ImageGeneration: prompt is required")
}
var opts map[string]interface{}
if p.NumOfArgs() > 2 && p.Args[2] != nil {
if o, ok := p.Args[2].(map[string]interface{}); ok {
opts = o
}
}
conn, _, err := selectWithCapabilities(connectorID)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: connector %s not found: %v", connectorID, err))
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: %v", err))
}
return map[string]interface{}{
"image": resp.Image,
"format": resp.Format,
}
}
// toOpenAIFormat converts CompletionResponse to OpenAI chat.completions format // toOpenAIFormat converts CompletionResponse to OpenAI chat.completions format
// for backward compatibility with code that consumed openai.chat.Completions. // for backward compatibility with code that consumed openai.chat.Completions.
func toOpenAIFormat(resp *agentContext.CompletionResponse) map[string]interface{} { func toOpenAIFormat(resp *agentContext.CompletionResponse) map[string]interface{} {

View file

@ -17,7 +17,6 @@ import (
"github.com/yaoapp/yao/agent/llm/adapters" "github.com/yaoapp/yao/agent/llm/adapters"
"github.com/yaoapp/yao/agent/llm/providers/base" "github.com/yaoapp/yao/agent/llm/providers/base"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/share"
) )
// Provider Anthropic Messages API provider // Provider Anthropic Messages API provider
@ -45,11 +44,11 @@ func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
// Tool call adapter // Tool call adapter
result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls)) result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls))
// Vision adapter (always registered to strip unsupported image content) // Vision adapter
visionSupport, visionFormat := context.GetVisionSupport(cap) visionSupport, visionFormat := context.GetVisionSupport(cap)
if visionSupport { if visionSupport {
result = append(result, adapters.NewVisionAdapter(true, visionFormat)) result = append(result, adapters.NewVisionAdapter(true, visionFormat))
} else { } else if cap.Vision != nil {
result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone)) result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone))
} }
@ -202,10 +201,21 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings via LLMConnector or fallback // Get connector settings
host, key, version, err := p.resolveHostKeyVersion() setting := p.Connector.Setting()
if err != nil { host, ok := setting["host"].(string)
return nil, err if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
version := "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
} }
// Build URL: host/v1/messages // Build URL: host/v1/messages
@ -217,13 +227,13 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
}) })
} }
// Create HTTP request with auth headers // Create HTTP request with Anthropic auth headers
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("x-api-key", key).
SetHeader("anthropic-version", version). SetHeader("anthropic-version", version).
SetHeader("Accept", "text/event-stream"). SetHeader("Accept", "text/event-stream").
SetHeader("User-Agent", "YaoEngine/"+share.VERSION) SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
setAnthropicAuthHeaders(req, p.Connector, key)
// Accumulate response data // Accumulate response data
accumulator := &streamAccumulator{ accumulator := &streamAccumulator{
@ -668,20 +678,31 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings via LLMConnector or fallback // Get connector settings
host, key, version, err := p.resolveHostKeyVersion() setting := p.Connector.Setting()
if err != nil { host, ok := setting["host"].(string)
return nil, err if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
version := "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
} }
url := buildAPIURL(host, "/messages") url := buildAPIURL(host, "/messages")
// Create HTTP request with auth headers // Create HTTP request
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("x-api-key", key).
SetHeader("anthropic-version", version). SetHeader("anthropic-version", version).
SetHeader("User-Agent", "YaoEngine/"+share.VERSION) SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
setAnthropicAuthHeaders(req, p.Connector, key)
resp := req.Post(requestBody) resp := req.Post(requestBody)
if resp.Code != 200 { if resp.Code != 200 {
@ -894,11 +915,6 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
} else if mt, ok := setting["max_tokens"].(int); ok && mt > 0 { } else if mt, ok := setting["max_tokens"].(int); ok && mt > 0 {
maxTokens = mt maxTokens = mt
} }
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 && maxTokens > caps.MaxOutputTokens {
maxTokens = caps.MaxOutputTokens
}
}
body["max_tokens"] = maxTokens body["max_tokens"] = maxTokens
// Temperature // Temperature
@ -926,13 +942,9 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
body["tool_choice"] = convertToolChoice(options.ToolChoice) body["tool_choice"] = convertToolChoice(options.ToolChoice)
} }
// Merge connector-level body params (thinking, etc.) // Thinking configuration from connector settings
// filtered through the SupportedParams / default whitelist. if thinking, exists := setting["thinking"]; exists && thinking != nil {
connParams := connector.FilterRequestBodyParams(setting, p.Connector) body["thinking"] = thinking
for k, v := range connParams {
if _, exists := body[k]; !exists {
body[k] = v
}
} }
return body, nil return body, nil
@ -1164,47 +1176,3 @@ func isRetryableError(err error) bool {
return false return false
} }
// resolveHostKeyVersion extracts host, key, and version via LLMConnector or Setting().
// Setting() is called at most once, and only when needed.
func (p *Provider) resolveHostKeyVersion() (host, key, version string, err error) {
setting := p.Connector.Setting()
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
} else {
host, _ = setting["host"].(string)
key, _ = setting["key"].(string)
}
// Version is Anthropic-specific, not on LLMConnector interface
version = "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
}
if host == "" {
return "", "", "", fmt.Errorf("no host found in connector settings")
}
if key == "" {
return "", "", "", fmt.Errorf("API key is not set")
}
return host, key, version, nil
}
// setAnthropicAuthHeaders sets auth headers based on LLMConnector.GetAuthMode().
func setAnthropicAuthHeaders(req *http.Request, conn connector.Connector, key string) {
if lc, ok := conn.(goullm.LLMConnector); ok {
switch lc.GetAuthMode() {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
return
case goullm.AuthBearer:
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
return
}
}
// Default for Anthropic: x-api-key
req.SetHeader("x-api-key", key)
}

View file

@ -140,30 +140,15 @@ func (p *Provider) GetConnectorStringSetting(key string) (string, error) {
// GetModel gets the model name from connector settings // GetModel gets the model name from connector settings
func (p *Provider) GetModel() (string, error) { func (p *Provider) GetModel() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
return m, nil
}
}
return p.GetConnectorStringSetting("model") return p.GetConnectorStringSetting("model")
} }
// GetAPIKey gets the API key from connector settings // GetAPIKey gets the API key from connector settings
func (p *Provider) GetAPIKey() (string, error) { func (p *Provider) GetAPIKey() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if k := lc.GetKey(); k != "" {
return k, nil
}
}
return p.GetConnectorStringSetting("key") return p.GetConnectorStringSetting("key")
} }
// GetHost gets the host URL from connector settings // GetHost gets the host URL from connector settings
func (p *Provider) GetHost() (string, error) { func (p *Provider) GetHost() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if u := lc.GetURL(); u != "" {
return u, nil
}
}
return p.GetConnectorStringSetting("host") return p.GetConnectorStringSetting("host")
} }

View file

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm/providers/anthropic" "github.com/yaoapp/yao/agent/llm/providers/anthropic"
"github.com/yaoapp/yao/agent/llm/providers/openai" "github.com/yaoapp/yao/agent/llm/providers/openai"
@ -62,18 +61,10 @@ func DetectAPIFormat(conn connector.Connector) string {
return "openai" return "openai"
} }
// Try LLMConnector for typed URL access, fall back to Setting() map // Check connector settings for host URL patterns as fallback
var host string settings := conn.Setting()
if lc, ok := conn.(goullm.LLMConnector); ok { if settings != nil {
host = lc.GetURL() if host, ok := settings["host"].(string); ok {
}
if host == "" {
if settings := conn.Setting(); settings != nil {
host, _ = settings["host"].(string)
}
}
if host != "" {
if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") { if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") {
return "anthropic" return "anthropic"
} }
@ -81,6 +72,7 @@ func DetectAPIFormat(conn connector.Connector) string {
return "openai" return "openai"
} }
} }
}
// Default to OpenAI-compatible // Default to OpenAI-compatible
return "openai" return "openai"

View file

@ -16,7 +16,6 @@ import (
"github.com/yaoapp/yao/agent/llm/adapters" "github.com/yaoapp/yao/agent/llm/adapters"
"github.com/yaoapp/yao/agent/llm/providers/base" "github.com/yaoapp/yao/agent/llm/providers/base"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/share"
"github.com/yaoapp/yao/utils/jsonschema" "github.com/yaoapp/yao/utils/jsonschema"
) )
@ -156,11 +155,12 @@ func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
// Tool call adapter // Tool call adapter
result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls)) result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls))
// Vision adapter (always registered to strip unsupported image content) // Vision adapter
visionSupport, visionFormat := context.GetVisionSupport(cap) visionSupport, visionFormat := context.GetVisionSupport(cap)
if visionSupport { if visionSupport {
result = append(result, adapters.NewVisionAdapter(true, visionFormat)) result = append(result, adapters.NewVisionAdapter(true, visionFormat))
} else { } else if cap.Vision != nil {
// Vision explicitly disabled, add adapter to remove image content
result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone)) result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone))
} }
@ -385,10 +385,16 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings via LLMConnector or fallback // Get connector settings
host, key, err := p.resolveHostKey() setting := p.Connector.Setting()
if err != nil { host, ok := setting["host"].(string)
return nil, err if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
} }
// Build URL // Build URL
@ -403,9 +409,9 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
// Create HTTP request with proxy support // Create HTTP request with proxy support
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)).
SetHeader("Accept", "text/event-stream"). SetHeader("Accept", "text/event-stream").
SetHeader("User-Agent", "YaoEngine/"+share.VERSION) SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
setAuthHeaders(req, p.Connector, key)
// Accumulate response data // Accumulate response data
accumulator := &streamAccumulator{ accumulator := &streamAccumulator{
@ -492,18 +498,16 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
accumulator.role = delta.Role accumulator.role = delta.Role
} }
reasoningText := delta.ReasoningContent // Handle reasoning content (DeepSeek R1)
if reasoningText == "" { if delta.ReasoningContent != "" {
reasoningText = delta.Reasoning // Start thinking message if not active
}
if reasoningText != "" {
if !messageTracker.active || messageTracker.messageType != message.ChunkThinking { if !messageTracker.active || messageTracker.messageType != message.ChunkThinking {
messageTracker.startMessage(message.ChunkThinking, handler) messageTracker.startMessage(message.ChunkThinking, handler)
} }
accumulator.reasoningContent += reasoningText accumulator.reasoningContent += delta.ReasoningContent
if handler != nil { if handler != nil {
handler(message.ChunkThinking, []byte(reasoningText)) handler(message.ChunkThinking, []byte(delta.ReasoningContent))
messageTracker.incrementChunk() messageTracker.incrementChunk()
} }
} }
@ -918,10 +922,16 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings via LLMConnector or fallback // Get connector settings
host, key, err := p.resolveHostKey() setting := p.Connector.Setting()
if err != nil { host, ok := setting["host"].(string)
return nil, err if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
} }
// Build URL // Build URL
@ -930,8 +940,8 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
// Create HTTP request with proxy support // Create HTTP request with proxy support
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "YaoEngine/"+share.VERSION) SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)).
setAuthHeaders(req, p.Connector, key) SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
// Make request // Make request
resp := req.Post(requestBody) resp := req.Post(requestBody)
@ -996,7 +1006,7 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
Model: fullResp.Model, Model: fullResp.Model,
Role: string(choice.Message.Role), Role: string(choice.Message.Role),
Content: content, Content: content,
ReasoningContent: reasoningOrFallback(choice.Message.ReasoningContent, choice.Message.Reasoning), ReasoningContent: choice.Message.ReasoningContent,
ToolCalls: choice.Message.ToolCalls, ToolCalls: choice.Message.ToolCalls,
FinishReason: choice.FinishReason, FinishReason: choice.FinishReason,
Usage: fullResp.Usage, Usage: fullResp.Usage,
@ -1030,6 +1040,12 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
return nil, fmt.Errorf("model is not set in connector") return nil, fmt.Errorf("model is not set in connector")
} }
// Get thinking setting from connector (for models that support reasoning/thinking mode)
var thinkingSetting interface{}
if thinking, exists := setting["thinking"]; exists {
thinkingSetting = thinking
}
// Convert messages to API format // Convert messages to API format
apiMessages := make([]map[string]interface{}, 0, len(messages)) apiMessages := make([]map[string]interface{}, 0, len(messages))
for _, msg := range messages { for _, msg := range messages {
@ -1083,10 +1099,6 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
apiMsg["tool_calls"] = msg.ToolCalls apiMsg["tool_calls"] = msg.ToolCalls
} }
if msg.ReasoningContent != "" {
apiMsg["reasoning_content"] = msg.ReasoningContent
}
if msg.Refusal != nil { if msg.Refusal != nil {
apiMsg["refusal"] = *msg.Refusal apiMsg["refusal"] = *msg.Refusal
} }
@ -1108,19 +1120,11 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
// Use max_completion_tokens (modern API parameter for GPT-5+) // Use max_completion_tokens (modern API parameter for GPT-5+)
// GPT-5 models only support max_completion_tokens (not max_tokens) // GPT-5 models only support max_completion_tokens (not max_tokens)
if options.MaxCompletionTokens != nil || options.MaxTokens != nil {
maxTokens := 0
if options.MaxCompletionTokens != nil { if options.MaxCompletionTokens != nil {
maxTokens = *options.MaxCompletionTokens body["max_completion_tokens"] = *options.MaxCompletionTokens
} else { } else if options.MaxTokens != nil {
maxTokens = *options.MaxTokens // Fallback: convert MaxTokens to max_completion_tokens for compatibility
} body["max_completion_tokens"] = *options.MaxTokens
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 && maxTokens > caps.MaxOutputTokens {
maxTokens = caps.MaxOutputTokens
}
}
body["max_completion_tokens"] = maxTokens
} }
if options.TopP != nil { if options.TopP != nil {
@ -1198,14 +1202,9 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
body["audio"] = options.Audio body["audio"] = options.Audio
} }
// Merge connector-level body params (thinking, reasoning, enable_thinking, etc.) // Add thinking parameter for models that support reasoning/thinking mode
// filtered through the SupportedParams / default whitelist. if thinkingSetting != nil {
// CompletionOptions (per-call) take precedence over connector defaults. body["thinking"] = thinkingSetting
connParams := connector.FilterRequestBodyParams(setting, p.Connector)
for k, v := range connParams {
if _, exists := body[k]; !exists {
body[k] = v
}
} }
return body, nil return body, nil
@ -1290,44 +1289,3 @@ func isRetryableError(err error) bool {
return false return false
} }
// resolveHostKey extracts host and key via LLMConnector or Setting() fallback.
func (p *Provider) resolveHostKey() (host, key string, err error) {
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
} else {
setting := p.Connector.Setting()
host, _ = setting["host"].(string)
key, _ = setting["key"].(string)
}
if host == "" {
return "", "", fmt.Errorf("no host found in connector settings")
}
if key == "" {
return "", "", fmt.Errorf("API key is not set")
}
return host, key, nil
}
// setAuthHeaders sets authentication headers based on LLMConnector.GetAuthMode().
func setAuthHeaders(req *http.Request, conn connector.Connector, key string) {
if lc, ok := conn.(goullm.LLMConnector); ok {
switch lc.GetAuthMode() {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
return
case goullm.AuthXAPIKey:
req.SetHeader("x-api-key", key)
return
}
}
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
}
func reasoningOrFallback(primary, fallback string) string {
if primary != "" {
return primary
}
return fallback
}

View file

@ -30,8 +30,7 @@ type Delta struct {
type DeltaContent struct { type DeltaContent struct {
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek direct API ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
Reasoning string `json:"reasoning,omitempty"` // OpenRouter
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"` ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
Refusal string `json:"refusal,omitempty"` Refusal string `json:"refusal,omitempty"`
} }
@ -61,8 +60,7 @@ type CompletionResponseFull struct {
Message struct { Message struct {
Role context.MessageRole `json:"role"` Role context.MessageRole `json:"role"`
Content interface{} `json:"content,omitempty"` // string or array Content interface{} `json:"content,omitempty"` // string or array
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek direct API ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
Reasoning string `json:"reasoning,omitempty"` // OpenRouter
ToolCalls []context.ToolCall `json:"tool_calls,omitempty"` ToolCalls []context.ToolCall `json:"tool_calls,omitempty"`
Refusal *string `json:"refusal,omitempty"` Refusal *string `json:"refusal,omitempty"`
} `json:"message"` } `json:"message"`

View file

@ -1,94 +0,0 @@
package llm
import (
"fmt"
"strings"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/llmprovider"
)
// RolePrefix marks a Connector field value as a role reference (e.g. "use::light").
const RolePrefix = "use::"
// ResolveConnector resolves an LLM connector using a unified priority chain.
//
// connectorID may be:
// - explicit connector ID (e.g. "openai.gpt-4o") — resolved directly
// - role reference with prefix (e.g. "use::light") — resolved via llmprovider roles
// - empty string — falls back to the "default" role
//
// Priority for role-based resolution:
// 1. GetRoleBy(role, identity) — user/team scoped setting
// 2. GetRole(role) — system-level default for that role
// 3. GetRoleBy("default", identity) — fallback to "default" role (user/team)
// 4. GetRole("default") — fallback to "default" role (system)
// 5. error — caller decides whether to apply legacy fallback
func ResolveConnector(connectorID string, identity llmprovider.Identity) (connector.Connector, *goullm.Capabilities, error) {
// Parse use:: prefix to extract role
role := ""
if strings.HasPrefix(connectorID, RolePrefix) {
role = strings.TrimPrefix(connectorID, RolePrefix)
connectorID = ""
}
// Explicit connector ID takes highest priority
if connectorID != "" {
return selectWithCapabilities(connectorID)
}
// Empty connector with no role → treat as "default"
if role == "" {
role = "default"
}
if llmprovider.Global == nil {
return nil, nil, fmt.Errorf("llmprovider not initialized and no explicit connector specified")
}
// Resolve by the specified role (e.g. "light", "vision")
if role != "default" {
if identity != nil {
if cid, err := llmprovider.Global.GetRoleBy(role, identity); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
if cid, err := llmprovider.Global.GetRole(role); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
// Fallback to "default" role
if identity != nil {
if cid, err := llmprovider.Global.GetRoleBy("default", identity); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
if cid, err := llmprovider.Global.GetRole("default"); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
return nil, nil, fmt.Errorf("no connector resolved for role %q", role)
}
func selectWithCapabilities(connectorID string) (connector.Connector, *goullm.Capabilities, error) {
conn, err := connector.Select(connectorID)
if err != nil && llmprovider.Global != nil {
conn, err = llmprovider.Global.GetModel(connectorID)
}
if err != nil {
return nil, nil, err
}
caps := GetCapabilitiesFromConn(conn)
return conn, caps, nil
}

View file

@ -1,171 +0,0 @@
package llm_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/test"
)
func TestMain(m *testing.M) {
test.Prepare(nil, config.Conf)
defer test.Clean()
os.Exit(m.Run())
}
type mockIdentity struct {
UserID string
TeamID string
}
func (m *mockIdentity) GetUserID() string { return m.UserID }
func (m *mockIdentity) GetTeamID() string { return m.TeamID }
func setupResolveTest(t *testing.T) string {
t.Helper()
test.Prepare(t, config.Conf)
err := setting.Init()
require.NoError(t, err)
err = llmprovider.Init()
require.NoError(t, err)
connIDs := connector.AIConnectors
if len(connIDs) == 0 {
t.Skip("no AI connectors available in test env")
}
cid := connIDs[0].Value
t.Cleanup(func() {
s, _ := store.Get("__yao.store")
if s != nil {
s.Del("llmprovider:*")
}
c, _ := store.Get("__yao.cache")
if c != nil {
c.Del("llmprovider:*")
}
test.Clean()
})
return cid
}
// --- use:: prefix tests ---
func TestResolveConnector_UseLight(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::light", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseDefault(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::default", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseLightWithIdentity(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::light", &mockIdentity{UserID: "u1", TeamID: "t1"})
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseLightNoProvider(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
saved := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = saved }()
_, _, err := llm.ResolveConnector("use::light", nil)
assert.Error(t, err)
}
// --- Explicit connector tests ---
func TestResolveConnector_ExplicitID(t *testing.T) {
cid := setupResolveTest(t)
conn, caps, err := llm.ResolveConnector(cid, nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_ExplicitIDPriority(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
// Explicit connector ID is NOT a use:: prefix, so it takes priority
conn, caps, err := llm.ResolveConnector(cid, &mockIdentity{UserID: "u1"})
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_InvalidID(t *testing.T) {
setupResolveTest(t)
_, _, err := llm.ResolveConnector("nonexistent-connector-xyz", nil)
assert.Error(t, err)
}
// --- Empty connector fallback ---
func TestResolveConnector_EmptyFallbackDefault(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
})
require.NoError(t, err)
// Empty string → treated as use::default
conn, caps, err := llm.ResolveConnector("", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}

View file

@ -19,7 +19,6 @@ import (
"github.com/yaoapp/yao/agent/store/xun" "github.com/yaoapp/yao/agent/store/xun"
"github.com/yaoapp/yao/agent/types" "github.com/yaoapp/yao/agent/types"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
) )
var agentDSL *types.DSL var agentDSL *types.DSL
@ -228,17 +227,14 @@ func initAssistant() error {
if agentDSL.System != nil { if agentDSL.System != nil {
assistant.SetSystemConfig(&assistant.SystemConfig{ assistant.SetSystemConfig(&assistant.SystemConfig{
Default: agentDSL.System.Default, Default: agentDSL.System.Default,
Light: agentDSL.System.Light,
Vision: agentDSL.System.Vision,
Audio: agentDSL.System.Audio,
Heavy: agentDSL.System.Heavy,
Keyword: agentDSL.System.Keyword, Keyword: agentDSL.System.Keyword,
QueryDSL: agentDSL.System.QueryDSL, QueryDSL: agentDSL.System.QueryDSL,
Title: agentDSL.System.Title, Title: agentDSL.System.Title,
Prompt: agentDSL.System.Prompt, Prompt: agentDSL.System.Prompt,
RobotPrompt: agentDSL.System.RobotPrompt,
NeedSearch: agentDSL.System.NeedSearch, NeedSearch: agentDSL.System.NeedSearch,
Entity: agentDSL.System.Entity, Entity: agentDSL.System.Entity,
Vision: agentDSL.System.Vision,
Voice: agentDSL.System.Voice,
}) })
} }
@ -457,22 +453,6 @@ func GetSearchConfig() *searchTypes.Config {
return agentDSL.Search return agentDSL.Search
} }
// SyncLLMDefaults writes the agent.yml system role defaults into setting.Global.
// Must be called after both llmprovider.Init() and setting.Init() have completed.
func SyncLLMDefaults() error {
if agentDSL == nil || agentDSL.System == nil {
return nil
}
if llmprovider.Global == nil {
return fmt.Errorf("llmprovider.Global not initialized")
}
roles := buildSystemRoles(agentDSL.System)
if len(roles) == 0 {
return nil
}
return llmprovider.Global.SetDefaults(roles)
}
// defaultAssistant get the default assistant // defaultAssistant get the default assistant
func defaultAssistant() (*assistant.Assistant, error) { func defaultAssistant() (*assistant.Assistant, error) {
if agentDSL.Uses == nil || agentDSL.Uses.Default == "" { if agentDSL.Uses == nil || agentDSL.Uses.Default == "" {
@ -481,34 +461,12 @@ func defaultAssistant() (*assistant.Assistant, error) {
return assistant.Get(agentDSL.Uses.Default) return assistant.Get(agentDSL.Uses.Default)
} }
// buildSystemRoles converts the System config block into a role→connectorID map
// for llmprovider.SetDefaults. Only role-level keys are written here; per-agent
// overrides (keyword, title, querydsl, etc.) are consumed by resolveSystemConnector.
func buildSystemRoles(sys *types.System) map[string]string {
roles := make(map[string]string)
add := func(role, cid string) {
if cid != "" {
roles[role] = cid
}
}
add("default", sys.Default)
add("light", sys.Light)
add("vision", sys.Vision)
add("audio", sys.Audio)
add("heavy", sys.Heavy)
return roles
}
// resolveEnvStrings resolves $ENV.XXX references in agent.yml string fields. // resolveEnvStrings resolves $ENV.XXX references in agent.yml string fields.
// agent.yml is parsed via yaml.Unmarshal which does not handle $ENV substitution, // agent.yml is parsed via yaml.Unmarshal which does not handle $ENV substitution,
// unlike connector files which call helper.EnvString explicitly during Register. // unlike connector files which call helper.EnvString explicitly during Register.
func resolveEnvStrings(setting *types.DSL) { func resolveEnvStrings(setting *types.DSL) {
if setting.System != nil { if setting.System != nil {
setting.System.Default = helper.EnvString(setting.System.Default) setting.System.Default = helper.EnvString(setting.System.Default)
setting.System.Light = helper.EnvString(setting.System.Light)
setting.System.Vision = helper.EnvString(setting.System.Vision)
setting.System.Audio = helper.EnvString(setting.System.Audio)
setting.System.Heavy = helper.EnvString(setting.System.Heavy)
setting.System.Keyword = helper.EnvString(setting.System.Keyword) setting.System.Keyword = helper.EnvString(setting.System.Keyword)
setting.System.QueryDSL = helper.EnvString(setting.System.QueryDSL) setting.System.QueryDSL = helper.EnvString(setting.System.QueryDSL)
setting.System.Title = helper.EnvString(setting.System.Title) setting.System.Title = helper.EnvString(setting.System.Title)
@ -516,6 +474,8 @@ func resolveEnvStrings(setting *types.DSL) {
setting.System.RobotPrompt = helper.EnvString(setting.System.RobotPrompt) setting.System.RobotPrompt = helper.EnvString(setting.System.RobotPrompt)
setting.System.NeedSearch = helper.EnvString(setting.System.NeedSearch) setting.System.NeedSearch = helper.EnvString(setting.System.NeedSearch)
setting.System.Entity = helper.EnvString(setting.System.Entity) setting.System.Entity = helper.EnvString(setting.System.Entity)
setting.System.Vision = helper.EnvString(setting.System.Vision)
setting.System.Voice = helper.EnvString(setting.System.Voice)
} }
if setting.Uses != nil { if setting.Uses != nil {

View file

@ -229,7 +229,7 @@ func TestResolveEnvStrings(t *testing.T) {
NeedSearch: "$ENV.TEST_CONNECTOR", NeedSearch: "$ENV.TEST_CONNECTOR",
Entity: "$ENV.TEST_CONNECTOR", Entity: "$ENV.TEST_CONNECTOR",
Vision: "$ENV.TEST_CONNECTOR", Vision: "$ENV.TEST_CONNECTOR",
Audio: "$ENV.TEST_CONNECTOR", Voice: "$ENV.TEST_CONNECTOR",
}, },
} }
resolveEnvStrings(setting) resolveEnvStrings(setting)
@ -243,24 +243,24 @@ func TestResolveEnvStrings(t *testing.T) {
assert.Equal(t, "openai.gpt-5", setting.System.NeedSearch) assert.Equal(t, "openai.gpt-5", setting.System.NeedSearch)
assert.Equal(t, "openai.gpt-5", setting.System.Entity) assert.Equal(t, "openai.gpt-5", setting.System.Entity)
assert.Equal(t, "openai.gpt-5", setting.System.Vision) assert.Equal(t, "openai.gpt-5", setting.System.Vision)
assert.Equal(t, "openai.gpt-5", setting.System.Audio) assert.Equal(t, "openai.gpt-5", setting.System.Voice)
}) })
t.Run("SystemVisionAudioSeparateEnv", func(t *testing.T) { t.Run("SystemVisionVoiceSeparateEnv", func(t *testing.T) {
t.Setenv("TEST_VISION_CONN", "openai.gpt-4o") t.Setenv("TEST_VISION_CONN", "openai.gpt-4o")
t.Setenv("TEST_AUDIO_CONN", "whisper-1") t.Setenv("TEST_VOICE_CONN", "whisper-1")
setting := &types.DSL{ setting := &types.DSL{
System: &types.System{ System: &types.System{
Default: "$ENV.TEST_CONNECTOR", Default: "$ENV.TEST_CONNECTOR",
Vision: "$ENV.TEST_VISION_CONN", Vision: "$ENV.TEST_VISION_CONN",
Audio: "$ENV.TEST_AUDIO_CONN", Voice: "$ENV.TEST_VOICE_CONN",
}, },
} }
resolveEnvStrings(setting) resolveEnvStrings(setting)
assert.Equal(t, "openai.gpt-5", setting.System.Default) assert.Equal(t, "openai.gpt-5", setting.System.Default)
assert.Equal(t, "openai.gpt-4o", setting.System.Vision) assert.Equal(t, "openai.gpt-4o", setting.System.Vision)
assert.Equal(t, "whisper-1", setting.System.Audio) assert.Equal(t, "whisper-1", setting.System.Voice)
}) })
t.Run("UsesFields", func(t *testing.T) { t.Run("UsesFields", func(t *testing.T) {

View file

@ -111,7 +111,6 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req
Messages: req.Messages, Messages: req.Messages,
PlanTime: req.PlanAt, PlanTime: req.PlanAt,
ExecutorMode: req.ExecutorMode, ExecutorMode: req.ExecutorMode,
Locale: req.Locale,
} }
// Call manager's Intervene // Call manager's Intervene

View file

@ -13,8 +13,6 @@ import (
"strings" "strings"
"time" "time"
"path/filepath"
"github.com/yaoapp/gou/process" "github.com/yaoapp/gou/process"
"github.com/yaoapp/gou/text" "github.com/yaoapp/gou/text"
agentcontext "github.com/yaoapp/yao/agent/context" agentcontext "github.com/yaoapp/yao/agent/context"
@ -23,7 +21,6 @@ import (
eventtypes "github.com/yaoapp/yao/event/types" eventtypes "github.com/yaoapp/yao/event/types"
"github.com/yaoapp/yao/messenger" "github.com/yaoapp/yao/messenger"
messengerTypes "github.com/yaoapp/yao/messenger/types" messengerTypes "github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/workspace"
) )
// handleDelivery routes delivery content to configured channels (email, webhook, process). // handleDelivery routes delivery content to configured channels (email, webhook, process).
@ -55,10 +52,6 @@ func (h *robotHandler) handleDelivery(ctx context.Context, ev *eventtypes.Event,
return return
} }
if ev.Auth != nil {
ctx = context.WithValue(ctx, "identity", ev.Auth)
}
deliveryCtx := &robottypes.DeliveryContext{ deliveryCtx := &robottypes.DeliveryContext{
MemberID: payload.MemberID, MemberID: payload.MemberID,
ExecutionID: payload.ExecutionID, ExecutionID: payload.ExecutionID,
@ -435,15 +428,6 @@ func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAt
result := make([]messengerTypes.Attachment, 0, len(attachments)) result := make([]messengerTypes.Attachment, 0, len(attachments))
for _, att := range attachments { for _, att := range attachments {
// Handle workspace:// URIs — read file content from workspace FS
if strings.HasPrefix(att.File, "workspace://") {
wsAtt := convertWorkspaceAttachment(ctx, att)
if wsAtt != nil {
result = append(result, *wsAtt)
}
continue
}
uploader, fileID, isWrapper := attachment.Parse(att.File) uploader, fileID, isWrapper := attachment.Parse(att.File)
if !isWrapper { if !isWrapper {
log.Warn("convertAttachments: skipping non-wrapper file value=%q title=%q", att.File, att.Title) log.Warn("convertAttachments: skipping non-wrapper file value=%q title=%q", att.File, att.Title)
@ -493,87 +477,6 @@ func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAt
return result return result
} }
// convertWorkspaceAttachment reads a file from workspace:// URI and returns a messenger attachment.
// URI format: workspace://<wsID>/<path>
func convertWorkspaceAttachment(ctx context.Context, att robottypes.DeliveryAttachment) *messengerTypes.Attachment {
uri := att.File
// Strip "workspace://" prefix
rest := strings.TrimPrefix(uri, "workspace://")
slashIdx := strings.Index(rest, "/")
if slashIdx < 0 {
log.Warn("convertWorkspaceAttachment: invalid URI %q — no path after wsID", uri)
return nil
}
wsID := rest[:slashIdx]
filePath := rest[slashIdx+1:]
if wsID == "" || filePath == "" {
log.Warn("convertWorkspaceAttachment: empty wsID or path in URI %q", uri)
return nil
}
wsm := workspace.M()
if wsm == nil {
log.Warn("convertWorkspaceAttachment: workspace manager not available for URI %q", uri)
return nil
}
wsFS, err := wsm.FS(ctx, wsID)
if err != nil {
log.Warn("convertWorkspaceAttachment: cannot get FS for workspace %q: %v", wsID, err)
return nil
}
content, err := wsFS.ReadFile(filePath)
if err != nil {
log.Warn("convertWorkspaceAttachment: failed to read %q from workspace %q: %v", filePath, wsID, err)
return nil
}
filename := filepath.Base(filePath)
if att.Title != "" {
filename = att.Title
}
contentType := mimeFromExtDelivery(filepath.Ext(filename))
log.Info("convertWorkspaceAttachment: added workspace attachment filename=%q contentType=%q size=%d uri=%q",
filename, contentType, len(content), uri)
return &messengerTypes.Attachment{
Filename: filename,
ContentType: contentType,
Content: content,
}
}
func mimeFromExtDelivery(ext string) string {
switch strings.ToLower(ext) {
case ".pdf":
return "application/pdf"
case ".html", ".htm":
return "text/html"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".csv":
return "text/csv"
case ".json":
return "application/json"
case ".md":
return "text/markdown"
case ".txt":
return "text/plain"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}
func attachmentManagerKeys() []string { func attachmentManagerKeys() []string {
keys := make([]string, 0, len(attachment.Managers)) keys := make([]string, 0, len(attachment.Managers))
for k := range attachment.Managers { for k := range attachment.Managers {

View file

@ -91,24 +91,13 @@ func buildContentParts(cm *dtapi.ConvertedMessage) []interface{} {
if url == "" { if url == "" {
url = mi.URL url = mi.URL
} }
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": url,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{ parts = append(parts, map[string]interface{}{
"type": "file", "type": "file",
"file": map[string]interface{}{ "file_url": url,
"url": url, "mime_type": mi.MimeType,
"filename": mi.FileName, "file_name": mi.FileName,
},
}) })
} }
}
return parts return parts
} }

View file

@ -96,24 +96,13 @@ func buildContentParts(cm *dcapi.ConvertedMessage) []interface{} {
if url == "" { if url == "" {
continue continue
} }
if strings.HasPrefix(mi.ContentType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": url,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{ parts = append(parts, map[string]interface{}{
"type": "file", "type": "file",
"file": map[string]interface{}{ "file_url": url,
"url": url, "mime_type": mi.ContentType,
"filename": mi.FileName, "file_name": mi.FileName,
},
}) })
} }
}
return parts return parts
} }

View file

@ -85,24 +85,13 @@ func buildContentParts(cm *fsapi.ConvertedMessage) []interface{} {
if mi.Wrapper == "" { if mi.Wrapper == "" {
continue continue
} }
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": mi.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{ parts = append(parts, map[string]interface{}{
"type": "file", "type": "file",
"file": map[string]interface{}{ "file_url": mi.Wrapper,
"url": mi.Wrapper, "mime_type": mi.MimeType,
"filename": mi.FileName, "file_name": mi.FileName,
},
}) })
} }
}
return parts return parts
} }

View file

@ -93,24 +93,13 @@ func buildContentParts(cm *tgapi.ConvertedMessage) []interface{} {
if mi.Wrapper == "" { if mi.Wrapper == "" {
continue continue
} }
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": mi.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{ parts = append(parts, map[string]interface{}{
"type": "file", "type": "file",
"file": map[string]interface{}{ "file_url": mi.Wrapper,
"url": mi.Wrapper, "mime_type": mi.MimeType,
"filename": mi.FileName, "file_name": mi.FileName,
},
}) })
} }
}
return parts return parts
} }

View file

@ -3,7 +3,6 @@ package weixin
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
agentcontext "github.com/yaoapp/yao/agent/context" agentcontext "github.com/yaoapp/yao/agent/context"
@ -114,24 +113,13 @@ func (a *Adapter) handleMessage(ctx context.Context, entry *botEntry, msg *weixi
parts = append(parts, map[string]interface{}{"type": "text", "text": content}) parts = append(parts, map[string]interface{}{"type": "text", "text": content})
} }
for _, m := range mediaItems { for _, m := range mediaItems {
if strings.HasPrefix(m.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": m.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{ parts = append(parts, map[string]interface{}{
"type": "file", "type": "file",
"file": map[string]interface{}{ "file_url": m.Wrapper,
"url": m.Wrapper, "mime_type": m.MimeType,
"filename": m.FileName, "file_name": m.FileName,
},
}) })
} }
}
msgContent = parts msgContent = parts
} }

View file

@ -5,14 +5,12 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
agentcontext "github.com/yaoapp/yao/agent/context" agentcontext "github.com/yaoapp/yao/agent/context"
events "github.com/yaoapp/yao/agent/robot/events" events "github.com/yaoapp/yao/agent/robot/events"
"github.com/yaoapp/yao/attachment" "github.com/yaoapp/yao/attachment"
weixinapi "github.com/yaoapp/yao/integrations/weixin" weixinapi "github.com/yaoapp/yao/integrations/weixin"
"github.com/yaoapp/yao/workspace"
) )
func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error { func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error {
@ -170,37 +168,7 @@ func (a *Adapter) sendMediaFromURL(ctx context.Context, entry *botEntry, toUserI
var plaintext []byte var plaintext []byte
var contentType string var contentType string
if strings.HasPrefix(fileURL, "workspace://") { if isWrapper(fileURL) {
rest := strings.TrimPrefix(fileURL, "workspace://")
slashIdx := strings.Index(rest, "/")
if slashIdx < 0 {
return fmt.Errorf("invalid workspace URL: %s", fileURL)
}
wsID := rest[:slashIdx]
filePath := rest[slashIdx+1:]
wsm := workspace.M()
if wsm == nil {
return fmt.Errorf("workspace manager not initialized")
}
wsFS, err := wsm.FS(ctx, wsID)
if err != nil {
return fmt.Errorf("open workspace %s: %w", wsID, err)
}
defer wsFS.Close()
plaintext, err = wsFS.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read workspace file %s/%s: %w", wsID, filePath, err)
}
contentType = mimeFromExt(filepath.Ext(filePath))
if fileName == "" {
fileName = filepath.Base(filePath)
}
log.Info("weixin sendMedia: workspace read bytes=%d contentType=%q fileName=%q", len(plaintext), contentType, fileName)
} else if isWrapper(fileURL) {
managerName, fileID, err := parseWrapper(fileURL) managerName, fileID, err := parseWrapper(fileURL)
if err != nil { if err != nil {
return err return err
@ -323,37 +291,6 @@ func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) {
return parts, ok return parts, ok
} }
func mimeFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".pdf":
return "application/pdf"
case ".html", ".htm":
return "text/html"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".md":
return "text/markdown"
case ".txt":
return "text/plain"
case ".csv":
return "text/csv"
case ".json":
return "application/json"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}
func (a *Adapter) resolveByAccountID(accountID string) *botEntry { func (a *Adapter) resolveByAccountID(accountID string) *botEntry {
if accountID == "" { if accountID == "" {
return nil return nil

View file

@ -33,15 +33,6 @@ func (h *robotHandler) handleMessage(ctx context.Context, ev *eventtypes.Event,
result, err := callHostAgent(ctx, &payload) result, err := callHostAgent(ctx, &payload)
if err != nil { if err != nil {
log.Error("message handler: host agent call failed robot=%s: %v", payload.RobotID, err) log.Error("message handler: host agent call failed robot=%s: %v", payload.RobotID, err)
if reply := getReplyFunc(); reply != nil {
errMsg := friendlyErrorMessage(payload.Metadata.Locale)
_ = reply(ctx, &agentcontext.Message{
Role: agentcontext.RoleAssistant,
Content: errMsg,
}, payload.Metadata)
}
if ev.IsCall { if ev.IsCall {
resp <- eventtypes.Result{Err: err} resp <- eventtypes.Result{Err: err}
} }
@ -82,7 +73,6 @@ func callHostAgent(ctx context.Context, payload *MessagePayload) (*MessageResult
authorized := &oauthtypes.AuthorizedInfo{ authorized := &oauthtypes.AuthorizedInfo{
UserID: payload.Metadata.SenderID, UserID: payload.Metadata.SenderID,
TeamID: record.TeamID,
} }
chatID := fmt.Sprintf("%s:%s", payload.Metadata.Channel, payload.Metadata.ChatID) chatID := fmt.Sprintf("%s:%s", payload.Metadata.Channel, payload.Metadata.ChatID)
agentCtx := agentcontext.New(ctx, authorized, chatID) agentCtx := agentcontext.New(ctx, authorized, chatID)
@ -242,13 +232,6 @@ func resolveHostAssistantID(ctx context.Context, memberID string) (string, *robo
return hostID, record, nil return hostID, record, nil
} }
func friendlyErrorMessage(locale string) string {
if strings.HasPrefix(locale, "zh") {
return "抱歉,处理您的消息时出现了问题,请稍后重试。"
}
return "Sorry, there was a problem processing your message. Please try again later."
}
func taskDeployedMessage(execID string, locale string) string { func taskDeployedMessage(execID string, locale string) string {
if strings.HasPrefix(locale, "zh") { if strings.HasPrefix(locale, "zh") {
return fmt.Sprintf("任务已部署(执行编号: %s完成后会将结果发送给你。", execID) return fmt.Sprintf("任务已部署(执行编号: %s完成后会将结果发送给你。", execID)

View file

@ -53,11 +53,6 @@ type AgentCaller struct {
// When non-empty, injected into agentCtx.Metadata["workspace_id"] for sandbox node resolution. // When non-empty, injected into agentCtx.Metadata["workspace_id"] for sandbox node resolution.
Workspace string Workspace string
// Mode is the agent execution mode (e.g., "task" for robot P3 execution).
// When non-empty, injected into agentCtx.Metadata["mode"] (exposed as $CTX.MODE
// in prompt templates) and into opts.Mode for framework-level buffer/chat recording.
Mode string
// log is an optional structured logger; when set, Call emits agent-call logs. // log is an optional structured logger; when set, Call emits agent-call logs.
log *execLogger log *execLogger
} }
@ -199,7 +194,6 @@ func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages
Search: c.SkipSearch, Search: c.SkipSearch,
}, },
Connector: c.Connector, Connector: c.Connector,
Mode: c.Mode,
} }
agentCtx := c.buildAgentContext(ctx, assistantID) agentCtx := c.buildAgentContext(ctx, assistantID)
@ -234,7 +228,7 @@ func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages
} }
if c.log != nil { if c.log != nil {
c.log.logAgentCall(assistantID, c.Connector, result) c.log.logAgentCall(assistantID, result)
} }
return result, nil return result, nil
@ -282,7 +276,6 @@ func (c *AgentCaller) CallStream(ctx *robottypes.Context, assistantID string, me
Search: c.SkipSearch, Search: c.SkipSearch,
}, },
Connector: c.Connector, Connector: c.Connector,
Mode: c.Mode,
} }
// Hook OnMessage to intercept streaming chunks and forward to callback // Hook OnMessage to intercept streaming chunks and forward to callback
@ -339,7 +332,7 @@ func (c *AgentCaller) CallStream(ctx *robottypes.Context, assistantID string, me
} }
if c.log != nil { if c.log != nil {
c.log.logAgentCall(assistantID, c.Connector, result) c.log.logAgentCall(assistantID, result)
} }
return result, nil return result, nil
@ -373,7 +366,6 @@ func (c *AgentCaller) CallStreamRaw(ctx *robottypes.Context, assistantID string,
Search: c.SkipSearch, Search: c.SkipSearch,
}, },
Connector: c.Connector, Connector: c.Connector,
Mode: c.Mode,
} }
if onMessage != nil { if onMessage != nil {
@ -408,7 +400,7 @@ func (c *AgentCaller) CallStreamRaw(ctx *robottypes.Context, assistantID string,
} }
if c.log != nil { if c.log != nil {
c.log.logAgentCall(assistantID, c.Connector, result) c.log.logAgentCall(assistantID, result)
} }
return result, nil return result, nil
@ -456,17 +448,12 @@ func (c *AgentCaller) buildAgentContext(ctx *robottypes.Context, assistantID str
} }
agentCtx.Logger = agentcontext.Noop() agentCtx.Logger = agentcontext.Noop()
if c.Workspace != "" || c.Mode != "" { if c.Workspace != "" {
if agentCtx.Metadata == nil { if agentCtx.Metadata == nil {
agentCtx.Metadata = map[string]interface{}{} agentCtx.Metadata = map[string]interface{}{}
} }
if c.Workspace != "" {
agentCtx.Metadata["workspace_id"] = c.Workspace agentCtx.Metadata["workspace_id"] = c.Workspace
} }
if c.Mode != "" {
agentCtx.Metadata["MODE"] = c.Mode
}
}
kunlog.Trace("[robot-agent] context built: assistantID=%s chatID=%s contextID=%s", assistantID, c.ChatID, agentCtx.ID) kunlog.Trace("[robot-agent] context built: assistantID=%s chatID=%s contextID=%s", assistantID, c.ChatID, agentCtx.ID)
return agentCtx return agentCtx

View file

@ -3,17 +3,14 @@ package standard
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path"
"strings" "strings"
"time" "time"
"github.com/yaoapp/gou/model" "github.com/yaoapp/gou/model"
"github.com/yaoapp/gou/process"
kunlog "github.com/yaoapp/kun/log" kunlog "github.com/yaoapp/kun/log"
robotevents "github.com/yaoapp/yao/agent/robot/events" robotevents "github.com/yaoapp/yao/agent/robot/events"
robottypes "github.com/yaoapp/yao/agent/robot/types" robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/event" "github.com/yaoapp/yao/event"
"github.com/yaoapp/yao/workspace"
) )
// RunDelivery executes P4: Delivery phase // RunDelivery executes P4: Delivery phase
@ -38,36 +35,14 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi
} }
formatter := NewInputFormatter() formatter := NewInputFormatter()
userContent := formatter.FormatDeliveryInput(exec, robot)
// Try workspace-based delivery input (manifest summaries instead of raw output inline)
var userContent string
if robot.Workspace != "" {
wsm := workspace.M()
if wsm != nil {
wsFS, err := wsm.FS(ctx, robot.Workspace)
if err == nil {
execDir := path.Join("robots", robot.MemberID, exec.ID)
data, err := wsFS.ReadFile(path.Join(execDir, "manifest.json"))
if err == nil {
var manifest Manifest
if json.Unmarshal(data, &manifest) == nil {
userContent = formatter.FormatDeliveryInputWithManifest(exec, robot, &manifest, robot.Workspace, execDir, locale)
}
}
}
}
}
// Fallback to legacy full-output inline
if userContent == "" {
userContent = formatter.FormatDeliveryInput(exec, robot)
}
if userContent == "" { if userContent == "" {
return fmt.Errorf("no content available for delivery generation") return fmt.Errorf("no content available for delivery generation")
} }
caller := NewAgentCaller() caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent) result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil { if err != nil {
@ -123,16 +98,7 @@ func (e *Executor) pushDeliveryEvent(ctx *robottypes.Context, exec *robottypes.E
} }
} }
eventCtx := ctx.Context _, err := event.Push(ctx.Context, robotevents.Delivery, robotevents.DeliveryPayload{
if ctx.Auth != nil {
eventCtx = event.WithAuth(eventCtx, &process.AuthorizedInfo{
UserID: ctx.Auth.UserID,
TeamID: ctx.Auth.TeamID,
Subject: ctx.Auth.Subject,
})
}
_, err := event.Push(eventCtx, robotevents.Delivery, robotevents.DeliveryPayload{
ExecutionID: exec.ID, ExecutionID: exec.ID,
MemberID: exec.MemberID, MemberID: exec.MemberID,
TeamID: exec.TeamID, TeamID: exec.TeamID,
@ -437,103 +403,3 @@ func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *
return sb.String() return sb.String()
} }
// FormatDeliveryInputWithManifest formats delivery input using workspace manifest summaries
// instead of inlining full task outputs. This drastically reduces token usage.
func (f *InputFormatter) FormatDeliveryInputWithManifest(
exec *robottypes.Execution,
robot *robottypes.Robot,
manifest *Manifest,
wsID string,
execDir string,
locale string,
) string {
if exec == nil || manifest == nil {
return ""
}
var sb strings.Builder
// Robot Identity (same as legacy)
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
sb.WriteString("## Robot Identity\n\n")
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
if len(robot.Config.Identity.Duties) > 0 {
sb.WriteString("- **Duties**: ")
sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", "))
sb.WriteString("\n")
}
sb.WriteString("\n")
}
sb.WriteString("## Execution Context\n\n")
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
if locale != "" {
sb.WriteString(fmt.Sprintf("- **Language**: %s\n", locale))
}
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
if exec.EndTime != nil {
duration := exec.EndTime.Sub(exec.StartTime)
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
}
sb.WriteString("\n")
sb.WriteString("## Goals\n\n")
sb.WriteString(manifest.Goals)
sb.WriteString("\n\n")
// Results from manifest summaries
sb.WriteString("## Results (P3)\n\n")
successCount := 0
failCount := 0
for _, t := range manifest.Tasks {
if t.Status == "completed" {
successCount++
sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", t.ID))
} else if t.Status == "failed" {
failCount++
sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", t.ID))
} else {
continue
}
sb.WriteString(fmt.Sprintf("- **Description**: %s\n", t.Description))
sb.WriteString(fmt.Sprintf("- **Executor**: %s (%s)\n", t.Executor, t.ExecutorType))
if t.Summary != "" {
sb.WriteString(fmt.Sprintf("- **Summary**: %s\n", t.Summary))
}
if len(t.KeyOutputs) > 0 {
sb.WriteString(fmt.Sprintf("- **Key Outputs**: %s\n", strings.Join(t.KeyOutputs, ", ")))
}
// File references — use existing URI if present, otherwise build from execDir
if len(t.Files) > 0 {
sb.WriteString("- **Artifacts**:\n")
for _, file := range t.Files {
uri := file.URI
if uri == "" {
uri = fmt.Sprintf("workspace://%s/%s/%s/%s", wsID, execDir, t.ID, file.Name)
}
sb.WriteString(fmt.Sprintf(" - [%s](%s)", file.Name, uri))
if file.Desc != "" {
sb.WriteString(fmt.Sprintf(" — %s", file.Desc))
}
sb.WriteString("\n")
}
}
// Full output file reference
outputURI := fmt.Sprintf("workspace://%s/%s/%s.output.md", wsID, execDir, t.ID)
sb.WriteString(fmt.Sprintf("- **Full Output**: [%s.output.md](%s)\n", t.ID, outputURI))
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n",
successCount+failCount, successCount, failCount))
return sb.String()
}

View file

@ -84,6 +84,7 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
// Call agent // Call agent
caller := NewAgentCaller() caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent) result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil { if err != nil {

View file

@ -32,6 +32,7 @@ func (e *Executor) CallHostAgent(ctx *robottypes.Context, robot *robottypes.Robo
kunlog.Info("calling Host Agent %s for scenario=%s chatID=%s", agentID, input.Scenario, chatID) kunlog.Info("calling Host Agent %s for scenario=%s chatID=%s", agentID, input.Scenario, chatID)
caller := NewConversationCaller(chatID) caller := NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON)) result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON))
if err != nil { if err != nil {

View file

@ -183,7 +183,6 @@ func (f *InputFormatter) FormatAvailableResourcesWithLocale(robot *robottypes.Ro
capabilities := i18n.Translate(agentID, locale, ast.Capabilities).(string) capabilities := i18n.Translate(agentID, locale, ast.Capabilities).(string)
sb.WriteString(fmt.Sprintf(" - **Capabilities**: %s\n", capabilities)) sb.WriteString(fmt.Sprintf(" - **Capabilities**: %s\n", capabilities))
} }
} }
sb.WriteString("\n") sb.WriteString("\n")
} }

View file

@ -53,6 +53,7 @@ func (e *Executor) RunInspiration(ctx *robottypes.Context, exec *robottypes.Exec
// Call agent // Call agent
caller := NewAgentCaller() caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent) result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil { if err != nil {

View file

@ -103,9 +103,9 @@ func (l *execLogger) devTaskOverview(tasks []robottypes.Task) {
// P3: Task Input // P3: Task Input
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func (l *execLogger) logTaskInput(task *robottypes.Task, prompt string, actualConnector string) { func (l *execLogger) logTaskInput(task *robottypes.Task, prompt string) {
if config.IsDevelopment() { if config.IsDevelopment() {
l.devTaskInput(task, prompt, actualConnector) l.devTaskInput(task, prompt)
} }
kunlog.With(kunlog.F{ kunlog.With(kunlog.F{
"robot_id": l.robotID(), "robot_id": l.robotID(),
@ -115,23 +115,17 @@ func (l *execLogger) logTaskInput(task *robottypes.Task, prompt string, actualCo
"executor_id": task.ExecutorID, "executor_id": task.ExecutorID,
"prompt_len": len(prompt), "prompt_len": len(prompt),
"language_model": l.connector(), "language_model": l.connector(),
"connector": actualConnector,
}).Info("Task input: %s [%s]", task.ID, task.ExecutorID) }).Info("Task input: %s [%s]", task.ID, task.ExecutorID)
} }
func (l *execLogger) devTaskInput(task *robottypes.Task, prompt string, actualConnector string) { func (l *execLogger) devTaskInput(task *robottypes.Task, prompt string) {
w := logger.Gray w := logger.Gray
v := logger.White v := logger.White
r := logger.Reset r := logger.Reset
connLabel := actualConnector
if connLabel == "" {
connLabel = "agent-default"
}
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s ▶ Task %s%s%s [%s:%s] Connector: %s%s%s Prompt: %d chars%s\n", sb.WriteString(fmt.Sprintf("%s ▶ Task %s%s%s [%s:%s] Prompt: %d chars%s\n",
w, v, task.ID, w, task.ExecutorType, task.ExecutorID, v, connLabel, w, len(prompt), r)) w, v, task.ID, w, task.ExecutorType, task.ExecutorID, len(prompt), r))
logger.Raw(sb.String()) logger.Raw(sb.String())
} }
@ -196,19 +190,18 @@ func (l *execLogger) devTaskOutput(task *robottypes.Task, result *robottypes.Tas
// Agent Call // Agent Call
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func (l *execLogger) logAgentCall(agentID string, connector string, result *CallResult) { func (l *execLogger) logAgentCall(agentID string, result *CallResult) {
if result == nil { if result == nil {
return return
} }
if config.IsDevelopment() { if config.IsDevelopment() {
l.devAgentCall(agentID, connector, result) l.devAgentCall(agentID, result)
} }
fields := kunlog.F{ fields := kunlog.F{
"robot_id": l.robotID(), "robot_id": l.robotID(),
"execution_id": l.execID, "execution_id": l.execID,
"agent_id": agentID, "agent_id": agentID,
"connector": connector,
"content_len": len(result.Content), "content_len": len(result.Content),
"language_model": l.connector(), "language_model": l.connector(),
} }
@ -216,28 +209,23 @@ func (l *execLogger) logAgentCall(agentID string, connector string, result *Call
fields["next_type"] = fmt.Sprintf("%T", result.Next) fields["next_type"] = fmt.Sprintf("%T", result.Next)
fields["next_len"] = outputLen(result.Next) fields["next_len"] = outputLen(result.Next)
} }
kunlog.With(fields).Info("Agent call: %s (connector=%s, content=%d, next=%T)", agentID, connector, len(result.Content), result.Next) kunlog.With(fields).Info("Agent call: %s (content=%d, next=%T)", agentID, len(result.Content), result.Next)
} }
func (l *execLogger) devAgentCall(agentID string, connector string, result *CallResult) { func (l *execLogger) devAgentCall(agentID string, result *CallResult) {
w := logger.Gray w := logger.Gray
v := logger.White v := logger.White
c := logger.Cyan c := logger.Cyan
r := logger.Reset r := logger.Reset
displayConn := connector
if displayConn == "" {
displayConn = "agent-default"
}
nextInfo := "—" nextInfo := "—"
if result.Next != nil { if result.Next != nil {
nextInfo = fmt.Sprintf("%T (len=%d)", result.Next, outputLen(result.Next)) nextInfo = fmt.Sprintf("%T (len=%d)", result.Next, outputLen(result.Next))
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s → Agent(%s%s%s) Connector: %s%s%s Content: %s%d%s chars Next: %s%s%s\n", sb.WriteString(fmt.Sprintf("%s → Agent(%s%s%s) Content: %s%d%s chars Next: %s%s%s\n",
c, v, agentID, c, v, displayConn, c, v, len(result.Content), w, v, nextInfo, r)) c, v, agentID, c, v, len(result.Content), w, v, nextInfo, r))
logger.Raw(sb.String()) logger.Raw(sb.String())
} }

View file

@ -1,60 +0,0 @@
workspace: |
## Workspace
Execution directory: {{.ExecDir}}
### Available Files
{{range .Files}}
- {{.Path}} — {{.Desc}}
{{- end}}
### Your Task Directory
Write any output files to: {{.TaskDir}}
context: |
## Execution Context
Goals: {{.Goals}}
{{- if .Locale}}
Language: {{.Locale}}
{{- end}}
Mode: automated-pipeline
{{- if .FailureWarning}}
⚠ {{.FailureWarning}}
{{- end}}
{{if .CompletedTasks}}
### Completed Tasks
{{range .CompletedTasks}}
{{.Seq}}. [{{.ID}}] {{.Description}} ({{.ExecutorType}}: {{.Executor}}) ✓
Summary: {{.Summary}}
{{- if .KeyOutputs}}
Key outputs: {{.KeyOutputs}}
{{- end}}
{{- if .HasFiles}}
Files: {{.Files}}
{{- end}}
{{end}}
{{- end}}
{{- if .FailedTasks}}
### Failed Tasks
{{range .FailedTasks}}
{{.Seq}}. [{{.ID}}] {{.Description}} ✗
Error: {{.Error}}
{{end}}
{{- end}}
### Current Task
{{.CurrentOrder}}. [{{.CurrentID}}] {{.CurrentDesc}} ({{.CurrentType}}: {{.CurrentExec}})
instructions: |
## Task Instructions
{{.TaskInstructions}}
{{- if .ExpectedOutput}}
### Expected Output
{{.ExpectedOutput}}
{{- end}}

View file

@ -2,10 +2,8 @@ package standard
import ( import (
"fmt" "fmt"
"path"
"time" "time"
kunlog "github.com/yaoapp/kun/log"
robotevents "github.com/yaoapp/yao/agent/robot/events" robotevents "github.com/yaoapp/yao/agent/robot/events"
robottypes "github.com/yaoapp/yao/agent/robot/types" robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/event" "github.com/yaoapp/yao/event"
@ -55,6 +53,9 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
config = DefaultRunConfig() config = DefaultRunConfig()
} }
// Determine locale for UI messages
locale := getEffectiveLocale(robot, exec.Input)
// Determine start index and restore results from resume context // Determine start index and restore results from resume context
startIndex := 0 startIndex := 0
if exec.ResumeContext != nil { if exec.ResumeContext != nil {
@ -66,26 +67,6 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
// Create task runner with execution-level chatID (§8.4) // Create task runner with execution-level chatID (§8.4)
runner := NewRunner(ctx, robot, config, exec.ChatID, exec.ID) runner := NewRunner(ctx, robot, config, exec.ChatID, exec.ID)
if ctx.Locale != "" {
runner.locale = ctx.Locale
} else {
runner.locale = getEffectiveLocale(robot, exec.Input)
}
// Initialize workspace for file-based context
wsFS, err := ensureRobotWorkspace(ctx, robot)
if err != nil {
kunlog.Warn("[robot-run] workspace unavailable, falling back to in-memory context: %v", err)
} else {
execDir := path.Join("robots", robot.MemberID, exec.ID)
if mkErr := wsFS.MkdirAll(execDir, 0755); mkErr != nil {
kunlog.Warn("[robot-run] mkdir %s: %v", execDir, mkErr)
} else {
runner.wsFS = wsFS
runner.execDir = execDir
runner.initManifest(exec)
}
}
// Execute tasks sequentially from startIndex // Execute tasks sequentially from startIndex
for i := startIndex; i < len(exec.Tasks); i++ { for i := startIndex; i < len(exec.Tasks); i++ {
@ -99,7 +80,7 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
} }
// Update UI field with current task description (i18n) // Update UI field with current task description (i18n)
taskName := formatTaskProgressName(task, i, len(exec.Tasks), runner.locale) taskName := formatTaskProgressName(task, i, len(exec.Tasks), locale)
e.updateUIFields(ctx, exec, "", taskName) e.updateUIFields(ctx, exec, "", taskName)
// Mark task as running // Mark task as running
@ -146,10 +127,7 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
}) })
} }
// Write task files to workspace (non-blocking, errors logged) // Store result
runner.writeTaskOutput(task, result, runner.lastPromptSnapshot)
// Store result (in-memory, for persistence + resume)
exec.Results = append(exec.Results, *result) exec.Results = append(exec.Results, *result)
// Persist completed/failed state to database // Persist completed/failed state to database

View file

@ -10,9 +10,7 @@ import (
"github.com/yaoapp/gou/process" "github.com/yaoapp/gou/process"
kunlog "github.com/yaoapp/kun/log" kunlog "github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context" agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm"
robottypes "github.com/yaoapp/yao/agent/robot/types" robottypes "github.com/yaoapp/yao/agent/robot/types"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
) )
// Runner handles execution of individual tasks // Runner handles execution of individual tasks
@ -22,12 +20,6 @@ type Runner struct {
config *RunConfig config *RunConfig
chatID string // execution-level chatID for conversation persistence (§8.4) chatID string // execution-level chatID for conversation persistence (§8.4)
log *execLogger log *execLogger
wsFS taiworkspace.FS // workspace file system (nil if unavailable)
execDir string // workspace-relative execution directory (e.g. "robots/<id>/<exec_id>")
lastPromptSnapshot string // captured prompt text for workspace .input.md
currentTaskIndex int // current task index for workspace prompt building
currentExec *robottypes.Execution
locale string // effective locale for this execution (e.g. "zh", "en")
} }
// NewRunner creates a new task runner // NewRunner creates a new task runner
@ -55,15 +47,12 @@ type RunnerContext struct {
// BuildTaskContext builds context for a task including previous results // BuildTaskContext builds context for a task including previous results
func (r *Runner) BuildTaskContext(exec *robottypes.Execution, taskIndex int) *RunnerContext { func (r *Runner) BuildTaskContext(exec *robottypes.Execution, taskIndex int) *RunnerContext {
r.currentTaskIndex = taskIndex
r.currentExec = exec
ctx := &RunnerContext{ ctx := &RunnerContext{
Goals: exec.Goals, Goals: exec.Goals,
SystemPrompt: r.robot.SystemPrompt, SystemPrompt: r.robot.SystemPrompt,
} }
// Include results from previous tasks (with bounds check) — kept for fallback // Include results from previous tasks (with bounds check)
if taskIndex > 0 && len(exec.Results) > 0 { if taskIndex > 0 && len(exec.Results) > 0 {
endIndex := taskIndex endIndex := taskIndex
if endIndex > len(exec.Results) { if endIndex > len(exec.Results) {
@ -143,56 +132,26 @@ func (r *Runner) executeNonAssistantTask(task *robottypes.Task, taskCtx *RunnerC
// Returns the extracted output, the raw CallResult (for need_input detection), and any error. // Returns the extracted output, the raw CallResult (for need_input detection), and any error.
func (r *Runner) executeAssistantTask(task *robottypes.Task, taskCtx *RunnerContext) (interface{}, *CallResult, error) { func (r *Runner) executeAssistantTask(task *robottypes.Task, taskCtx *RunnerContext) (interface{}, *CallResult, error) {
caller := NewAgentCaller() caller := NewAgentCaller()
caller.Mode = "task"
caller.log = r.log caller.log = r.log
if r.robot.LanguageModel != "" {
if _, _, err := llm.ResolveConnector(r.robot.LanguageModel, nil); err == nil {
caller.Connector = r.robot.LanguageModel caller.Connector = r.robot.LanguageModel
} else {
kunlog.Warn("[robot-runner] connector %s invalid, using agent default: %v",
r.robot.LanguageModel, err)
}
}
caller.Workspace = r.robot.Workspace caller.Workspace = r.robot.Workspace
caller.ChatID = r.chatID caller.ChatID = r.chatID
var input string
workspacePromptUsed := false
// Use workspace-based prompt when available
if r.wsFS != nil {
manifest, err := r.readManifest()
if err == nil {
taskInstructions := r.FormatMessagesAsText(task.Messages)
input = r.buildWorkspacePrompt(manifest, r.currentTaskIndex, task, taskInstructions)
workspacePromptUsed = (input != "")
}
}
// Fallback to legacy in-memory context
if input == "" {
messages := r.BuildAssistantMessages(task, taskCtx) messages := r.BuildAssistantMessages(task, taskCtx)
input = r.FormatMessagesAsText(messages) input := r.FormatMessagesAsText(messages)
}
if strings.TrimSpace(input) == "" { if strings.TrimSpace(input) == "" {
return nil, nil, fmt.Errorf("no valid input messages for task %s", task.ID) return nil, nil, fmt.Errorf("no valid input messages for task %s", task.ID)
} }
// Only inject robot system prompt for legacy (non-workspace) path. if taskCtx.SystemPrompt != "" {
// In workspace mode the agent's own prompts.yml defines its role;
// injecting the robot's dispatcher prompt would confuse the executor.
if taskCtx.SystemPrompt != "" && !workspacePromptUsed {
input = "## Context\n\n" + taskCtx.SystemPrompt + "\n\n## Task\n\n" + input input = "## Context\n\n" + taskCtx.SystemPrompt + "\n\n## Task\n\n" + input
} }
// Capture prompt snapshot for workspace .input.md
r.lastPromptSnapshot = input
kunlog.Trace("[robot-runner] executeAssistantTask: task=%s assistant=%s promptLen=%d prevResults=%d", kunlog.Trace("[robot-runner] executeAssistantTask: task=%s assistant=%s promptLen=%d prevResults=%d",
task.ID, task.ExecutorID, len(input), len(taskCtx.PreviousResults)) task.ID, task.ExecutorID, len(input), len(taskCtx.PreviousResults))
r.log.logTaskInput(task, input, caller.Connector) r.log.logTaskInput(task, input)
result, err := caller.CallWithMessages(r.ctx, task.ExecutorID, input) result, err := caller.CallWithMessages(r.ctx, task.ExecutorID, input)
if err != nil { if err != nil {

View file

@ -2,9 +2,7 @@ package standard
import ( import (
"fmt" "fmt"
"strings"
kunlog "github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context" agentcontext "github.com/yaoapp/yao/agent/context"
robottypes "github.com/yaoapp/yao/agent/robot/types" robottypes "github.com/yaoapp/yao/agent/robot/types"
) )
@ -56,6 +54,7 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution,
// Call agent // Call agent
caller := NewAgentCaller() caller := NewAgentCaller()
caller.log = newExecLogger(robot, exec.ID) caller.log = newExecLogger(robot, exec.ID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent) result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil { if err != nil {
@ -81,9 +80,6 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution,
return fmt.Errorf("tasks agent (%s) returned invalid task structure: %w", agentID, err) return fmt.Errorf("tasks agent (%s) returned invalid task structure: %w", agentID, err)
} }
// Normalize executor IDs and types against available resources
NormalizeTaskExecutors(tasks, robot)
// Validate tasks // Validate tasks
if err := ValidateTasks(tasks); err != nil { if err := ValidateTasks(tasks); err != nil {
return fmt.Errorf("tasks validation failed: %w", err) return fmt.Errorf("tasks validation failed: %w", err)
@ -399,81 +395,3 @@ func ValidateMCPTask(task *robottypes.Task) error {
} }
return nil return nil
} }
// NormalizeTaskExecutors fixes LLM-generated executor_id and executor_type
// against the robot's actual resource lists. It handles two common LLM errors:
// 1. Partial executor_id (e.g. "report-writer" instead of "yao.report-writer")
// 2. Wrong executor_type (e.g. classifying an assistant as "mcp")
func NormalizeTaskExecutors(tasks []robottypes.Task, robot *robottypes.Robot) {
if robot == nil || robot.Config == nil || robot.Config.Resources == nil {
return
}
agentSet := make(map[string]bool, len(robot.Config.Resources.Agents))
for _, id := range robot.Config.Resources.Agents {
agentSet[id] = true
}
mcpSet := make(map[string]bool, len(robot.Config.Resources.MCP))
for _, m := range robot.Config.Resources.MCP {
mcpSet[m.ID] = true
}
for i := range tasks {
task := &tasks[i]
origID := task.ExecutorID
origType := task.ExecutorType
// Skip process tasks — they are not in resource lists
if task.ExecutorType == robottypes.ExecutorProcess {
continue
}
// Step 1: Try exact match first
if agentSet[task.ExecutorID] {
task.ExecutorType = robottypes.ExecutorAssistant
if origType != task.ExecutorType {
kunlog.Trace("[normalize] task %s: executor_type %s -> %s (crosscheck)", task.ID, origType, task.ExecutorType)
}
continue
}
if mcpSet[task.ExecutorID] {
task.ExecutorType = robottypes.ExecutorMCP
if origType != task.ExecutorType {
kunlog.Trace("[normalize] task %s: executor_type %s -> %s (crosscheck)", task.ID, origType, task.ExecutorType)
}
continue
}
// Step 2: Suffix match — LLM may omit namespace prefix
if match := suffixMatch(task.ExecutorID, robot.Config.Resources.Agents); match != "" {
task.ExecutorID = match
task.ExecutorType = robottypes.ExecutorAssistant
kunlog.Trace("[normalize] task %s: executor_id %s -> %s (suffix match)", task.ID, origID, match)
continue
}
mcpIDs := make([]string, 0, len(robot.Config.Resources.MCP))
for _, m := range robot.Config.Resources.MCP {
mcpIDs = append(mcpIDs, m.ID)
}
if match := suffixMatch(task.ExecutorID, mcpIDs); match != "" {
task.ExecutorID = match
task.ExecutorType = robottypes.ExecutorMCP
kunlog.Trace("[normalize] task %s: executor_id %s -> %s (suffix match)", task.ID, origID, match)
continue
}
}
}
// suffixMatch finds the first entry in candidates that ends with "."+partial
// (or equals partial exactly, which is already handled by the caller).
func suffixMatch(partial string, candidates []string) string {
suffix := "." + partial
for _, c := range candidates {
if strings.HasSuffix(c, suffix) {
return c
}
}
return ""
}

View file

@ -433,6 +433,7 @@ func (v *Validator) validateSemantic(task *robottypes.Task, output interface{})
// Call validation agent // Call validation agent
caller := NewAgentCaller() caller := NewAgentCaller()
caller.Connector = v.robot.LanguageModel
caller.Workspace = v.robot.Workspace caller.Workspace = v.robot.Workspace
result, err := caller.CallWithMessages(v.ctx, validationAgentID, validationPrompt) result, err := caller.CallWithMessages(v.ctx, validationAgentID, validationPrompt)
if err != nil { if err != nil {
@ -640,6 +641,7 @@ func (av *robotAgentValidator) Validate(agentID string, output, input, criteria
// Call agent // Call agent
caller := NewAgentCaller() caller := NewAgentCaller()
caller.Connector = av.v.robot.LanguageModel
caller.Workspace = av.v.robot.Workspace caller.Workspace = av.v.robot.Workspace
callResult, err := caller.CallWithMessages(av.v.ctx, agentID, string(inputJSON)) callResult, err := caller.CallWithMessages(av.v.ctx, agentID, string(inputJSON))
if err != nil { if err != nil {

View file

@ -1,850 +0,0 @@
package standard
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"path"
"path/filepath"
"regexp"
"strings"
"text/template"
kunlog "github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm"
robottypes "github.com/yaoapp/yao/agent/robot/types"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
"github.com/yaoapp/yao/workspace"
"gopkg.in/yaml.v3"
)
//go:embed prompts/workspace.yml
var workspacePromptYAML []byte
type workspacePrompts struct {
Workspace string `yaml:"workspace"`
Context string `yaml:"context"`
Instructions string `yaml:"instructions"`
}
var wsPromptTpls struct {
Workspace *template.Template
Context *template.Template
Instructions *template.Template
}
func init() {
var p workspacePrompts
if err := yaml.Unmarshal(workspacePromptYAML, &p); err != nil {
return
}
wsPromptTpls.Workspace, _ = template.New("ws").Parse(p.Workspace)
wsPromptTpls.Context, _ = template.New("ctx").Parse(p.Context)
wsPromptTpls.Instructions, _ = template.New("inst").Parse(p.Instructions)
}
// Manifest is the shared context hub for an execution, written as manifest.json.
type Manifest struct {
ExecID string `json:"exec_id"`
RobotID string `json:"robot_id"`
Goals string `json:"goals"`
Tasks []ManifestTask `json:"tasks"`
}
// ManifestTask represents a single task entry in the manifest.
type ManifestTask struct {
ID string `json:"id"`
Order int `json:"order"`
Description string `json:"description"`
Executor string `json:"executor"`
ExecutorType string `json:"executor_type"`
Status string `json:"status"`
Summary string `json:"summary,omitempty"`
KeyOutputs []string `json:"key_outputs,omitempty"`
Files []ManifestFile `json:"files,omitempty"`
Error string `json:"error,omitempty"`
}
// ManifestFile represents a produced artifact.
type ManifestFile struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Desc string `json:"desc,omitempty"`
URI string `json:"uri,omitempty"`
}
// ensureRobotWorkspace guarantees a workspace FS exists for the robot.
// If robot.Workspace is empty, it derives a deterministic ID and auto-creates.
// It also writes back robot.Workspace so subsequent callers get the correct ID.
func ensureRobotWorkspace(ctx *robottypes.Context, robot *robottypes.Robot) (taiworkspace.FS, error) {
wsm := workspace.M()
if wsm == nil {
return nil, fmt.Errorf("workspace manager not available")
}
wsID := robot.Workspace
if wsID == "" {
nodes := wsm.Nodes()
nodeID := ""
for _, n := range nodes {
if n.Online {
nodeID = n.Name
break
}
}
if nodeID == "" {
return nil, fmt.Errorf("no available node for workspace")
}
wsID = workspace.DefaultWorkspaceID(robot.TeamID, nodeID)
if _, err := wsm.Get(ctx, wsID); err != nil {
if _, err := wsm.Create(ctx, workspace.CreateOptions{
ID: wsID,
Name: "Robot Workspace",
Owner: robot.TeamID,
Node: nodeID,
}); err != nil {
return nil, fmt.Errorf("workspace create failed: %w", err)
}
}
robot.Workspace = wsID
}
return wsm.FS(ctx, wsID)
}
// initManifest creates the initial manifest.json with goals and pending task list.
// Uses slice index (not P2's order field) so manifests always have sequential numbering.
func (r *Runner) initManifest(exec *robottypes.Execution) {
if r.wsFS == nil {
return
}
goalsContent := ""
if exec.Goals != nil {
goalsContent = exec.Goals.Content
}
m := &Manifest{
ExecID: exec.ID,
RobotID: r.robot.MemberID,
Goals: goalsContent,
Tasks: make([]ManifestTask, 0, len(exec.Tasks)),
}
for i, t := range exec.Tasks {
m.Tasks = append(m.Tasks, ManifestTask{
ID: t.ID,
Order: i,
Description: t.Description,
Executor: t.ExecutorID,
ExecutorType: string(t.ExecutorType),
Status: string(t.Status),
})
}
r.writeManifest(m)
}
// writeManifest serializes and writes manifest.json.
func (r *Runner) writeManifest(m *Manifest) {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
kunlog.Warn("[robot-workspace] marshal manifest: %v", err)
return
}
p := path.Join(r.execDir, "manifest.json")
if err := r.wsFS.WriteFile(p, data, 0644); err != nil {
kunlog.Warn("[robot-workspace] write manifest: %v", err)
}
}
// readManifest reads and parses manifest.json from workspace.
func (r *Runner) readManifest() (*Manifest, error) {
if r.wsFS == nil {
return nil, fmt.Errorf("wsFS not available")
}
data, err := r.wsFS.ReadFile(path.Join(r.execDir, "manifest.json"))
if err != nil {
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
// writeTaskOutput writes the three task files and updates manifest after task completion.
func (r *Runner) writeTaskOutput(task *robottypes.Task, result *robottypes.TaskResult, promptSnapshot string) {
if r.wsFS == nil {
return
}
taskID := task.ID
// Write task-NNN.input.md (prompt snapshot for debug)
if promptSnapshot != "" {
inputPath := path.Join(r.execDir, taskID+".input.md")
if err := r.wsFS.WriteFile(inputPath, []byte(promptSnapshot), 0644); err != nil {
kunlog.Warn("[robot-workspace] write %s.input.md: %v", taskID, err)
}
}
// Write task-NNN.output.md (full output)
outputText := formatOutputAsText(result.Output)
outputPath := path.Join(r.execDir, taskID+".output.md")
if err := r.wsFS.WriteFile(outputPath, []byte(outputText), 0644); err != nil {
kunlog.Warn("[robot-workspace] write %s.output.md: %v", taskID, err)
}
// Write task-NNN.json (metadata)
meta := map[string]interface{}{
"id": taskID,
"executor": task.ExecutorID,
"executor_type": string(task.ExecutorType),
"status": string(task.Status),
"duration_ms": result.Duration,
"success": result.Success,
}
if result.Error != "" {
meta["error"] = result.Error
}
metaJSON, _ := json.MarshalIndent(meta, "", " ")
metaPath := path.Join(r.execDir, taskID+".json")
if err := r.wsFS.WriteFile(metaPath, metaJSON, 0644); err != nil {
kunlog.Warn("[robot-workspace] write %s.json: %v", taskID, err)
}
r.updateManifestForTask(task, result)
}
// updateManifestForTask reads manifest, updates the matching task entry, and writes back.
func (r *Runner) updateManifestForTask(task *robottypes.Task, result *robottypes.TaskResult) {
m, err := r.readManifest()
if err != nil {
kunlog.Warn("[robot-workspace] read manifest for update: %v", err)
return
}
// Scan files first so LLM summary can reference them
files := r.scanTaskArtifacts(task.ID)
outputURIs := r.extractAndVerifyFiles(result.Output)
files = mergeManifestFiles(files, outputURIs)
for i := range m.Tasks {
if m.Tasks[i].ID == task.ID {
if result.Success {
m.Tasks[i].Status = "completed"
summary := r.llmSummarize(task, result.Output, files)
if summary == "" {
summary = generateSummary(result.Output)
}
m.Tasks[i].Summary = summary
m.Tasks[i].KeyOutputs = extractKeyOutputs(result.Output)
} else {
m.Tasks[i].Status = "failed"
m.Tasks[i].Error = result.Error
if result.Error != "" {
m.Tasks[i].Summary = "Failed: " + result.Error
}
}
m.Tasks[i].Files = files
break
}
}
r.writeManifest(m)
}
// scanTaskArtifacts scans the task-id/ directory for produced files.
func (r *Runner) scanTaskArtifacts(taskID string) []ManifestFile {
if r.wsFS == nil {
return nil
}
dirPath := path.Join(r.execDir, taskID)
entries, err := r.wsFS.ReadDir(dirPath)
if err != nil {
return nil
}
var files []ManifestFile
for _, e := range entries {
if e.IsDir() {
continue
}
files = append(files, ManifestFile{
Name: e.Name(),
Type: mimeFromExt(filepath.Ext(e.Name())),
})
}
return files
}
// llmSummarize uses a lightweight LLM to generate a concise task summary
// from the full task context (description, output, produced files).
// Returns empty string on any failure, allowing caller to fall back to static extraction.
func (r *Runner) llmSummarize(task *robottypes.Task, output interface{}, files []ManifestFile) string {
text := flattenOutput(output)
if text == "" {
return ""
}
conn, _, err := llm.ResolveConnector("use::light", r.ctx.Auth)
if err != nil {
kunlog.Warn("[robot-workspace] llmSummarize resolve connector: %v", err)
return ""
}
opts := llm.BuildCompletionOptions(conn, nil)
instance, err := llm.New(conn, opts)
if err != nil {
kunlog.Warn("[robot-workspace] llmSummarize create LLM: %v", err)
return ""
}
var sb strings.Builder
sb.WriteString("Task: " + task.Description + "\n\n")
if len(files) > 0 {
sb.WriteString("Produced files:\n")
for _, f := range files {
sb.WriteString("- " + f.Name + " (" + f.Type + ")\n")
}
sb.WriteString("\n")
}
sb.WriteString("Output:\n")
outputText := text
if len(outputText) > 4000 {
outputText = outputText[:4000]
}
sb.WriteString(outputText)
messages := []agentcontext.Message{
{Role: agentcontext.RoleSystem, Content: "Summarize the task execution result in 1-2 concise sentences. " +
"Focus on what was actually produced or accomplished, not the process. " +
"If files were produced, mention them. Reply in the same language as the task description."},
{Role: agentcontext.RoleUser, Content: sb.String()},
}
agentCtx := agentcontext.New(r.ctx.Context, r.ctx.Auth, "")
defer agentCtx.Release()
resp, err := instance.Post(agentCtx, messages, opts)
if err != nil {
kunlog.Warn("[robot-workspace] llmSummarize Post: %v", err)
return ""
}
return extractLLMContent(resp)
}
// extractLLMContent extracts the text content from a CompletionResponse.
func extractLLMContent(resp *agentcontext.CompletionResponse) string {
if resp == nil {
return ""
}
if s, ok := resp.Content.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
// workspaceURIRegex matches workspace://wsID/path patterns in markdown links and plain text.
// Excludes trailing backticks, quotes, and brackets that are markdown formatting artifacts.
var workspaceURIRegex = regexp.MustCompile("workspace://([^/\\s)]+)/([^\\s)\\]`\"']+)")
// extractAndVerifyFiles extracts workspace:// URIs from output and verifies each
// file exists via wsFS.Stat, eliminating false positives from regex artifacts.
func (r *Runner) extractAndVerifyFiles(output interface{}) []ManifestFile {
text := flattenOutput(output)
if text == "" || r.wsFS == nil {
return nil
}
wsID, err := r.wsFS.GetID()
if err != nil {
return nil
}
prefix := "workspace://" + wsID + "/"
matches := workspaceURIRegex.FindAllStringSubmatch(text, -1)
seen := make(map[string]bool)
var files []ManifestFile
for _, m := range matches {
uri := "workspace://" + m[1] + "/" + m[2]
if seen[uri] {
continue
}
seen[uri] = true
if !strings.HasPrefix(uri, prefix) {
continue
}
relPath := strings.TrimPrefix(uri, prefix)
info, err := r.wsFS.Stat(relPath)
if err != nil || info.IsDir() {
continue
}
name := filepath.Base(relPath)
files = append(files, ManifestFile{
Name: name,
Type: mimeFromExt(filepath.Ext(name)),
URI: uri,
})
}
return files
}
// mergeManifestFiles deduplicates files from scanTaskArtifacts and extractWorkspaceURIs.
// If a URI-bearing entry has the same Name as a scan entry, the URI is merged onto the
// existing entry instead of creating a duplicate.
func mergeManifestFiles(scanned []ManifestFile, fromURIs []ManifestFile) []ManifestFile {
if len(fromURIs) == 0 {
return scanned
}
nameIndex := make(map[string]int, len(scanned))
for i, f := range scanned {
nameIndex[f.Name] = i
}
for _, uf := range fromURIs {
if idx, exists := nameIndex[uf.Name]; exists {
if scanned[idx].URI == "" {
scanned[idx].URI = uf.URI
}
} else {
nameIndex[uf.Name] = len(scanned)
scanned = append(scanned, uf)
}
}
return scanned
}
// --- Summary & key_outputs extraction ---
// llmPrefixPatterns are common LLM filler prefixes that carry no information.
var llmPrefixPatterns = []string{
"It seems ", "It appears ", "Here is ", "Here's ",
"Based on ", "I encountered ", "I wasn't able ",
"I'm currently unable ", "Let me ", "I recommend ",
}
// generateSummary produces a concise summary of the task's actual output/result.
// It prioritizes conclusion/summary sections over the beginning of the output,
// because agent responses typically start with planning/thinking text.
func generateSummary(output interface{}) string {
text := flattenOutput(output)
if text == "" {
return ""
}
const maxLen = 200
// Try to find an explicit summary/conclusion section
for _, heading := range []string{"## Summary", "## Conclusion", "## Result", "## 总结", "## 结论", "## 结果"} {
if idx := strings.Index(text, heading); idx >= 0 {
section := strings.TrimSpace(text[idx+len(heading):])
section = strings.TrimPrefix(section, "\n")
if nextH := strings.Index(section, "\n## "); nextH > 0 {
section = section[:nextH]
}
section = strings.TrimSpace(section)
if section != "" {
if len(section) > maxLen {
return section[:maxLen] + "..."
}
return section
}
}
}
// No explicit section — use last substantive paragraph as it's
// more likely to contain the actual result than the beginning
paragraphs := strings.Split(text, "\n\n")
for i := len(paragraphs) - 1; i >= 0; i-- {
p := strings.TrimSpace(paragraphs[i])
if p == "" || len(p) < 10 {
continue
}
isFiller := false
for _, prefix := range llmPrefixPatterns {
if strings.HasPrefix(p, prefix) {
isFiller = true
break
}
}
if isFiller {
continue
}
if len(p) > maxLen {
return p[:maxLen] + "..."
}
return p
}
// Fallback: skip filler and take from the beginning
text = skipFillerPrefixes(text)
if len(text) > maxLen {
return text[:maxLen] + "..."
}
return text
}
// skipFillerPrefixes skips past common LLM opening phrases to find substantive content.
func skipFillerPrefixes(text string) string {
lines := strings.SplitN(text, "\n", 20)
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
isFiller := false
for _, prefix := range llmPrefixPatterns {
if strings.HasPrefix(trimmed, prefix) {
isFiller = true
break
}
}
if !isFiller {
return strings.Join(lines[i:], "\n")
}
}
return text
}
// boldPatternRegex matches **bold text** in markdown
var boldPatternRegex = regexp.MustCompile(`\*\*([^*]+)\*\*`)
// extractKeyOutputs tries to extract structured key_outputs from the output.
func extractKeyOutputs(output interface{}) []string {
if output == nil {
return nil
}
// If output is a map with key_outputs/outputs/results, extract directly
if m, ok := output.(map[string]interface{}); ok {
for _, key := range []string{"key_outputs", "outputs", "results"} {
if arr, ok := m[key].([]interface{}); ok {
result := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
result = append(result, s)
}
}
if len(result) > 0 {
return capSlice(result, 5)
}
}
}
}
text := flattenOutput(output)
// Try ## headings
if strings.Contains(text, "\n## ") {
var headings []string
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "## ") {
headings = append(headings, strings.TrimPrefix(line, "## "))
}
}
if len(headings) > 0 {
return capSlice(headings, 5)
}
}
// Try **bold** items from numbered lists or bullet lists
// e.g. "1. **Model-Driven Architecture:**" or "- **Low-Code Engine**"
var boldItems []string
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "**") {
continue
}
// Only extract from list-like lines
if !(strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") ||
(len(trimmed) > 2 && trimmed[0] >= '0' && trimmed[0] <= '9' && trimmed[1] == '.')) {
continue
}
matches := boldPatternRegex.FindStringSubmatch(trimmed)
if len(matches) >= 2 {
item := strings.TrimRight(matches[1], ":")
if len(item) > 0 && len(item) < 80 {
boldItems = append(boldItems, item)
}
}
}
if len(boldItems) > 0 {
return capSlice(boldItems, 5)
}
return nil
}
func capSlice(s []string, max int) []string {
if len(s) > max {
return s[:max]
}
return s
}
// flattenOutput converts any output value to a plain text string.
func flattenOutput(output interface{}) string {
if output == nil {
return ""
}
switch v := output.(type) {
case string:
return v
case map[string]interface{}:
if text, ok := v["text"].(string); ok {
return text
}
if content, ok := v["content"].(string); ok {
return content
}
b, _ := json.Marshal(v)
return string(b)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
// formatOutputAsText converts task output to markdown text for .output.md files.
func formatOutputAsText(output interface{}) string {
if output == nil {
return ""
}
switch v := output.(type) {
case string:
return v
default:
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
// --- Prompt template rendering ---
// wsTemplateData holds template variables for the workspace section.
type wsTemplateData struct {
ExecDir string
Files []wsFileEntry
TaskDir string
}
type wsFileEntry struct {
Path string
Desc string
}
// ctxTemplateData holds template variables for the execution context section.
type ctxTemplateData struct {
Goals string
Locale string
CompletedTasks []ctxTaskEntry
FailedTasks []ctxFailedEntry
CurrentOrder int
CurrentID string
CurrentDesc string
CurrentType string
CurrentExec string
FailureWarning string
}
type ctxTaskEntry struct {
Seq int
ID string
Description string
ExecutorType string
Executor string
Summary string
KeyOutputs string
Files string
HasFiles bool
}
type ctxFailedEntry struct {
Seq int
ID string
Description string
Error string
}
// instTemplateData holds template variables for the instructions section.
type instTemplateData struct {
TaskInstructions string
ExpectedOutput string
}
// buildWorkspacePrompt renders the full prompt for a task from manifest + template.
func (r *Runner) buildWorkspacePrompt(manifest *Manifest, taskIndex int, task *robottypes.Task, taskInstructions string) string {
if manifest == nil || taskIndex >= len(manifest.Tasks) {
return taskInstructions
}
var sb strings.Builder
// Section 1: Workspace
if wsPromptTpls.Workspace != nil {
wd := wsTemplateData{
ExecDir: r.execDir + "/",
TaskDir: r.execDir + "/" + manifest.Tasks[taskIndex].ID + "/",
}
wd.Files = append(wd.Files, wsFileEntry{
Path: "manifest.json",
Desc: "Execution context: goals, completed task summaries, progress",
})
for i := 0; i < taskIndex; i++ {
t := manifest.Tasks[i]
if t.Status == "completed" {
wd.Files = append(wd.Files, wsFileEntry{
Path: t.ID + ".output.md",
Desc: "Full output: " + t.Description,
})
for _, f := range t.Files {
desc := "Artifact: " + f.Name
if f.URI != "" {
desc = "Artifact: " + f.Name + " (" + f.URI + ")"
}
filePath := t.ID + "/" + f.Name
if f.URI != "" {
filePath = f.URI
}
wd.Files = append(wd.Files, wsFileEntry{
Path: filePath,
Desc: desc,
})
}
}
}
var buf bytes.Buffer
if err := wsPromptTpls.Workspace.Execute(&buf, wd); err == nil {
sb.WriteString(buf.String())
sb.WriteString("\n\n")
}
}
// Section 2: Execution Context
if wsPromptTpls.Context != nil {
ct := manifest.Tasks[taskIndex]
cd := ctxTemplateData{
Goals: manifest.Goals,
Locale: r.locale,
CurrentID: ct.ID,
CurrentOrder: taskIndex + 1,
CurrentDesc: ct.Description,
CurrentType: ct.ExecutorType,
CurrentExec: ct.Executor,
}
seq := 0
failedCount := 0
for i := 0; i < taskIndex; i++ {
t := manifest.Tasks[i]
seq++
if t.Status == "completed" {
entry := ctxTaskEntry{
Seq: seq,
ID: t.ID,
Description: t.Description,
ExecutorType: t.ExecutorType,
Executor: t.Executor,
Summary: t.Summary,
KeyOutputs: strings.Join(t.KeyOutputs, ", "),
}
if len(t.Files) > 0 {
entry.HasFiles = true
names := make([]string, 0, len(t.Files))
for _, f := range t.Files {
names = append(names, f.Name)
}
entry.Files = strings.Join(names, ", ")
}
cd.CompletedTasks = append(cd.CompletedTasks, entry)
} else if t.Status == "failed" {
failedCount++
errMsg := t.Error
if errMsg == "" {
errMsg = t.Summary
}
cd.FailedTasks = append(cd.FailedTasks, ctxFailedEntry{
Seq: seq,
ID: t.ID,
Description: t.Description,
Error: errMsg,
})
}
}
// P8: failure cascade warning
if failedCount > 0 && len(cd.CompletedTasks) == 0 {
cd.FailureWarning = "WARNING: All previous tasks failed. You may lack necessary input data. Do your best with available information or report the limitation."
} else if failedCount > 0 {
cd.FailureWarning = fmt.Sprintf("Note: %d previous task(s) failed. Some expected input may be missing.", failedCount)
}
var buf bytes.Buffer
if err := wsPromptTpls.Context.Execute(&buf, cd); err == nil {
sb.WriteString(buf.String())
sb.WriteString("\n\n")
}
}
// Section 3: Task Instructions (enriched with expected_output from P2)
if wsPromptTpls.Instructions != nil {
instData := instTemplateData{TaskInstructions: taskInstructions}
if task != nil && task.ExpectedOutput != "" {
instData.ExpectedOutput = task.ExpectedOutput
}
var buf bytes.Buffer
if err := wsPromptTpls.Instructions.Execute(&buf, instData); err == nil {
sb.WriteString(buf.String())
}
} else {
sb.WriteString("## Task Instructions\n\n")
sb.WriteString(taskInstructions)
}
return sb.String()
}
func mimeFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".md":
return "text/markdown"
case ".html", ".htm":
return "text/html"
case ".json":
return "application/json"
case ".pdf":
return "application/pdf"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".csv":
return "text/csv"
case ".txt":
return "text/plain"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}

View file

@ -1,371 +0,0 @@
package standard
import (
"context"
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/agent"
agentcontext "github.com/yaoapp/yao/agent/context"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/llmprovider"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/tai/volume"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
// ============================================================================
// extractLLMContent — pure unit tests (no external dependencies)
// ============================================================================
func TestExtractLLMContent(t *testing.T) {
t.Run("string content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: " hello world "}
assert.Equal(t, "hello world", extractLLMContent(resp))
})
t.Run("nil response", func(t *testing.T) {
assert.Equal(t, "", extractLLMContent(nil))
})
t.Run("non-string content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: []interface{}{"a", "b"}}
assert.Equal(t, "", extractLLMContent(resp))
})
t.Run("empty string content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: " "}
assert.Equal(t, "", extractLLMContent(resp))
})
t.Run("multiline content trimmed", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: "\n summary line\n"}
assert.Equal(t, "summary line", extractLLMContent(resp))
})
t.Run("int content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: 42}
assert.Equal(t, "", extractLLMContent(resp))
})
}
// ============================================================================
// extractAndVerifyFiles — unit tests with local volume-backed FS
// ============================================================================
func newTestWorkspaceFS(t *testing.T) taiworkspace.FS {
t.Helper()
dir := t.TempDir()
vol := volume.NewLocal(dir)
t.Cleanup(func() { vol.Close() })
wfs := taiworkspace.New(vol, "ws-test")
t.Cleanup(func() { wfs.Close() })
return wfs
}
func TestExtractAndVerifyFiles(t *testing.T) {
t.Run("valid URI with existing file", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-001/notes.md", []byte("hello"), 0644))
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
output := "I wrote the file to workspace://ws-test/robots/r1/exec1/task-001/notes.md for you."
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 1)
assert.Equal(t, "notes.md", files[0].Name)
assert.Equal(t, "text/markdown", files[0].Type)
assert.Equal(t, "workspace://ws-test/robots/r1/exec1/task-001/notes.md", files[0].URI)
})
t.Run("URI with non-existent file filtered out", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
output := "See workspace://ws-test/robots/r1/exec1/task-001/missing.pdf"
files := r.extractAndVerifyFiles(output)
assert.Empty(t, files)
})
t.Run("URI with trailing backtick excluded by regex", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-001/data.json", []byte("{}"), 0644))
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
// Backtick-wrapped URI — the regex excludes the backtick from the captured path
output := "`workspace://ws-test/robots/r1/exec1/task-001/data.json`"
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 1)
assert.Equal(t, "data.json", files[0].Name)
assert.Equal(t, "application/json", files[0].Type)
})
t.Run("duplicate URIs deduplicated", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-001/report.html", []byte("<h1>hi</h1>"), 0644))
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
output := "workspace://ws-test/robots/r1/exec1/task-001/report.html and again workspace://ws-test/robots/r1/exec1/task-001/report.html"
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 1)
assert.Equal(t, "report.html", files[0].Name)
})
t.Run("empty output returns nil", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
assert.Nil(t, r.extractAndVerifyFiles(""))
})
t.Run("nil wsFS returns nil", func(t *testing.T) {
r := &Runner{wsFS: nil, execDir: "robots/r1/exec1"}
assert.Nil(t, r.extractAndVerifyFiles("some text with workspace://ws-test/foo/bar"))
})
t.Run("URI from different workspace filtered", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
output := "See workspace://other-ws/robots/r1/exec1/task-001/file.txt"
files := r.extractAndVerifyFiles(output)
assert.Empty(t, files)
})
t.Run("directory URI filtered out", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
output := "workspace://ws-test/robots/r1/exec1/task-001"
files := r.extractAndVerifyFiles(output)
assert.Empty(t, files)
})
t.Run("multiple valid files extracted", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-002", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-002/slides.html", []byte("<html>"), 0644))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-002/slides.pdf", []byte("%PDF"), 0644))
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
output := "Generated workspace://ws-test/robots/r1/exec1/task-002/slides.html and exported workspace://ws-test/robots/r1/exec1/task-002/slides.pdf"
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 2)
names := []string{files[0].Name, files[1].Name}
assert.Contains(t, names, "slides.html")
assert.Contains(t, names, "slides.pdf")
})
}
// ============================================================================
// mergeManifestFiles — pure unit tests
// ============================================================================
func TestMergeManifestFiles(t *testing.T) {
t.Run("empty fromURIs returns scanned unchanged", func(t *testing.T) {
scanned := []ManifestFile{{Name: "a.md", Type: "text/markdown"}}
result := mergeManifestFiles(scanned, nil)
assert.Equal(t, scanned, result)
})
t.Run("merge URI onto matching scanned entry", func(t *testing.T) {
scanned := []ManifestFile{{Name: "notes.md", Type: "text/markdown"}}
fromURIs := []ManifestFile{{Name: "notes.md", Type: "text/markdown", URI: "workspace://ws/path/notes.md"}}
result := mergeManifestFiles(scanned, fromURIs)
require.Len(t, result, 1)
assert.Equal(t, "workspace://ws/path/notes.md", result[0].URI)
})
t.Run("add new URI entry when no scan match", func(t *testing.T) {
scanned := []ManifestFile{{Name: "a.md", Type: "text/markdown"}}
fromURIs := []ManifestFile{{Name: "b.pdf", Type: "application/pdf", URI: "workspace://ws/b.pdf"}}
result := mergeManifestFiles(scanned, fromURIs)
require.Len(t, result, 2)
assert.Equal(t, "b.pdf", result[1].Name)
})
}
// ============================================================================
// llmSummarize — integration test (real LLM call)
// ============================================================================
func setupLLMProvider(t *testing.T) {
t.Helper()
if err := setting.Init(); err != nil {
t.Skipf("setting.Init failed (store not available): %v", err)
}
if err := llmprovider.Init(); err != nil {
t.Skipf("llmprovider.Init failed: %v", err)
}
if err := agent.SyncLLMDefaults(); err != nil {
t.Skipf("SyncLLMDefaults failed: %v", err)
}
connIDs := connector.AIConnectors
if len(connIDs) == 0 {
t.Skip("no AI connectors available in test env")
}
t.Cleanup(func() {
s, _ := store.Get("__yao.store")
if s != nil {
s.Del("llmprovider:*")
}
c, _ := store.Get("__yao.cache")
if c != nil {
c.Del("llmprovider:*")
}
})
}
func TestLLMSummarize(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test (requires real LLM)")
}
testutils.Prepare(t)
defer testutils.Clean(t)
setupLLMProvider(t)
auth := &oauthtypes.AuthorizedInfo{UserID: "test-user", TeamID: "test-team"}
ctx := robottypes.NewContext(context.Background(), auth)
r := &Runner{
ctx: ctx,
}
task := &robottypes.Task{
ID: "task-001",
Description: "Research Yao Agents platform and compile structured notes",
}
output := `## Research Findings
Yao Agents is an AI-powered platform that enables developers to build intelligent agents.
### Key Features
- **Model-Driven Architecture**: Define data models in YAML/JSON
- **Low-Code Engine**: Visual workflow builder
- **Multi-Agent Orchestration**: Coordinate multiple AI agents
### Conclusion
Yao Agents provides a comprehensive toolkit for building production-ready AI applications with minimal boilerplate code.`
files := []ManifestFile{
{Name: "research-notes.md", Type: "text/markdown"},
}
summary := r.llmSummarize(task, output, files)
t.Logf("LLM Summary: %s", summary)
assert.NotEmpty(t, summary, "summary should not be empty")
assert.Less(t, len(summary), 500, "summary should be concise (< 500 chars)")
}
// ============================================================================
// updateManifestForTask end-to-end — integration test
// ============================================================================
func TestUpdateManifestForTaskWithLLMSummary(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test (requires real LLM)")
}
testutils.Prepare(t)
defer testutils.Clean(t)
setupLLMProvider(t)
wfs := newTestWorkspaceFS(t)
auth := &oauthtypes.AuthorizedInfo{UserID: "test-user", TeamID: "test-team"}
ctx := robottypes.NewContext(context.Background(), auth)
execDir := "robots/r1/exec1"
r := &Runner{
ctx: ctx,
wsFS: wfs,
execDir: execDir,
robot: &robottypes.Robot{MemberID: "r1"},
}
task := robottypes.Task{
ID: "task-001",
Description: "Research Yao Agents platform",
ExecutorID: "yao.general",
ExecutorType: robottypes.ExecutorAssistant,
Status: robottypes.TaskPending,
}
exec := &robottypes.Execution{
ID: "exec1",
Tasks: []robottypes.Task{task},
}
r.initManifest(exec)
// Verify manifest was created with pending status
m, err := r.readManifest()
require.NoError(t, err)
require.Len(t, m.Tasks, 1)
assert.Equal(t, "pending", m.Tasks[0].Status)
// Write an artifact file that scanTaskArtifacts can discover
require.NoError(t, wfs.MkdirAll(path.Join(execDir, "task-001"), 0755))
require.NoError(t, wfs.WriteFile(path.Join(execDir, "task-001", "notes.md"), []byte("research content"), 0644))
// Simulate task completion with output referencing the artifact
wsID, _ := wfs.GetID()
result := &robottypes.TaskResult{
Success: true,
Duration: 5000,
Output: "Completed research. Notes saved to workspace://" + wsID + "/" + execDir + "/task-001/notes.md",
}
r.updateManifestForTask(&task, result)
// Re-read and verify
m, err = r.readManifest()
require.NoError(t, err)
require.Len(t, m.Tasks, 1)
mt := m.Tasks[0]
assert.Equal(t, "completed", mt.Status)
assert.NotEmpty(t, mt.Summary, "summary should be generated (LLM or fallback)")
t.Logf("Summary: %s", mt.Summary)
// Files should include the scanned artifact, potentially with URI merged
assert.NotEmpty(t, mt.Files, "files should be populated")
found := false
for _, f := range mt.Files {
if f.Name == "notes.md" {
found = true
assert.Equal(t, "text/markdown", f.Type)
}
}
assert.True(t, found, "notes.md should be in files list")
}

View file

@ -47,7 +47,7 @@ func (l *Logger) prefix() string {
func (l *Logger) Trace(format string, args ...interface{}) { func (l *Logger) Trace(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
if config.IsDevelopment() && !config.Silent { if config.IsDevelopment() {
fmt.Printf("%s → %s %s%s\n", gray, l.prefix(), msg, reset) fmt.Printf("%s → %s %s%s\n", gray, l.prefix(), msg, reset)
} }
kunlog.Trace("%s %s", l.prefix(), msg) kunlog.Trace("%s %s", l.prefix(), msg)
@ -55,7 +55,7 @@ func (l *Logger) Trace(format string, args ...interface{}) {
func (l *Logger) Debug(format string, args ...interface{}) { func (l *Logger) Debug(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
if config.IsDevelopment() && !config.Silent { if config.IsDevelopment() {
fmt.Printf("%s • %s %s%s\n", gray, l.prefix(), msg, reset) fmt.Printf("%s • %s %s%s\n", gray, l.prefix(), msg, reset)
} }
kunlog.Debug("%s %s", l.prefix(), msg) kunlog.Debug("%s %s", l.prefix(), msg)
@ -63,7 +63,7 @@ func (l *Logger) Debug(format string, args ...interface{}) {
func (l *Logger) Info(format string, args ...interface{}) { func (l *Logger) Info(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
if config.IsDevelopment() && !config.Silent { if config.IsDevelopment() {
fmt.Printf("%s %s %s%s\n", cyan, l.prefix(), msg, reset) fmt.Printf("%s %s %s%s\n", cyan, l.prefix(), msg, reset)
} }
kunlog.Info("%s %s", l.prefix(), msg) kunlog.Info("%s %s", l.prefix(), msg)
@ -71,7 +71,7 @@ func (l *Logger) Info(format string, args ...interface{}) {
func (l *Logger) Warn(format string, args ...interface{}) { func (l *Logger) Warn(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
if config.IsDevelopment() && !config.Silent { if config.IsDevelopment() {
fmt.Printf("%s ⚠ %s %s%s\n", yellow, l.prefix(), msg, reset) fmt.Printf("%s ⚠ %s %s%s\n", yellow, l.prefix(), msg, reset)
} }
kunlog.Warn("%s %s", l.prefix(), msg) kunlog.Warn("%s %s", l.prefix(), msg)
@ -79,7 +79,7 @@ func (l *Logger) Warn(format string, args ...interface{}) {
func (l *Logger) Error(format string, args ...interface{}) { func (l *Logger) Error(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
if config.IsDevelopment() && !config.Silent { if config.IsDevelopment() {
fmt.Printf("%s ✗ %s %s%s\n", red, l.prefix(), msg, reset) fmt.Printf("%s ✗ %s %s%s\n", red, l.prefix(), msg, reset)
} }
kunlog.Error("%s %s", l.prefix(), msg) kunlog.Error("%s %s", l.prefix(), msg)
@ -94,7 +94,7 @@ func IsDev() bool {
// Use for rich multi-line output (box-style logs, tables, etc.) // Use for rich multi-line output (box-style logs, tables, etc.)
// that should bypass the standard single-line prefix format. // that should bypass the standard single-line prefix format.
func Raw(s string) { func Raw(s string) {
if config.IsDevelopment() && !config.Silent { if config.IsDevelopment() {
fmt.Print(s) fmt.Print(s)
} }
} }

View file

@ -337,6 +337,7 @@ func (m *Manager) callHostAgent(ctx *types.Context, agentID string, input *types
} }
caller := standard.NewConversationCaller(chatID) caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON)) result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON))
if err != nil { if err != nil {
@ -739,6 +740,7 @@ func (m *Manager) callHostAgentStream(ctx *types.Context, agentID string, input
} }
caller := standard.NewConversationCaller(chatID) caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessagesStream(ctx, agentID, string(inputJSON), streamFn) result, err := caller.CallWithMessagesStream(ctx, agentID, string(inputJSON), streamFn)
if err != nil { if err != nil {
@ -958,6 +960,7 @@ func (m *Manager) callHostAgentStreamRaw(ctx *types.Context, agentID string, inp
} }
caller := standard.NewConversationCaller(chatID) caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace caller.Workspace = robot.Workspace
result, err := caller.CallWithMessagesStreamRaw(ctx, agentID, string(inputJSON), wrappedOnMessage) result, err := caller.CallWithMessagesStreamRaw(ctx, agentID, string(inputJSON), wrappedOnMessage)
if err != nil { if err != nil {

View file

@ -524,7 +524,6 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
Action: req.Action, Action: req.Action,
Messages: req.Messages, Messages: req.Messages,
UserID: ctx.UserID(), UserID: ctx.UserID(),
Locale: req.Locale,
} }
// Handle plan.add action - schedule for later // Handle plan.add action - schedule for later

View file

@ -14,7 +14,6 @@ type InterveneRequest struct {
Messages []agentcontext.Message `json:"messages"` // user input (text, images, files) Messages []agentcontext.Message `json:"messages"` // user input (text, images, files)
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan
ExecutorMode ExecutorMode `json:"executor_mode,omitempty"` // optional: override robot config ExecutorMode ExecutorMode `json:"executor_mode,omitempty"` // optional: override robot config
Locale string `json:"locale,omitempty"` // language for UI display (e.g., "en", "zh")
} }
// EventRequest - event trigger request // EventRequest - event trigger request

View file

@ -372,12 +372,9 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
} }
} }
// Prevent Claude CLI from using an excessive max_tokens that the backend // Note: System prompt and max_turns are passed via CLI flags in BuildCommand
// API will reject. In OpenAI-proxy mode the hardcoded model is // CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI
// claude-sonnet-4-6 whose limit is 16384. // --append-system-prompt or --system-prompt flags must be used instead
if opts.ConnectorType != "anthropic" {
env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = "16384"
}
return env return env
} }

View file

@ -2,19 +2,224 @@ package claude
import ( import (
"context" "context"
"fmt"
"path/filepath"
"strings"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/shared" "github.com/yaoapp/yao/attachment"
workspace "github.com/yaoapp/yao/tai/workspace" workspace "github.com/yaoapp/yao/tai/workspace"
) )
// prepareAttachments resolves __yao.attachment:// URLs in messages, // prepareAttachments resolves __yao.attachment:// URLs in messages,
// copies actual files into the workspace .attachments/{chatID}/ directory via ws.Copy, // copies actual files into the workspace .attachments/{chatID}/ directory via ws.Copy,
// and replaces multimodal content parts with text references. // and replaces multimodal content parts with text references.
//
// Delegates to shared.PrepareAttachments; the returned text-replaced messages
// are used directly by the Claude CLI (which reads local files via text refs).
func prepareAttachments(ctx context.Context, messages []agentContext.Message, chatID string, ws workspace.FS) ([]agentContext.Message, error) { func prepareAttachments(ctx context.Context, messages []agentContext.Message, chatID string, ws workspace.FS) ([]agentContext.Message, error) {
processed, _, err := shared.PrepareAttachments(ctx, messages, chatID, ws) usedNames := make(map[string]int)
return processed, err attachDir := ".attachments/" + chatID
result := make([]agentContext.Message, len(messages))
copy(result, messages)
for i, msg := range result {
if msg.Role != "user" {
continue
}
parts, ok := msg.Content.([]interface{})
if !ok {
if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok {
iparts := make([]interface{}, len(typedParts))
for j, p := range typedParts {
m := map[string]interface{}{"type": string(p.Type)}
if p.Text != "" {
m["text"] = p.Text
}
if p.ImageURL != nil {
m["image_url"] = map[string]interface{}{
"url": p.ImageURL.URL,
"detail": string(p.ImageURL.Detail),
}
}
if p.File != nil {
m["file"] = map[string]interface{}{
"url": p.File.URL,
"filename": p.File.Filename,
}
}
iparts[j] = m
}
parts = iparts
} else {
continue
}
}
if len(parts) == 0 {
continue
}
var textParts []string
for _, item := range parts {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
partType, _ := m["type"].(string)
switch partType {
case "text":
if text, ok := m["text"].(string); ok && text != "" {
textParts = append(textParts, text)
}
case "image_url":
imgData, _ := m["image_url"].(map[string]interface{})
if imgData == nil {
continue
}
url, _ := imgData["url"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[Image: %s]", url))
continue
}
ref, err := resolveAttachment(ctx, uploaderName, fileID, "", attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached image: failed to load]")
continue
}
textParts = append(textParts, ref)
case "file":
fileData, _ := m["file"].(map[string]interface{})
if fileData == nil {
continue
}
url, _ := fileData["url"].(string)
hintName, _ := fileData["filename"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[File: %s]", url))
continue
}
ref, err := resolveAttachment(ctx, uploaderName, fileID, hintName, attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached file: failed to load]")
continue
}
textParts = append(textParts, ref)
}
}
if len(textParts) > 0 {
newMsg := result[i]
newMsg.Content = strings.Join(textParts, "\n\n")
result[i] = newMsg
}
}
return result, nil
}
// resolveAttachment gets the local path of an attachment and copies it into
// the workspace via ws.Copy("local:///abs/path", ".attachments/{chatID}/filename").
func resolveAttachment(
ctx context.Context,
uploaderName, fileID, hintName, attachDir string,
usedNames map[string]int,
ws workspace.FS,
) (string, error) {
manager, exists := attachment.Managers[uploaderName]
if !exists {
return "", fmt.Errorf("attachment manager not found: %s", uploaderName)
}
fileInfo, err := manager.Info(ctx, fileID)
if err != nil {
return "", fmt.Errorf("failed to get file info: %w", err)
}
absPath, _, err := manager.LocalPath(ctx, fileID)
if err != nil {
return "", fmt.Errorf("failed to get local path: %w", err)
}
filename := fileInfo.Filename
if filename == "" && hintName != "" {
filename = hintName
}
if filename == "" {
ext := extensionFromContentType(fileInfo.ContentType)
filename = fileID + ext
}
baseName := filename
if count, exists := usedNames[baseName]; exists {
ext := filepath.Ext(filename)
name := strings.TrimSuffix(filename, ext)
filename = fmt.Sprintf("%s_%d%s", name, count+1, ext)
usedNames[baseName] = count + 1
} else {
usedNames[baseName] = 0
}
dstPath := attachDir + "/" + filename
src := "local:///" + absPath
if _, err := ws.Copy(src, dstPath); err != nil {
return "", fmt.Errorf("failed to copy attachment to workspace: %w", err)
}
sizeStr := formatFileSize(fileInfo.Bytes)
return fmt.Sprintf("[Attached file: %s (%s, %s)]", dstPath, fileInfo.ContentType, sizeStr), nil
}
func extensionFromContentType(contentType string) string {
switch contentType {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/svg+xml":
return ".svg"
case "application/pdf":
return ".pdf"
case "text/plain":
return ".txt"
case "text/html":
return ".html"
case "text/css":
return ".css"
case "text/javascript", "application/javascript":
return ".js"
case "application/json":
return ".json"
case "application/zip":
return ".zip"
default:
return ""
}
}
func formatFileSize(bytes int) string {
switch {
case bytes >= 1024*1024:
return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024))
case bytes >= 1024:
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
default:
return fmt.Sprintf("%dB", bytes)
}
} }

View file

@ -2,8 +2,6 @@ package claude
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
@ -12,9 +10,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/gou/store" "github.com/yaoapp/gou/store"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/kun/str" "github.com/yaoapp/kun/str"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
@ -22,17 +18,11 @@ import (
) )
const defaultA2OPort = 3099 const defaultA2OPort = 3099
const defaultA2OMaxOutputTokens = 16384
var yaoSessionNS = uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479") var yaoSessionNS = uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
var safeNameRe = regexp.MustCompile(`[^a-zA-Z0-9_\-.]`) var safeNameRe = regexp.MustCompile(`[^a-zA-Z0-9_\-.]`)
func hashUserID(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:8]) // 16 hex chars — short yet collision-safe
}
func chatIDToSessionUUID(assistantID, chatID string) string { func chatIDToSessionUUID(assistantID, chatID string) string {
return uuid.NewSHA1(yaoSessionNS, []byte(assistantID+":"+chatID)).String() return uuid.NewSHA1(yaoSessionNS, []byte(assistantID+":"+chatID)).String()
} }
@ -84,9 +74,6 @@ func (r *Runner) buildCommand(ctx context.Context, req *types.StreamRequest, p p
var systemPrompt string var systemPrompt string
envPrompt := buildSandboxEnvPrompt(p, workDir) envPrompt := buildSandboxEnvPrompt(p, workDir)
if capPrompt := buildModelCapabilityPrompt(req); capPrompt != "" {
envPrompt += "\n\n" + capPrompt
}
if !isContinuation && req.SystemPrompt != "" { if !isContinuation && req.SystemPrompt != "" {
systemPrompt = req.SystemPrompt + "\n\n" + envPrompt systemPrompt = req.SystemPrompt + "\n\n" + envPrompt
} else if !isContinuation { } else if !isContinuation {
@ -140,10 +127,6 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
} }
env["WORKDIR"] = workDir env["WORKDIR"] = workDir
if req.Locale != "" {
env["CTX_LOCALE"] = req.Locale
}
assistantID := req.AssistantID assistantID := req.AssistantID
if assistantID != "" { if assistantID != "" {
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID) configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)
@ -157,46 +140,69 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
if req.Connector != nil { if req.Connector != nil {
setting := req.Connector.Setting() setting := req.Connector.Setting()
host, _ := setting["host"].(string)
key, _ := setting["key"].(string)
model, _ := setting["model"].(string)
var host, key, model string roleConnectors := getRoleConnectors(req)
if lc, ok := req.Connector.(goullm.LLMConnector); ok { getConn := func(id string) connector.Connector {
host = lc.GetURL() c, _ := connector.Connectors[id]
key = lc.GetKey() return c
model = lc.GetModel()
}
if host == "" {
host, _ = setting["host"].(string)
}
if key == "" {
key, _ = setting["key"].(string)
}
if model == "" {
model, _ = setting["model"].(string)
} }
isAnthropic := req.Connector.Is(connector.ANTHROPIC) if req.Connector.Is(connector.ANTHROPIC) {
env["ANTHROPIC_BASE_URL"] = host
env["ANTHROPIC_API_KEY"] = key
if model != "" {
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
env["CLAUDE_CODE_SUBAGENT_MODEL"] = model
}
if isAnthropic { if len(roleConnectors) > 0 {
setAnthropicModelEnv(env, host, key, model, req.Connector) primaryHost := host
applyAnthropicRoleOverrides(env, host, req.Roles) for role, rm := range claudeRoleEnvMap {
if role == "primary" {
continue
}
rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn)
if rc == nil {
continue
}
rcHost := connectorHost(rc)
if rcHost == primaryHost && supportsProtocol(rc, "anthropic") {
rcModel, _ := rc.Setting()["model"].(string)
if rcModel != "" {
env[rm.EnvVar] = rcModel
}
}
}
}
} else { } else {
setA2OModelEnv(env, req.Connector.ID(), model, req.Connector) connectorID := req.Connector.ID()
applyA2ORoleOverrides(env, req.Roles) env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID)
} env["ANTHROPIC_API_KEY"] = "dummy"
env["ANTHROPIC_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-sonnet-4-6"
env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6"
if lc, ok := req.Connector.(goullm.LLMConnector); ok { if len(roleConnectors) > 0 {
if caps := lc.GetCapabilities(); caps != nil { for role, rm := range claudeRoleEnvMap {
if caps.MaxOutputTokens > 0 { if role == "primary" {
env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = fmt.Sprintf("%d", caps.MaxOutputTokens) continue
} }
if caps.MaxInputTokens > 0 { rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn)
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = fmt.Sprintf("%d", caps.MaxInputTokens) if rc == nil {
continue
}
env[rm.EnvVar] = rm.ModelName
} }
} }
} }
if _, ok := env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"]; !ok && !req.Connector.Is(connector.ANTHROPIC) {
env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = fmt.Sprintf("%d", defaultA2OMaxOutputTokens)
}
if thinking, ok := setting["thinking"].(map[string]interface{}); ok { if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
thinkType, _ := thinking["type"].(string) thinkType, _ := thinking["type"].(string)
@ -226,40 +232,6 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
} }
} }
logger := req.Logger
if logger == nil {
logger = agentContext.NoopLogger()
}
connectorID := ""
if req.Connector != nil {
connectorID = req.Connector.ID()
}
logger.Debug("claude-env: connector=%s isAnthropic=%v", connectorID, req.Connector != nil && req.Connector.Is(connector.ANTHROPIC))
logger.Debug("claude-env: ANTHROPIC_MODEL=%s", env["ANTHROPIC_MODEL"])
logger.Debug("claude-env: OPUS_MODEL=%s SONNET_MODEL=%s HAIKU_MODEL=%s",
env["ANTHROPIC_DEFAULT_OPUS_MODEL"],
env["ANTHROPIC_DEFAULT_SONNET_MODEL"],
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"])
logger.Debug("claude-env: CUSTOM_MODEL_OPTION=%s CAPABILITIES=%s",
env["ANTHROPIC_CUSTOM_MODEL_OPTION"],
env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"])
logger.Debug("claude-env: MAX_THINKING_TOKENS=%s", env["MAX_THINKING_TOKENS"])
// Override metadata.user_id with a sanitized value.
// Claude CLI sets metadata.user_id to a JSON object that third-party
// Anthropic-compatible APIs (e.g. DeepSeek) reject because the value
// doesn't match ^[a-zA-Z0-9_-]+$.
if _, ok := env["CLAUDE_CODE_EXTRA_BODY"]; !ok {
uid := "yao-sandbox"
if req.Config != nil && req.Config.Owner != "" {
uid = hashUserID(req.Config.Owner)
} else if assistantID != "" {
uid = hashUserID(assistantID)
}
env["CLAUDE_CODE_EXTRA_BODY"] = fmt.Sprintf(`{"metadata":{"user_id":"%s"}}`, uid)
}
logger.Debug("claude-env: EXTRA_BODY=%s", env["CLAUDE_CODE_EXTRA_BODY"])
return env return env
} }
@ -330,149 +302,26 @@ func buildSandboxEnvPrompt(p platform, workDir string) string {
shellNote := p.EnvPromptNote() shellNote := p.EnvPromptNote()
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s - **Operating System**: %[2]s
- **Shell**: %[3]s - **Shell**: %[3]s
- **Working Directory**: %[1]s - **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s - **File Access**: You have full read/write access to %[1]s
%[4]s`, workDir, osName, shell, shellNote) - **Environment variable syntax**: `+"`%[5]s`"+` (e.g. `+"`$CTX_SKILLS_DIR`"+` on POSIX, `+"`$env:CTX_SKILLS_DIR`"+` on Windows)%[4]s
}
func buildModelCapabilityPrompt(req *types.StreamRequest) string { ## User Attachments
if req.Connector == nil {
return ""
}
lc, ok := req.Connector.(goullm.LLMConnector) User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.attachments/{chatID}/
if !ok { Each chat session has its own subdirectory to avoid conflicts.
return "" When the user references an attached file, read it from this directory using the Read or Bash tool.
} For image files, you can view them directly as Claude supports vision on local files.
primaryModel := lc.GetModel() `, workDir, osName, shell, shellNote, envVarSyntax)
if primaryModel == "" {
return ""
}
primaryCaps := lc.GetCapabilities()
type tierInfo struct {
tier string
alias string
model string
caps *goullm.Capabilities
conn connector.Connector
}
tiers := []tierInfo{
{tier: "Default", alias: "sonnet", model: primaryModel, caps: primaryCaps, conn: req.Connector},
}
hasDifferentTier := false
if rc, exists := req.Roles["heavy"]; exists && rc != nil {
m := connectorModel(rc)
if m != "" {
caps := connectorCaps(rc)
tiers = append(tiers, tierInfo{tier: "Heavy", alias: "opus", model: m, caps: caps, conn: rc})
if m != primaryModel {
hasDifferentTier = true
}
}
}
if rc, exists := req.Roles["light"]; exists && rc != nil {
m := connectorModel(rc)
if m != "" {
caps := connectorCaps(rc)
tiers = append(tiers, tierInfo{tier: "Light", alias: "haiku", model: m, caps: caps, conn: rc})
if m != primaryModel {
hasDifferentTier = true
}
}
}
var sb strings.Builder
sb.WriteString("## Model Capabilities\n\n")
sb.WriteString(fmt.Sprintf("Your current model: `%s`\n", primaryModel))
if hasDifferentTier {
sb.WriteString("\n### Available Model Tiers\n\n")
sb.WriteString("| Tier | Alias | Model | Capabilities |\n")
sb.WriteString("| ---- | ----- | ----- | ------------ |\n")
for _, t := range tiers {
capList := formatCapabilities(t.caps, t.conn)
sb.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", t.tier, t.alias, t.model, capList))
}
}
var guidance []string
if hasDifferentTier {
guidance = append(guidance,
"For complex reasoning, multi-step analysis, or tasks requiring deep thought, delegate to a sub-agent with `model: \"opus\"`",
"For simple tasks (formatting, translation, summarization), use `model: \"haiku\"` for faster responses",
)
}
primaryHasVision := primaryCaps.HasVision()
if !primaryHasVision {
if _, hasVisionRole := req.Roles["vision"]; hasVisionRole {
guidance = append(guidance,
"**Image/Vision**: Your current model cannot process images directly. Use the `image_read` system tool (`tai tool image_read`) to analyze images — see the yao-image skill for details",
)
}
}
if len(guidance) > 0 {
sb.WriteString("\n### Usage Guidance\n\n")
for _, g := range guidance {
sb.WriteString("- " + g + "\n")
}
}
if !hasDifferentTier && len(guidance) == 0 {
return ""
}
return sb.String()
}
func connectorModel(c connector.Connector) string {
if lc, ok := c.(goullm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
return m
}
}
m, _ := c.Setting()["model"].(string)
return m
}
func connectorCaps(c connector.Connector) *goullm.Capabilities {
if lc, ok := c.(goullm.LLMConnector); ok {
return lc.GetCapabilities()
}
return nil
}
func formatCapabilities(caps *goullm.Capabilities, conn connector.Connector) string {
var parts []string
hasThinking := caps.HasReasoning()
if !hasThinking && conn != nil {
if thinking, ok := conn.Setting()["thinking"].(map[string]interface{}); ok {
if t, _ := thinking["type"].(string); t == "enabled" {
hasThinking = true
}
}
}
if hasThinking {
parts = append(parts, "thinking")
}
if caps.HasVision() {
parts = append(parts, "vision")
}
if caps.HasToolCalls() {
parts = append(parts, "tool_calls")
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, ", ")
} }
func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool { func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool {
@ -549,23 +398,24 @@ func buildLastUserMessageJSONL(messages []agentContext.Message) string {
} }
// claudeRoleEnvMap maps abstract Yao model roles to Claude CLI environment // claudeRoleEnvMap maps abstract Yao model roles to Claude CLI environment
// variables. Only roles with matching Claude CLI env vars are listed here. // variables and virtual model name identifiers used as A2O route keys.
// ANTHROPIC_DEFAULT_SONNET_MODEL is set to the primary model in buildEnv. // ModelName uniqueness is only required among roles that have independent
var claudeRoleEnvMap = map[string]struct{ EnvVar string }{ // connectors (i.e. are added to the A2O routes map).
"default": {EnvVar: "ANTHROPIC_MODEL"}, var claudeRoleEnvMap = map[string]struct {
"heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL"}, EnvVar string
"light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL"}, ModelName string
}{
"primary": {EnvVar: "ANTHROPIC_MODEL", ModelName: "claude-sonnet-4-6"},
"heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL", ModelName: "claude-opus-4-6"},
"light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL", ModelName: "claude-haiku-4-5"},
"subagent": {EnvVar: "CLAUDE_CODE_SUBAGENT_MODEL", ModelName: "claude-subagent-4-6"},
"vision": {EnvVar: "ANTHROPIC_DEFAULT_SONNET_MODEL", ModelName: "claude-vision-4-5"},
} }
func connectorHost(c connector.Connector) string { func connectorHost(c connector.Connector) string {
if c == nil { if c == nil {
return "" return ""
} }
if lc, ok := c.(goullm.LLMConnector); ok {
if u := lc.GetURL(); u != "" {
return u
}
}
host, _ := c.Setting()["host"].(string) host, _ := c.Setting()["host"].(string)
return host return host
} }
@ -593,149 +443,33 @@ func supportsProtocol(c connector.Connector, proto string) bool {
return false return false
} }
// resolveRoleConnector determines which connector to use for a given role.
// Returns nil when the role should use the primary connector (caller decides).
func resolveRoleConnector(
role string,
roleConnectors map[string]*types.RoleConnector,
userExplicit bool,
getConnector func(id string) connector.Connector,
) connector.Connector {
rc, ok := roleConnectors[role]
if !ok || rc == nil {
return nil
}
if rc.Override == "user" && userExplicit {
return nil
}
return getConnector(rc.Connector)
}
func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector {
if req.Config == nil {
return nil
}
return req.Config.Runner.Connectors
}
var claudeArgWhitelist = map[string]string{ var claudeArgWhitelist = map[string]string{
"max_turns": "--max-turns", "max_turns": "--max-turns",
"disallowed_tools": "--disallowed-tools", "disallowed_tools": "--disallowed-tools",
"allowed_tools": "--allowedTools", "allowed_tools": "--allowedTools",
} }
func isStandardAnthropicModel(model string) bool {
return strings.HasPrefix(model, "claude-") || strings.HasPrefix(model, "anthropic.")
}
func buildClaudeCodeCapabilities(conn connector.Connector) string {
if conn == nil {
return ""
}
setting := conn.Setting()
if setting == nil {
return ""
}
var caps []string
if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
if thinkType, _ := thinking["type"].(string); thinkType == "enabled" {
caps = append(caps, "thinking")
}
}
return strings.Join(caps, ",")
}
func setAnthropicModelEnv(env map[string]string, host, key, model string, conn connector.Connector) {
env["ANTHROPIC_BASE_URL"] = host
env["ANTHROPIC_API_KEY"] = key
if model == "" {
return
}
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
if isStandardAnthropicModel(model) {
return
}
caps := buildClaudeCodeCapabilities(conn)
env["ANTHROPIC_CUSTOM_MODEL_OPTION"] = model
env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"] = model
if caps != "" {
env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES"] = caps
}
}
func applyAnthropicRoleOverrides(
env map[string]string,
primaryHost string,
roles map[string]connector.Connector,
) {
for role, rm := range claudeRoleEnvMap {
if role == "default" {
continue
}
rc, ok := roles[role]
if !ok || rc == nil {
continue
}
roleHost := connectorHost(rc)
if roleHost != primaryHost {
log.Warn("[claude] role %s: host mismatch (%s != %s), falling back to primary", role, roleHost, primaryHost)
continue
}
if !supportsProtocol(rc, "anthropic") {
log.Warn("[claude] role %s: not anthropic protocol, falling back to primary", role)
continue
}
rcModel, _ := rc.Setting()["model"].(string)
if rcModel == "" {
continue
}
env[rm.EnvVar] = rcModel
if isStandardAnthropicModel(rcModel) {
continue
}
env[rm.EnvVar+"_NAME"] = rcModel
if caps := buildClaudeCodeCapabilities(rc); caps != "" {
env[rm.EnvVar+"_SUPPORTED_CAPABILITIES"] = caps
}
}
}
func setA2OModelEnv(env map[string]string, connectorID, model string, conn connector.Connector) {
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID)
env["ANTHROPIC_API_KEY"] = "dummy"
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
if !isStandardAnthropicModel(model) {
env["ANTHROPIC_CUSTOM_MODEL_OPTION"] = model
env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"] = model
if caps := buildClaudeCodeCapabilities(conn); caps != "" {
env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES"] = caps
}
}
}
func applyA2ORoleOverrides(
env map[string]string,
roles map[string]connector.Connector,
) {
for role, rm := range claudeRoleEnvMap {
if role == "default" {
continue
}
rc, ok := roles[role]
if !ok || rc == nil {
continue
}
var rcModel string
if lc, ok := rc.(goullm.LLMConnector); ok {
rcModel = lc.GetModel()
}
if rcModel == "" {
rcModel, _ = rc.Setting()["model"].(string)
}
if rcModel == "" {
continue
}
env[rm.EnvVar] = rcModel
if !isStandardAnthropicModel(rcModel) {
env[rm.EnvVar+"_NAME"] = rcModel
if caps := buildClaudeCodeCapabilities(rc); caps != "" {
env[rm.EnvVar+"_SUPPORTED_CAPABILITIES"] = caps
}
}
}
}

View file

@ -9,7 +9,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
gouTypes "github.com/yaoapp/gou/types" gouTypes "github.com/yaoapp/gou/types"
"github.com/yaoapp/xun/dbal/query" "github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/xun/dbal/schema" "github.com/yaoapp/xun/dbal/schema"
@ -309,6 +308,7 @@ func TestBuildSandboxEnvPrompt(t *testing.T) {
assert.Contains(t, prompt, "darwin") assert.Contains(t, prompt, "darwin")
assert.Contains(t, prompt, "bash") assert.Contains(t, prompt, "bash")
assert.Contains(t, prompt, "Sandbox Environment") assert.Contains(t, prompt, "Sandbox Environment")
assert.Contains(t, prompt, ".attachments")
} }
func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) { func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) {
@ -525,6 +525,64 @@ func TestSupportsProtocol(t *testing.T) {
assert.True(t, supportsProtocol(oai, "openai")) assert.True(t, supportsProtocol(oai, "openai"))
} }
func TestResolveRoleConnector_Undeclared(t *testing.T) {
roles := map[string]*types.RoleConnector{}
result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector { return nil })
assert.Nil(t, result)
}
func TestResolveRoleConnector_Force(t *testing.T) {
heavyConn := newOpenAIConnector("thinking", "https://api.thinking.com", "think-model", "k")
roles := map[string]*types.RoleConnector{
"heavy": {Connector: "thinking", Override: "force"},
}
result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector {
if id == "thinking" {
return heavyConn
}
return nil
})
assert.Equal(t, heavyConn, result)
}
func TestResolveRoleConnector_UserExplicit(t *testing.T) {
roles := map[string]*types.RoleConnector{
"heavy": {Connector: "thinking", Override: "user"},
}
result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector {
return newOpenAIConnector("thinking", "h", "m", "k")
})
assert.Nil(t, result, "override=user + userExplicit=true => use user's connector")
}
func TestResolveRoleConnector_UserNotExplicit(t *testing.T) {
heavyConn := newOpenAIConnector("thinking", "h", "m", "k")
roles := map[string]*types.RoleConnector{
"heavy": {Connector: "thinking", Override: "user"},
}
result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector {
if id == "thinking" {
return heavyConn
}
return nil
})
assert.Equal(t, heavyConn, result, "override=user + userExplicit=false => use sandbox connector")
}
// --- buildEnv with multi-connector ---
func registerTestConnectors(t *testing.T, connectors map[string]connector.Connector) func() {
t.Helper()
for id, c := range connectors {
connector.Connectors[id] = c
}
return func() {
for id := range connectors {
delete(connector.Connectors, id)
}
}
}
func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) { func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) {
oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test")
req := &types.StreamRequest{ req := &types.StreamRequest{
@ -537,32 +595,37 @@ func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) {
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1") assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1")
assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi") assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi")
assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"]) assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"])
assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_DEFAULT_OPUS_MODEL"])
} }
func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) { func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) {
primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test")
heavyConn := newOpenAIConnector("heavy-conn", "https://api.heavy.com", "heavy-model", "sk-h") vision := newOpenAIConnector("vision-conn", "https://api.vision.com", "vis-model", "sk-v")
cleanup := registerTestConnectors(t, map[string]connector.Connector{
"vision-conn": vision,
})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{}, Config: &types.SandboxConfig{
Connector: primary, Runner: types.RunnerConfig{
Roles: map[string]connector.Connector{ Connectors: map[string]*types.RoleConnector{
"default": primary, "vision": {Connector: "vision-conn", Override: "force"},
"heavy": heavyConn,
}, },
},
},
Connector: primary,
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Equal(t, "heavy-model", env["ANTHROPIC_DEFAULT_OPUS_MODEL"], assert.Equal(t, "claude-vision-4-5", env["ANTHROPIC_DEFAULT_SONNET_MODEL"],
"heavy role should use actual model name from connector") "vision role should get its virtual model name for A2O routing")
assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"], assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"],
"primary should use actual model name from connector") "primary should keep default virtual model")
assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_CUSTOM_MODEL_OPTION"],
"non-standard model should set custom model option")
} }
func TestBuildEnv_Anthropic_SingleConnector(t *testing.T) { func TestBuildEnv_Anthropic_SingleConnector(t *testing.T) {
@ -584,13 +647,20 @@ func TestBuildEnv_Anthropic_MultiConnector_Compatible(t *testing.T) {
primary := newAnthropicConnector("claude", "https://api.yao.run", "claude-sonnet-4-20250514", "sk-ant") primary := newAnthropicConnector("claude", "https://api.yao.run", "claude-sonnet-4-20250514", "sk-ant")
lightConn := newDualProtoConnector("light-conn", "https://api.yao.run", "claude-haiku-3-5-20241022", "sk-light") lightConn := newDualProtoConnector("light-conn", "https://api.yao.run", "claude-haiku-3-5-20241022", "sk-light")
cleanup := registerTestConnectors(t, map[string]connector.Connector{
"light-conn": lightConn,
})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{}, Config: &types.SandboxConfig{
Connector: primary, Runner: types.RunnerConfig{
Roles: map[string]connector.Connector{ Connectors: map[string]*types.RoleConnector{
"default": primary, "light": {Connector: "light-conn", Override: "force"},
"light": lightConn,
}, },
},
},
Connector: primary,
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
@ -622,10 +692,10 @@ func TestBuildSingleA2OConfig_Nil(t *testing.T) {
func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) { func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) {
primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-kimi") primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-kimi")
heavyConn := newOpenAIConnector("heavy", "https://api.heavy.com", "heavy-model", "sk-h") vision := newOpenAIConnector("vision", "https://api.vision.com", "vis-model", "sk-v")
roleConnectors := map[string]connector.Connector{ roleConnectors := map[string]connector.Connector{
"heavy-model": heavyConn, "claude-vision-4-5": vision,
} }
primaryCfg := buildSingleA2OConfig(primary) primaryCfg := buildSingleA2OConfig(primary)
@ -650,10 +720,10 @@ func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) {
require.True(t, ok, "routes should be present in JSON") require.True(t, ok, "routes should be present in JSON")
assert.Len(t, routesMap, 1) assert.Len(t, routesMap, 1)
heavyRoute, ok := routesMap["heavy-model"].(map[string]interface{}) visionRoute, ok := routesMap["claude-vision-4-5"].(map[string]interface{})
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "heavy-model", heavyRoute["model"]) assert.Equal(t, "vis-model", visionRoute["model"])
assert.Contains(t, heavyRoute["backend"], "api.heavy.com") assert.Contains(t, visionRoute["backend"], "api.vision.com")
} }
func TestResolveAllRoleConnectors_Empty(t *testing.T) { func TestResolveAllRoleConnectors_Empty(t *testing.T) {
@ -666,191 +736,48 @@ func TestResolveAllRoleConnectors_Empty(t *testing.T) {
} }
func TestResolveAllRoleConnectors_WithRoles(t *testing.T) { func TestResolveAllRoleConnectors_WithRoles(t *testing.T) {
primaryConn := newOpenAIConnector("primary", "https://primary.com", "primary-m", "k") vision := newOpenAIConnector("vis", "https://vis.com", "vis-m", "sk")
heavyConn := newOpenAIConnector("hvy", "https://heavy.com", "heavy-m", "sk") cleanup := registerTestConnectors(t, map[string]connector.Connector{"vis": vision})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{}, Config: &types.SandboxConfig{
Connector: primaryConn, Runner: types.RunnerConfig{
Roles: map[string]connector.Connector{ Connectors: map[string]*types.RoleConnector{
"default": primaryConn, "vision": {Connector: "vis", Override: "force"},
"heavy": heavyConn,
}, },
},
},
Connector: newOpenAIConnector("primary", "h", "m", "k"),
} }
result := resolveAllRoleConnectors(req) result := resolveAllRoleConnectors(req)
assert.Len(t, result, 2) assert.Len(t, result, 1)
assert.Equal(t, primaryConn, result["primary-m"]) assert.Equal(t, vision, result["claude-vision-4-5"])
assert.Equal(t, heavyConn, result["heavy-m"])
} }
func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) { func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) {
primary := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant") primary := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant")
heavyConn := newOpenAIConnector("heavy-oai", "https://api.openai.com", "gpt-4o", "sk-oai") visionConn := newOpenAIConnector("vision-oai", "https://api.openai.com", "gpt-4o", "sk-oai")
cleanup := registerTestConnectors(t, map[string]connector.Connector{
"vision-oai": visionConn,
})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{}, Config: &types.SandboxConfig{
Connector: primary, Runner: types.RunnerConfig{
Roles: map[string]connector.Connector{ Connectors: map[string]*types.RoleConnector{
"default": primary, "vision": {Connector: "vision-oai", Override: "force"},
"heavy": heavyConn,
}, },
},
},
Connector: primary,
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Equal(t, "claude-sonnet-4-20250514", env["ANTHROPIC_DEFAULT_OPUS_MODEL"], assert.Equal(t, "claude-sonnet-4-20250514", env["ANTHROPIC_DEFAULT_SONNET_MODEL"],
"incompatible connector: heavy should keep primary model (different host, no anthropic protocol)") "incompatible connector: vision should keep primary model (different host, no anthropic protocol)")
}
// --- fakeLLMConnector implements both connector.Connector and goullm.LLMConnector ---
type fakeLLMConnector struct {
fakeConnector
model string
caps *goullm.Capabilities
}
func (f *fakeLLMConnector) GetAuthMode() goullm.AuthMode { return goullm.AuthBearer }
func (f *fakeLLMConnector) GetURL() string { return "" }
func (f *fakeLLMConnector) GetKey() string { return "" }
func (f *fakeLLMConnector) GetModel() string { return f.model }
func (f *fakeLLMConnector) GetSupportedParams() map[string]*goullm.ParamSpec {
return nil
}
func (f *fakeLLMConnector) GetCapabilities() *goullm.Capabilities { return f.caps }
// --- buildModelCapabilityPrompt tests ---
func TestBuildModelCapabilityPrompt_NilConnector(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
result := buildModelCapabilityPrompt(req)
assert.Empty(t, result)
}
func TestBuildModelCapabilityPrompt_NoRoles_NoSpecialCaps(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "test", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true, Streaming: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
}
result := buildModelCapabilityPrompt(req)
assert.Empty(t, result, "no roles and no special caps → empty")
}
func TestBuildModelCapabilityPrompt_WithHeavyAndLight(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-flash", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true},
}
heavy := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-pro", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-pro"}},
model: "deepseek-v4-pro",
caps: &goullm.Capabilities{Reasoning: true, ToolCalls: true},
}
light := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-lite", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavy,
"light": light,
},
}
result := buildModelCapabilityPrompt(req)
assert.Contains(t, result, "deepseek-v4-flash")
assert.Contains(t, result, "deepseek-v4-pro")
assert.Contains(t, result, "opus")
assert.Contains(t, result, "haiku")
assert.Contains(t, result, "thinking")
assert.Contains(t, result, "tool_calls")
assert.Contains(t, result, "sub-agent")
}
func TestBuildModelCapabilityPrompt_VisionGuidance(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-flash", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true},
}
visionConn := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "vision", typ: connector.OPENAI, settings: map[string]interface{}{"model": "gpt-4o"}},
model: "gpt-4o",
caps: &goullm.Capabilities{Vision: true, ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"vision": visionConn,
},
}
result := buildModelCapabilityPrompt(req)
assert.Contains(t, result, "image_read")
assert.Contains(t, result, "Image/Vision")
}
func TestBuildModelCapabilityPrompt_PrimaryHasVision_NoGuidance(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "gpt4o", typ: connector.OPENAI, settings: map[string]interface{}{"model": "gpt-4o"}},
model: "gpt-4o",
caps: &goullm.Capabilities{Vision: true, ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"vision": primary,
},
}
result := buildModelCapabilityPrompt(req)
assert.NotContains(t, result, "image_read", "should not suggest image_read when primary has vision")
}
func TestBuildModelCapabilityPrompt_ThinkingFromSettings(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{
id: "ds-pro",
typ: connector.OPENAI,
settings: map[string]interface{}{
"model": "deepseek-v4-pro",
"thinking": map[string]interface{}{
"type": "enabled",
},
},
},
model: "deepseek-v4-pro",
caps: &goullm.Capabilities{ToolCalls: true},
}
heavy := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-pro2", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-pro-max"}},
model: "deepseek-v4-pro-max",
caps: &goullm.Capabilities{ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavy,
},
}
result := buildModelCapabilityPrompt(req)
assert.Contains(t, result, "thinking", "should detect thinking from Setting()[\"thinking\"]")
} }

View file

@ -11,7 +11,6 @@ import (
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
) )
// streamParser is an explicit state machine for Claude CLI stream-json output. // streamParser is an explicit state machine for Claude CLI stream-json output.
@ -78,7 +77,8 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
} }
}() }()
reader := bufio.NewReaderSize(stdout, 64*1024) scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
startTime := time.Now() startTime := time.Now()
lineCount := 0 lineCount := 0
@ -87,22 +87,9 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
log.Trace("[claude-parse] stream started") log.Trace("[claude-parse] stream started")
for { for scanner.Scan() {
line, skipped, err := shared.ReadJSONLine(reader) line := scanner.Text()
if err == io.EOF { if line == "" {
break
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
if skipped {
log.Warn("[claude-parse] skipped oversized JSONL line (>%dMB)", shared.MaxLineSize/1024/1024)
continue
}
if len(line) == 0 {
continue continue
} }
lineCount++ lineCount++
@ -118,11 +105,11 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
} }
var msg map[string]any var msg map[string]any
if err := json.Unmarshal(line, &msg); err != nil { if err := json.Unmarshal([]byte(line), &msg); err != nil {
if len(line) > 200 { if len(line) > 200 {
log.Trace("[claude-parse] JSON unmarshal error: %v (line len=%d, prefix=%q)", err, len(line), string(line[:200])) log.Trace("[claude-parse] JSON unmarshal error: %v (line len=%d, prefix=%q)", err, len(line), line[:200])
} else { } else {
log.Trace("[claude-parse] JSON unmarshal error: %v (line=%q)", err, string(line)) log.Trace("[claude-parse] JSON unmarshal error: %v (line=%q)", err, line)
} }
continue continue
} }
@ -154,9 +141,16 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
} }
} }
log.Trace("[claude-parse] stream ended: lines=%d elapsed=%v completed=%v", log.Trace("[claude-parse] stream ended: lines=%d elapsed=%v completed=%v scanErr=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed) lineCount, time.Since(startTime).Round(time.Second), p.completed, scanner.Err())
if err := scanner.Err(); err != nil {
log.Trace("[claude-parse] scanner error: %v (ctx.Err=%v)", err, ctx.Err())
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
return nil return nil
} }

View file

@ -8,14 +8,11 @@ import (
"time" "time"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2" infra "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tools"
) )
// Runner implements the sandbox Runner interface for Claude CLI (mode=cli). // Runner implements the sandbox Runner interface for Claude CLI (mode=cli).
@ -51,18 +48,6 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
steps := append([]types.PrepareStep{}, req.Config.Prepare...) steps := append([]types.PrepareStep{}, req.Config.Prepare...)
if ws := req.Computer.Workplace(); ws != nil {
if err := shared.InjectSystemSkills(ws, tools.SkillsFS, ".claude/skills"); err != nil {
r.logger.Warn("inject system skills: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "CLAUDE.md", tools.SystemPrompt); err != nil {
r.logger.Warn("append CLAUDE.md: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "AGENTS.md", tools.SystemPrompt); err != nil {
r.logger.Warn("append AGENTS.md: %v", err)
}
}
if req.SkillsDir != "" { if req.SkillsDir != "" {
ws := req.Computer.Workplace() ws := req.Computer.Workplace()
if ws != nil { if ws != nil {
@ -202,8 +187,6 @@ type a2oConnectorConfig struct {
Backend string `json:"backend"` Backend string `json:"backend"`
Model string `json:"model"` Model string `json:"model"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
AuthMode string `json:"auth_mode,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Options map[string]interface{} `json:"options,omitempty"` Options map[string]interface{} `json:"options,omitempty"`
Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"` Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"`
} }
@ -216,18 +199,6 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
cfg := &a2oConnectorConfig{} cfg := &a2oConnectorConfig{}
// Extract standard fields via LLMConnector methods when available
if lc, ok := conn.(goullm.LLMConnector); ok {
if url := lc.GetURL(); url != "" {
cfg.Backend = connector.BuildAPIURL(url, "/chat/completions")
}
cfg.Model = lc.GetModel()
cfg.APIKey = lc.GetKey()
cfg.AuthMode = string(lc.GetAuthMode())
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 {
cfg.MaxOutputTokens = caps.MaxOutputTokens
}
} else {
if host, ok := settings["host"].(string); ok && host != "" { if host, ok := settings["host"].(string); ok && host != "" {
cfg.Backend = connector.BuildAPIURL(host, "/chat/completions") cfg.Backend = connector.BuildAPIURL(host, "/chat/completions")
} else if proxy, ok := settings["proxy"].(string); ok && proxy != "" { } else if proxy, ok := settings["proxy"].(string); ok && proxy != "" {
@ -239,43 +210,47 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
if key, ok := settings["key"].(string); ok && key != "" { if key, ok := settings["key"].(string); ok && key != "" {
cfg.APIKey = key cfg.APIKey = key
} }
}
// Whitelist-filter remaining settings for the options field extra := make(map[string]interface{})
extra := connector.FilterRequestBodyParams(settings, conn) for k, v := range settings {
switch k {
case "host", "model", "key", "proxy", "type":
continue
default:
extra[k] = v
}
}
if len(extra) > 0 { if len(extra) > 0 {
cfg.Options = extra cfg.Options = extra
} }
if cfg.MaxOutputTokens == 0 {
cfg.MaxOutputTokens = defaultA2OMaxOutputTokens
}
if cfg.Backend == "" { if cfg.Backend == "" {
return nil return nil
} }
return cfg return cfg
} }
// resolveAllRoleConnectors maps pre-resolved role connectors from req.Roles // resolveAllRoleConnectors resolves all declared role connectors and returns
// to actual model names used as A2O proxy route keys. // a map of virtual model name -> connector for roles that have independent connectors.
func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector { func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector {
if len(req.Roles) == 0 { roleConns := getRoleConnectors(req)
if len(roleConns) == 0 {
return nil return nil
} }
result := make(map[string]connector.Connector) result := make(map[string]connector.Connector)
for _, rc := range req.Roles { for role, rm := range claudeRoleEnvMap {
var model string if role == "primary" {
if lc, ok := rc.(goullm.LLMConnector); ok { continue
model = lc.GetModel()
} }
if model == "" { rc := resolveRoleConnector(role, roleConns, req.UserExplicit, func(id string) connector.Connector {
model, _ = rc.Setting()["model"].(string) c, _ := connector.Connectors[id]
} return c
if model != "" { })
result[model] = rc if rc == nil {
continue
} }
result[rm.ModelName] = rc
} }
return result return result
} }

View file

@ -63,10 +63,6 @@ func (s *session) runStream(handler message.StreamFunc) (completed bool, err err
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr) s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
if !parser.completed && parseErr != nil {
s.exec.Cancel()
}
if parser.completed { if parser.completed {
s.logger.Info("claude stream completed normally") s.logger.Info("claude stream completed normally")
return true, nil return true, nil

View file

@ -2,7 +2,6 @@ package sandboxv2
import ( import (
"github.com/yaoapp/yao/agent/sandbox/v2/claude" "github.com/yaoapp/yao/agent/sandbox/v2/claude"
"github.com/yaoapp/yao/agent/sandbox/v2/opencode"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
yaorunner "github.com/yaoapp/yao/agent/sandbox/v2/yao" yaorunner "github.com/yaoapp/yao/agent/sandbox/v2/yao"
) )
@ -10,7 +9,5 @@ import (
func init() { func init() {
Register("claude", func() types.Runner { return claude.New() }) Register("claude", func() types.Runner { return claude.New() })
Register("claude/cli", func() types.Runner { return claude.New() }) Register("claude/cli", func() types.Runner { return claude.New() })
Register("opencode", func() types.Runner { return opencode.New() })
Register("opencode/cli", func() types.Runner { return opencode.New() })
Register("yao", func() types.Runner { return yaorunner.New() }) Register("yao", func() types.Runner { return yaorunner.New() })
} }

View file

@ -329,9 +329,8 @@ func resolveOwnerID(ctx *agentContext.Context) string {
} }
// pickNodeByFilter selects a random online node that satisfies the given filter // pickNodeByFilter selects a random online node that satisfies the given filter
// and image requirement. If image is non-empty, nodes with a container runtime // and image requirement. If image is non-empty, candidate nodes must have a
// (Docker or K8s) are preferred; if none are available, host_exec nodes are // container runtime (Docker or K8s).
// accepted as fallback (ResolveNodeID will resolve them to host mode).
func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error) { func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error) {
reg := registry.Global() reg := registry.Global()
if reg == nil { if reg == nil {
@ -340,7 +339,6 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error
nodes := reg.List() nodes := reg.List()
var candidates []string var candidates []string
var hostExecFallback []string
for _, n := range nodes { for _, n := range nodes {
if n.Status != "online" && n.Status != "" { if n.Status != "online" && n.Status != "" {
continue continue
@ -374,20 +372,12 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error
} }
if image != "" && !(n.Capabilities.Docker || n.Capabilities.K8s) { if image != "" && !(n.Capabilities.Docker || n.Capabilities.K8s) {
if n.Capabilities.HostExec {
hostExecFallback = append(hostExecFallback, n.TaiID)
}
continue continue
} }
candidates = append(candidates, n.TaiID) candidates = append(candidates, n.TaiID)
} }
if len(candidates) == 0 && len(hostExecFallback) > 0 {
log.Trace("[sandbox/v2] pickNodeByFilter: no container node for image %q, falling back to host_exec node", image)
candidates = hostExecFallback
}
if len(candidates) == 0 { if len(candidates) == 0 {
kind := "" kind := ""
os := "" os := ""

View file

@ -1,378 +0,0 @@
package opencode
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/kun/str"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
var (
yaoSessionNS = uuid.MustParse("e37bc21a-72dd-4a8f-b567-1f02c3d4e590")
safeNameRe = regexp.MustCompile(`[^a-zA-Z0-9_\-.]`)
)
type command struct {
shell []string
env map[string]string
stdin string
workDir string
}
func chatIDToSessionID(assistantID, chatID string) string {
return uuid.NewSHA1(yaoSessionNS, []byte(assistantID+":"+chatID)).String()
}
func sanitizeSessionName(chatID string) string {
return "yao-oc-" + safeNameRe.ReplaceAllString(chatID, "_")
}
func chatSessionExists(storeKey string) bool {
s, err := store.Get("__yao.store")
if err != nil {
return false
}
return s.Has(storeKey)
}
func markChatSession(storeKey, sessionID string, ttl time.Duration) {
s, err := store.Get("__yao.store")
if err != nil {
return
}
s.Set(storeKey, sessionID, ttl)
}
func (r *Runner) buildCommand(req *types.StreamRequest, p platform, attachmentPaths []string) command {
workDir := req.Computer.GetWorkDir()
assistantID := req.AssistantID
chatID := req.ChatID
var isContinuation bool
if chatID != "" {
storeKey := "opencode-session:" + assistantID + ":" + chatID
isContinuation = chatSessionExists(storeKey)
}
env := buildEnv(req, p)
args := buildArgs(req, r, isContinuation, chatID)
stdinMsg := buildStdinMessage(req.Messages, attachmentPaths)
script := shellQuoteForPlatform(p, "opencode", args...)
return command{
shell: p.ShellCmd(script),
env: env,
stdin: stdinMsg,
workDir: workDir,
}
}
func buildEnv(req *types.StreamRequest, p platform) map[string]string {
env := make(map[string]string)
workDir := req.Computer.GetWorkDir()
ws := req.Computer.Workplace()
if ws != nil {
workspaceID, err := ws.GetID()
if err == nil {
env["CTX_WORKSPACE_ID"] = workspaceID
}
}
for k, v := range p.HomeEnv(workDir) {
env[k] = v
}
env["WORKDIR"] = workDir
assistantID := req.AssistantID
prefix := p.PathJoin(workDir, ".yao", "assistants", assistantID, "opencode")
if assistantID == "" {
prefix = p.PathJoin(workDir, ".opencode-data")
}
if assistantID != "" {
env["CTX_ASSISTANT_ID"] = assistantID
env["CTX_SKILLS_DIR"] = p.PathJoin(workDir, ".yao", "assistants", assistantID, "skills")
}
env["OPENCODE_DATA_DIR"] = p.PathJoin(prefix, "data")
env["OPENCODE_CACHE_DIR"] = p.PathJoin(prefix, "cache")
env["OPENCODE_STATE_DIR"] = p.PathJoin(prefix, "state")
env["OPENCODE_CONFIG_DIR"] = p.PathJoin(prefix, "config")
env["OPENCODE_DISABLE_AUTOUPDATE"] = "true"
env["OPENCODE_DISABLE_MODELS_FETCH"] = "true"
env["OPENCODE_DISABLE_LSP_DOWNLOAD"] = "true"
env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
env["OPENCODE_DISABLE_TERMINAL_TITLE"] = "true"
env["OPENCODE_DISABLE_MOUSE"] = "true"
env["OPENCODE_DISABLE_CLAUDE_CODE"] = "true"
env["OPENCODE_CLIENT"] = "cli"
// Lower bash default timeout from 120s to 30s. Long-running commands
// like browsers should be nohup'd; this prevents accidental 2-min hangs.
env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = "30000"
primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
if primaryConn != nil {
setting := primaryConn.Setting()
key, _ := setting["key"].(string)
if key != "" {
env["YAO_PROVIDER_KEY"] = key
}
if primaryConn.Is(connector.ANTHROPIC) {
apiKey, _ := setting["key"].(string)
if apiKey != "" {
env["ANTHROPIC_API_KEY"] = apiKey
}
}
}
injectRoleEnvVars(env, req)
if req.Config != nil && len(req.Config.Secrets) > 0 {
for k, v := range req.Config.Secrets {
env[k] = str.EnvVar(v)
}
}
if req.Token != nil {
if req.Token.Token != "" {
env["YAO_TOKEN"] = req.Token.Token
}
if req.Token.RefreshToken != "" {
env["YAO_REFRESH_TOKEN"] = req.Token.RefreshToken
}
}
return env
}
func buildArgs(req *types.StreamRequest, r *Runner, isContinuation bool, chatID string) []string {
args := []string{"run", "--format", "json"}
permMode := ""
if req.Config != nil && req.Config.Runner.Options != nil {
if v, ok := req.Config.Runner.Options["permission_mode"]; ok {
permMode = fmt.Sprintf("%v", v)
}
}
if permMode == "bypassPermissions" {
args = append(args, "--dangerously-skip-permissions")
}
if chatID != "" && isContinuation {
sessionID := chatIDToSessionID(req.AssistantID, chatID)
args = append(args, "--continue", "--session", sessionID)
}
primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
if primaryConn != nil {
if mid := connectorModelID(primaryConn); mid != "" {
args = append(args, "--model", mid)
}
}
// User message and attachments are passed via stdin (heredoc pipe),
// NOT as positional args. This avoids shell escaping issues with
// special characters, CJK text, long messages, and --file ambiguity.
return args
}
// buildStdinMessage builds the text piped to `opencode run` via stdin.
// It combines the user's text message with attachment references so OpenCode
// receives everything through stdin — no positional args, no --file flags.
// This mirrors the Claude runner approach and avoids shell escaping pitfalls.
func buildStdinMessage(messages []agentContext.Message, attachmentPaths []string) string {
var parts []string
if len(attachmentPaths) > 0 {
parts = append(parts, "The user has attached the following files — read them to understand context:")
for _, p := range attachmentPaths {
parts = append(parts, fmt.Sprintf(" - %s", p))
}
parts = append(parts, "")
}
text := lastUserText(messages)
if text != "" {
parts = append(parts, text)
}
return strings.Join(parts, "\n")
}
// lastUserText extracts the plain text from the last user message,
// handling string, []ContentPart, and []any (generic JSON) content types.
func lastUserText(messages []agentContext.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role != "user" {
continue
}
switch c := messages[i].Content.(type) {
case string:
return c
case []agentContext.ContentPart:
var texts []string
for _, part := range c {
if part.Type == agentContext.ContentText && part.Text != "" {
texts = append(texts, part.Text)
}
}
return strings.Join(texts, "\n")
case []any:
var texts []string
for _, item := range c {
if m, ok := item.(map[string]any); ok {
if t, _ := m["type"].(string); t == "text" {
if text, _ := m["text"].(string); text != "" {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
}
}
return ""
}
func buildSandboxEnvPrompt(p platform, workDir string) string {
osName := p.OS()
if osName == "" {
osName = "linux"
}
shell := p.Shell()
if shell == "" {
shell = "bash"
}
return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s
- **Shell**: %[3]s
- **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s
`, workDir, osName, shell)
}
func getProviderPrefix(conn connector.Connector) string {
if conn != nil && conn.Is(connector.ANTHROPIC) {
return "anthropic"
}
return "openai"
}
// shellQuoteForPlatform builds a shell-safe command string. On Windows
// (PowerShell) it uses single quotes with ” escaping; on POSIX it uses
// single quotes with '\” escaping.
func shellQuoteForPlatform(p platform, program string, args ...string) string {
if p.OS() == "windows" {
return shellQuotePowerShell(program, args...)
}
return shellQuote(program, args...)
}
// shellQuote builds a POSIX shell-safe command string from program and args.
func shellQuote(program string, args ...string) string {
parts := make([]string, 0, 1+len(args))
parts = append(parts, program)
for _, a := range args {
if a == "" || strings.ContainsAny(a, " \t\n\"'\\$`!#&|;(){}[]<>?*~") {
parts = append(parts, "'"+strings.ReplaceAll(a, "'", `'\''`)+"'")
} else {
parts = append(parts, a)
}
}
return strings.Join(parts, " ")
}
// shellQuotePowerShell builds a PowerShell-safe command string. In PowerShell,
// single-quoted strings escape embedded single quotes by doubling them (”).
func shellQuotePowerShell(program string, args ...string) string {
parts := make([]string, 0, 1+len(args))
parts = append(parts, program)
for _, a := range args {
if a == "" || strings.ContainsAny(a, " \t\n\"'\\$`!#&|;(){}[]<>?*~") {
parts = append(parts, "'"+strings.ReplaceAll(a, "'", "''")+"'")
} else {
parts = append(parts, a)
}
}
return strings.Join(parts, " ")
}
// connectorModelID returns the "provider/model" string matching the
// provider ID used in opencode.json (see buildProviderConfig).
// Uses LLMConnector interface first (consistent with buildProviderConfig).
func connectorModelID(c connector.Connector) string {
host := connectorHost(c)
var modelName string
if lc, ok := c.(goullm.LLMConnector); ok {
modelName = lc.GetModel()
}
if modelName == "" {
modelName, _ = c.Setting()["model"].(string)
}
if c.Is(connector.ANTHROPIC) {
return "anthropic/" + modelName
}
if host == "" || isNativeOpenAI(host) {
return "openai/" + modelName
}
return "custom/" + modelName
}
// injectRoleEnvVars adds API key, base URL, and model environment variables
// for each role connector defined in openCodeRoleMap. These env vars are
// consumed by opencode.json provider blocks (via {env:...} references) and
// by the custom read.ts tool (for vision API calls).
func injectRoleEnvVars(env map[string]string, req *types.StreamRequest) {
if len(req.Roles) == 0 {
return
}
for role, spec := range openCodeRoleMap {
if spec.EnvKeyPrefix == "" {
continue
}
c, ok := req.Roles[role]
if !ok || c == nil {
continue
}
setting := c.Setting()
if key, _ := setting["key"].(string); key != "" {
env[spec.EnvKeyPrefix+"_KEY"] = key
}
if host, _ := setting["host"].(string); host != "" {
env[spec.EnvKeyPrefix+"_BASE_URL"] = normalizeBaseURL(host)
}
if model, _ := setting["model"].(string); model != "" {
env[spec.EnvKeyPrefix+"_MODEL"] = model
}
}
}
func connectorHost(c connector.Connector) string {
if c == nil {
return ""
}
if lc, ok := c.(goullm.LLMConnector); ok {
if u := lc.GetURL(); u != "" {
return strings.TrimSpace(u)
}
}
host, _ := c.Setting()["host"].(string)
return strings.TrimSpace(host)
}

View file

@ -1,145 +0,0 @@
package opencode
import (
"testing"
agentContext "github.com/yaoapp/yao/agent/context"
)
func TestChatIDToSessionID(t *testing.T) {
id1 := chatIDToSessionID("assistant-1", "chat-1")
id2 := chatIDToSessionID("assistant-1", "chat-1")
id3 := chatIDToSessionID("assistant-1", "chat-2")
if id1 != id2 {
t.Error("same inputs should produce same session ID")
}
if id1 == id3 {
t.Error("different chatIDs should produce different session IDs")
}
if id1 == "" {
t.Error("session ID should not be empty")
}
}
func TestSanitizeSessionName(t *testing.T) {
cases := []struct {
input, want string
}{
{"simple-chat", "yao-oc-simple-chat"},
{"chat with spaces", "yao-oc-chat_with_spaces"},
{"chat/with/slashes", "yao-oc-chat_with_slashes"},
{"chat@special#chars", "yao-oc-chat_special_chars"},
}
for _, tc := range cases {
got := sanitizeSessionName(tc.input)
if got != tc.want {
t.Errorf("sanitizeSessionName(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestLastUserText(t *testing.T) {
cases := []struct {
name string
messages []agentContext.Message
want string
}{
{
name: "empty",
messages: nil,
want: "",
},
{
name: "single user message",
messages: []agentContext.Message{
{Role: "user", Content: "hello"},
},
want: "hello",
},
{
name: "last user wins",
messages: []agentContext.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "reply"},
{Role: "user", Content: "second"},
},
want: "second",
},
{
name: "no user messages",
messages: []agentContext.Message{
{Role: "assistant", Content: "only assistant"},
},
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := lastUserText(tc.messages)
if got != tc.want {
t.Errorf("lastUserText() = %q, want %q", got, tc.want)
}
})
}
}
func TestBuildStdinMessage(t *testing.T) {
msgs := []agentContext.Message{
{Role: "user", Content: "把这个会议纪要的关键内容提取出来"},
}
t.Run("no attachments", func(t *testing.T) {
got := buildStdinMessage(msgs, nil)
if got != "把这个会议纪要的关键内容提取出来" {
t.Errorf("unexpected: %q", got)
}
})
t.Run("with attachments", func(t *testing.T) {
got := buildStdinMessage(msgs, []string{"/workspace/.attachments/abc/test.txt"})
if !strContains(got, "/workspace/.attachments/abc/test.txt") {
t.Error("should contain attachment path")
}
if !strContains(got, "把这个会议纪要的关键内容提取出来") {
t.Error("should contain user message")
}
})
t.Run("empty message", func(t *testing.T) {
got := buildStdinMessage(nil, []string{"/workspace/file.txt"})
if !strContains(got, "/workspace/file.txt") {
t.Error("should contain attachment path even without message")
}
})
}
func TestBuildSandboxEnvPrompt(t *testing.T) {
p := &posixBase{os: "linux", shell: "bash"}
prompt := buildSandboxEnvPrompt(p, "/workspace")
if prompt == "" {
t.Error("prompt should not be empty")
}
if !strContains(prompt, "/workspace") {
t.Error("prompt should mention workspace path")
}
if !strContains(prompt, "linux") {
t.Error("prompt should mention OS")
}
}
func TestGetProviderPrefix(t *testing.T) {
if p := getProviderPrefix(nil); p != "openai" {
t.Errorf("nil connector should give openai, got %s", p)
}
}
func strContains(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View file

@ -1,379 +0,0 @@
package opencode
import (
"encoding/json"
"strings"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
type roleSpec struct {
EnvKeyPrefix string
TopLevel string
}
// openCodeRoleMap lists roles that map to native OpenCode config concepts.
// "light" → top-level "small_model"; "vision" → env vars only (read.ts hack).
// "heavy" is handled via resolvePrimaryConnector (becomes the main model).
var openCodeRoleMap = map[string]roleSpec{
"light": {
EnvKeyPrefix: "YAO_LIGHT",
TopLevel: "small_model",
},
"vision": {
EnvKeyPrefix: "YAO_VISION",
},
}
// resolvePrimaryConnector returns the heavy role connector if present in the
// pre-resolved roles map, otherwise falls back to the caller-supplied primary.
func resolvePrimaryConnector(primary connector.Connector, roles map[string]connector.Connector) connector.Connector {
if c, ok := roles["heavy"]; ok && c != nil {
return c
}
return primary
}
// buildOpenCodeConfig generates the opencode.json project configuration.
// All provider configuration is direct (no a2o proxy).
func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer) []byte {
cfg := map[string]any{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"snapshot": false,
"share": "disabled",
"watcher": map[string]any{"ignore": []string{".yao/**", ".attachments/**"}},
"permission": map[string]any{"*": "allow"},
}
primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
if primaryConn != nil {
providerID, providerCfg, modelStr := buildProviderConfig(primaryConn)
cfg["provider"] = map[string]any{providerID: providerCfg}
cfg["model"] = modelStr
cfg["enabled_providers"] = []string{providerID}
}
injectRoleProviders(cfg, req, primaryConn)
if len(mcpServers) > 0 {
cfg["mcp"] = buildMCPConfig(mcpServers)
}
prefix := ".yao/assistants/" + req.AssistantID
if req.AssistantID == "" {
prefix = ".opencode"
}
cfg["instructions"] = []string{prefix + "/system-prompt.md"}
data, _ := json.MarshalIndent(cfg, "", " ")
return data
}
// buildProviderConfig maps a Yao connector to an OpenCode provider configuration.
// Anthropic connectors map directly; OpenAI/OpenAI-compatible map to "openai".
//
// OpenCode appends its own endpoint paths (e.g. /responses) to baseURL,
// so we must NOT include /chat/completions. For native OpenAI (api.openai.com)
// we omit baseURL entirely and let OpenCode use its built-in default.
// For custom hosts (OpenAI-compatible proxies), we pass the bare host URL.
func buildProviderConfig(conn connector.Connector) (providerID string, cfg map[string]any, model string) {
setting := conn.Setting()
var host, modelName string
if lc, ok := conn.(goullm.LLMConnector); ok {
host = lc.GetURL()
modelName = lc.GetModel()
}
if host == "" {
host, _ = setting["host"].(string)
}
if modelName == "" {
modelName, _ = setting["model"].(string)
}
opts := map[string]any{
"apiKey": "{env:YAO_PROVIDER_KEY}",
}
if conn.Is(connector.ANTHROPIC) {
if host != "" {
opts["baseURL"] = host
}
return "anthropic", map[string]any{"options": opts}, "anthropic/" + modelName
}
// Native OpenAI (api.openai.com): use built-in "openai" provider which
// already knows all official models — no models declaration needed.
if host == "" || isNativeOpenAI(host) {
return "openai", map[string]any{"options": opts}, "openai/" + modelName
}
// OpenAI-compatible provider (DeepSeek, Moonshot, etc.): must use
// @ai-sdk/openai-compatible and explicitly declare models, otherwise
// OpenCode throws ProviderModelNotFoundError.
opts["baseURL"] = normalizeBaseURL(host)
modelCfg := map[string]any{
"name": modelName,
}
// DeepSeek (and similar) thinking models return reasoning_content in
// assistant messages. OpenCode must be told to preserve and replay
// this field on conversation continuation, otherwise the API returns:
// "The reasoning_content in the thinking mode must be passed back to the API."
// Adding "interleaved" is safe for non-thinking models (no-op if absent).
modelCfg["interleaved"] = map[string]any{"field": "reasoning_content"}
// Forward connector-level request body params (thinking, reasoning, etc.)
// to OpenCode model options, using the same FilterRequestBodyParams
// mechanism as buildRequestBody in yao/agent/llm.
connParams := connector.FilterRequestBodyParams(setting, conn)
if len(connParams) > 0 {
modelCfg["options"] = connParams
}
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
limit := map[string]any{}
if caps.MaxInputTokens > 0 {
limit["context"] = caps.MaxInputTokens
}
if caps.MaxOutputTokens > 0 {
limit["output"] = caps.MaxOutputTokens
}
if len(limit) > 0 {
modelCfg["limit"] = limit
}
}
}
return "custom", map[string]any{
"npm": "@ai-sdk/openai-compatible",
"options": opts,
"models": map[string]any{
modelName: modelCfg,
},
}, "custom/" + modelName
}
// isNativeOpenAI returns true if host points to official OpenAI API,
// where OpenCode already knows the correct base URL.
func isNativeOpenAI(host string) bool {
h := strings.TrimRight(strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://"), "/")
return h == "api.openai.com" ||
strings.HasPrefix(h, "api.openai.com/")
}
// normalizeBaseURL strips trailing /chat/completions or /v1/chat/completions
// that Yao connectors may include, because OpenCode appends its own paths.
func normalizeBaseURL(host string) string {
u := strings.TrimRight(host, "/")
for _, suffix := range []string{"/chat/completions", "/completions"} {
if strings.HasSuffix(u, suffix) {
u = strings.TrimSuffix(u, suffix)
break
}
}
return strings.TrimRight(u, "/")
}
// injectRoleProviders iterates openCodeRoleMap and injects provider blocks
// for every role that has a configured connector. For the "light" role it
// also sets the top-level "small_model" field. primaryConn is the resolved
// primary connector (may be heavy or default) used for sameProvider checks.
func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest, primaryConn connector.Connector) {
if len(req.Roles) == 0 {
return
}
providers, _ := cfg["provider"].(map[string]any)
if providers == nil {
providers = map[string]any{}
cfg["provider"] = providers
}
enabledSlice, _ := cfg["enabled_providers"].([]string)
enabledSet := map[string]bool{}
for _, e := range enabledSlice {
enabledSet[e] = true
}
primaryHost := ""
primaryType := ""
if primaryConn != nil {
primaryHost = connectorHost(primaryConn)
if primaryConn.Is(connector.ANTHROPIC) {
primaryType = "anthropic"
} else {
primaryType = "openai"
}
}
for role, spec := range openCodeRoleMap {
c, ok := req.Roles[role]
if !ok || c == nil {
continue
}
setting := c.Setting()
modelName, _ := setting["model"].(string)
if modelName == "" {
continue
}
roleHost := connectorHost(c)
roleType := "openai"
if c.Is(connector.ANTHROPIC) {
roleType = "anthropic"
}
sameProvider := roleType == primaryType && roleHost == primaryHost
if sameProvider && primaryHost != "" {
sameProvider = true
} else if sameProvider && primaryHost == "" && roleHost == "" {
sameProvider = true
} else if roleHost != primaryHost {
sameProvider = false
}
var providerID string
var modelRef string
if sameProvider {
providerID = resolveExistingProviderID(providers, primaryType)
modelRef = providerID + "/" + modelName
mergeModelIntoProvider(providers, providerID, modelName)
} else {
providerID = role
providerCfg := buildRoleProviderConfig(c, spec.EnvKeyPrefix)
providers[providerID] = providerCfg
modelRef = providerID + "/" + modelName
}
if !enabledSet[providerID] {
enabledSlice = append(enabledSlice, providerID)
enabledSet[providerID] = true
}
if spec.TopLevel != "" {
cfg[spec.TopLevel] = modelRef
}
}
cfg["enabled_providers"] = enabledSlice
}
// resolveExistingProviderID finds the actual provider ID key used in the
// providers map for a given type. For "openai" type, it could be "openai"
// or "custom" (for openai-compatible). Returns the type as fallback.
func resolveExistingProviderID(providers map[string]any, pType string) string {
if _, ok := providers[pType]; ok {
return pType
}
if pType == "openai" {
if _, ok := providers["custom"]; ok {
return "custom"
}
}
return pType
}
// mergeModelIntoProvider adds a model entry to an existing provider block.
func mergeModelIntoProvider(providers map[string]any, providerID, modelName string) {
block, ok := providers[providerID].(map[string]any)
if !ok {
return
}
models, _ := block["models"].(map[string]any)
if models == nil {
models = map[string]any{}
block["models"] = models
}
models[modelName] = map[string]any{"name": modelName}
}
// buildRoleProviderConfig creates a provider configuration block for a
// non-primary role connector. Uses the role's env key prefix for API key
// and base URL references.
func buildRoleProviderConfig(conn connector.Connector, envKeyPrefix string) map[string]any {
setting := conn.Setting()
modelName, _ := setting["model"].(string)
host, _ := setting["host"].(string)
opts := map[string]any{
"apiKey": "{env:" + envKeyPrefix + "_KEY}",
}
modelCfg := map[string]any{"name": modelName}
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
limit := map[string]any{}
if caps.MaxInputTokens > 0 {
limit["context"] = caps.MaxInputTokens
}
if caps.MaxOutputTokens > 0 {
limit["output"] = caps.MaxOutputTokens
}
if len(limit) > 0 {
modelCfg["limit"] = limit
}
}
}
if conn.Is(connector.ANTHROPIC) {
if host != "" {
opts["baseURL"] = host
}
return map[string]any{
"options": opts,
"models": map[string]any{modelName: modelCfg},
}
}
if host == "" || isNativeOpenAI(host) {
return map[string]any{
"options": opts,
"models": map[string]any{modelName: modelCfg},
}
}
opts["baseURL"] = normalizeBaseURL(host)
modelCfg["interleaved"] = map[string]any{"field": "reasoning_content"}
return map[string]any{
"npm": "@ai-sdk/openai-compatible",
"options": opts,
"models": map[string]any{modelName: modelCfg},
}
}
// buildMCPConfig produces the "mcp" object for opencode.json.
// OpenCode uses "command" as an array (not command + args like Claude).
func buildMCPConfig(servers []types.MCPServer) map[string]any {
result := make(map[string]any, len(servers))
for _, s := range servers {
name := s.ServerID
if name == "" {
continue
}
result[name] = map[string]any{
"type": "local",
"command": []string{"tai", "mcp", name},
"enabled": true,
"environment": map[string]string{"YAO_TOKEN": "{env:YAO_TOKEN}"},
}
}
if len(result) == 0 {
result["yao"] = map[string]any{
"type": "local",
"command": []string{"tai", "mcp"},
"enabled": true,
"environment": map[string]string{"YAO_TOKEN": "{env:YAO_TOKEN}"},
}
}
return result
}

View file

@ -1,125 +0,0 @@
package opencode
import (
"encoding/json"
"testing"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
func TestBuildOpenCodeConfig_Defaults(t *testing.T) {
req := &types.PrepareRequest{
AssistantID: "test-assistant",
Config: &types.SandboxConfig{},
}
data := buildOpenCodeConfig(req, nil)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if cfg["autoupdate"] != false {
t.Errorf("autoupdate should be false, got %v", cfg["autoupdate"])
}
if cfg["snapshot"] != false {
t.Errorf("snapshot should be false, got %v", cfg["snapshot"])
}
if cfg["share"] != "disabled" {
t.Errorf("share should be disabled, got %v", cfg["share"])
}
instructions, ok := cfg["instructions"].([]any)
if !ok || len(instructions) == 0 {
t.Fatal("instructions should be a non-empty array")
}
if instructions[0] != ".yao/assistants/test-assistant/system-prompt.md" {
t.Errorf("instructions[0] = %q, want .yao/assistants/test-assistant/system-prompt.md", instructions[0])
}
watcher, ok := cfg["watcher"].(map[string]any)
if !ok {
t.Fatal("watcher should be a map")
}
ignore, ok := watcher["ignore"].([]any)
if !ok || len(ignore) < 2 {
t.Errorf("watcher.ignore should have at least 2 entries, got %v", ignore)
}
}
func TestBuildOpenCodeConfig_NoAssistantID(t *testing.T) {
req := &types.PrepareRequest{
AssistantID: "",
Config: &types.SandboxConfig{},
}
data := buildOpenCodeConfig(req, nil)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
instructions := cfg["instructions"].([]any)
if instructions[0] != ".opencode/system-prompt.md" {
t.Errorf("instructions[0] = %q, want .opencode/system-prompt.md", instructions[0])
}
}
func TestBuildOpenCodeConfig_WithMCP(t *testing.T) {
req := &types.PrepareRequest{
AssistantID: "test",
Config: &types.SandboxConfig{},
}
servers := []types.MCPServer{
{ServerID: "my-server"},
{ServerID: "another-server"},
}
data := buildOpenCodeConfig(req, servers)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatal("mcp should be a map")
}
if _, exists := mcp["my-server"]; !exists {
t.Error("mcp should contain my-server")
}
if _, exists := mcp["another-server"]; !exists {
t.Error("mcp should contain another-server")
}
}
func TestBuildMCPConfig_Default(t *testing.T) {
result := buildMCPConfig(nil)
if _, ok := result["yao"]; !ok {
t.Error("empty server list should produce default 'yao' entry")
}
}
func TestBuildMCPConfig_WithServers(t *testing.T) {
servers := []types.MCPServer{
{ServerID: "server-a"},
{ServerID: "server-b"},
{ServerID: ""},
}
result := buildMCPConfig(servers)
if _, ok := result["server-a"]; !ok {
t.Error("should contain server-a")
}
if _, ok := result["server-b"]; !ok {
t.Error("should contain server-b")
}
if len(result) != 2 {
t.Errorf("should only have 2 entries (empty ID skipped), got %d", len(result))
}
serverA := result["server-a"].(map[string]any)
cmd := serverA["command"].([]string)
if len(cmd) != 3 || cmd[0] != "tai" || cmd[1] != "mcp" || cmd[2] != "server-a" {
t.Errorf("command should be [tai mcp server-a], got %v", cmd)
}
}

View file

@ -1,540 +0,0 @@
package opencode
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
)
// streamParser handles OpenCode's JSONL output (--format json).
//
// OpenCode emits one JSON object per line with a "type" field. Unlike Claude's
// streaming events which provide incremental deltas, OpenCode events arrive at
// completion boundaries:
//
// step_start agent step begins (contains sessionID, part metadata)
// text completed text block (full text, only emitted when part.time.end is set)
// tool_use completed tool call (status: completed|error, contains full input/output)
// step_finish step ended (reason: stop|tool-calls; includes token/cost info)
// reasoning thinking/reasoning output
// error error event
//
// Because tool_use events only arrive after the tool finishes, the parser
// emits a "running" execute chunk at step_start so the frontend has immediate
// feedback that work is in progress. It also tracks intermediate tool_use
// events (status=completed) so they are shown as soon as they arrive, even
// before step_finish.
type streamParser struct {
handler message.StreamFunc
completed bool
toolIndex int
// textActive tracks whether a text message group is currently open.
textActive bool
textMsgID string
// pendingExec tracks tools that were announced at step_start but haven't
// received their completed tool_use event yet. Key = step ID.
pendingExec map[string]string // stepID -> msgID
}
func newStreamParser(handler message.StreamFunc) *streamParser {
return &streamParser{
handler: handler,
pendingExec: make(map[string]string),
}
}
func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
doneParsing := make(chan struct{})
defer close(doneParsing)
go func() {
select {
case <-ctx.Done():
stdout.Close()
case <-doneParsing:
}
}()
reader := bufio.NewReaderSize(stdout, 64*1024)
startTime := time.Now()
lineCount := 0
lastHeartbeat := time.Now()
log.Trace("[opencode-parse] stream started")
for {
line, skipped, err := shared.ReadJSONLine(reader)
if err == io.EOF {
break
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
if skipped {
log.Warn("[opencode-parse] skipped oversized JSONL line (>%dMB)", shared.MaxLineSize/1024/1024)
continue
}
if len(line) == 0 {
continue
}
lineCount++
if time.Since(lastHeartbeat) > 30*time.Second {
log.Trace("[opencode-parse] heartbeat: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
lastHeartbeat = time.Now()
}
var msg map[string]any
if err := json.Unmarshal(line, &msg); err != nil {
if len(line) > 200 {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line len=%d)", err, len(line))
} else {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line=%q)", err, string(line))
}
continue
}
msgType, _ := msg["type"].(string)
switch msgType {
case "step_start":
if p.handleStepStart(msg) {
return nil
}
case "text":
if p.handleText(msg) {
return nil
}
case "tool_use":
if p.handleToolUse(msg) {
return nil
}
case "step_finish":
if err := p.handleStepFinish(msg); err != nil {
return err
}
if p.completed {
log.Trace("[opencode-parse] stream completed: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
return nil
}
log.Trace("[opencode-parse] step_finish (intermediate, reason!=stop): continuing parse loop")
case "reasoning":
p.handleReasoning(msg)
case "error":
log.Trace("[opencode-parse] error event: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
return p.handleError(msg)
default:
log.Trace("[opencode-parse] unknown event type: %s", msgType)
}
}
log.Trace("[opencode-parse] stream ended: lines=%d elapsed=%v completed=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed)
return nil
}
// --- Message lifecycle helpers (mirroring Claude parser) ---
func (p *streamParser) beginMessageWithID(id, msgType string) (stopped bool) {
startData := message.EventMessageStartData{
MessageID: id,
Type: msgType,
Timestamp: time.Now().UnixMilli(),
}
sd, _ := json.Marshal(startData)
return p.handler != nil && p.handler(message.ChunkMessageStart, sd) != 0
}
func (p *streamParser) beginMessage(msgType string) (messageID string, stopped bool) {
id := fmt.Sprintf("sandbox-%s-%s", msgType, message.GenerateNanoID())
return id, p.beginMessageWithID(id, msgType)
}
func (p *streamParser) endMessage() {
if p.handler != nil {
p.handler(message.ChunkMessageEnd, nil)
}
}
func (p *streamParser) closeTextMessage() {
if p.textActive {
p.endMessage()
p.textActive = false
p.textMsgID = ""
}
}
func (p *streamParser) ensureTextMessage() (stopped bool) {
if !p.textActive {
id, stopped := p.beginMessage("text")
if stopped {
return true
}
p.textActive = true
p.textMsgID = id
}
return false
}
func (p *streamParser) emitText(text string) (stopped bool) {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return p.handler != nil && p.handler(message.ChunkText, []byte(text)) != 0
}
func (p *streamParser) emitExecute(props map[string]any) (stopped bool) {
data, _ := json.Marshal(props)
return p.handler != nil && p.handler(message.ChunkExecute, data) != 0
}
func (p *streamParser) emitMetadata(data map[string]any) {
if p.handler == nil {
return
}
encoded, _ := json.Marshal(data)
p.handler(message.ChunkMetadata, encoded)
}
// --- Event handlers ---
func (p *streamParser) handleStepStart(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
sessionID, _ := msg["sessionID"].(string)
meta := map[string]any{"opencode_session_id": sessionID}
part, _ := msg["part"].(map[string]any)
if part != nil {
if id, ok := part["id"].(string); ok {
meta["step_id"] = id
}
}
p.emitMetadata(meta)
// Record the step ID so that a subsequent tool_use can be correlated.
// We do NOT emit a "running" execute here because we can't tell yet
// whether the step will involve tool calls or just text output. For
// pure text responses an empty execute widget would look wrong. The
// "running" indicator is instead emitted lazily in handleToolUse when
// the first tool event actually arrives.
if part != nil {
if stepID, ok := part["id"].(string); ok && stepID != "" {
p.pendingExec[stepID] = "" // placeholder — msgID assigned later
}
}
return false
}
func (p *streamParser) handleText(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return false
}
content, _ := part["text"].(string)
if content == "" {
content, _ = part["content"].(string)
}
if content == "" {
return false
}
// Close any pending execute message before text output.
p.closePendingExec(msg)
if p.ensureTextMessage() {
return true
}
if p.emitText(content) {
p.closeTextMessage()
return true
}
p.closeTextMessage()
return false
}
func (p *streamParser) handleToolUse(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return false
}
state, _ := part["state"].(map[string]any)
if state == nil {
return false
}
toolName, _ := part["toolName"].(string)
if toolName == "" {
toolName, _ = part["tool"].(string)
}
toolID, _ := part["toolCallId"].(string)
if toolID == "" {
toolID = fmt.Sprintf("oc-tool_%d_%d", p.toolIndex, time.Now().UnixNano())
}
p.toolIndex++
status, _ := state["status"].(string)
isError := status == "error"
p.closeTextMessage()
// Clear any pending step placeholder (no msgID to reuse since we didn't
// emit anything at step_start).
p.closePendingExec(msg)
// Build execute properties.
execProps := map[string]any{
"tool": toolName,
"tool_id": toolID,
"status": status,
"runner": "opencode-cli",
}
if isError {
execProps["is_error"] = true
}
var inputStr string
if input, ok := state["input"].(string); ok && input != "" {
inputStr = input
} else if inputObj, ok := state["input"].(map[string]any); ok {
inputJSON, _ := json.Marshal(inputObj)
inputStr = string(inputJSON)
}
if inputStr != "" {
execProps["input"] = json.RawMessage(inputStr)
summary := extractSummary(toolName, inputStr)
if summary != "" {
execProps["summary"] = summary
}
}
if output, ok := state["output"].(string); ok && output != "" {
execProps["output"] = output
} else if outputObj := state["output"]; outputObj != nil {
execProps["output"] = outputObj
}
// Single message group with the complete tool result.
if _, stopped := p.beginMessage("execute"); stopped {
return true
}
if p.emitExecute(execProps) {
p.endMessage()
return true
}
p.endMessage()
return false
}
func (p *streamParser) handleStepFinish(msg map[string]any) error {
p.closeTextMessage()
p.closePendingExec(msg)
part, _ := msg["part"].(map[string]any)
reason := ""
if part != nil {
reason, _ = part["reason"].(string)
if reason == "" {
reason, _ = part["finishReason"].(string)
}
}
if reason == "stop" || reason == "end_turn" {
p.completed = true
if p.handler != nil {
finishMeta := map[string]any{
"result_summary": map[string]any{
"finish_reason": reason,
},
}
// Include token/cost info if available.
if part != nil {
if tokens, ok := part["tokens"].(map[string]any); ok {
finishMeta["result_summary"].(map[string]any)["tokens"] = tokens
}
if cost, ok := part["cost"]; ok {
finishMeta["result_summary"].(map[string]any)["cost"] = cost
}
}
p.emitMetadata(finishMeta)
}
return nil
}
// reason == "tool-calls" or other intermediate reasons: not final.
// Emit metadata so the frontend knows a new round is starting.
if p.handler != nil && reason != "" {
p.emitMetadata(map[string]any{
"step_transition": map[string]any{
"reason": reason,
},
})
}
return nil
}
func (p *streamParser) handleReasoning(msg map[string]any) {
if p.handler == nil {
return
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return
}
content, _ := part["text"].(string)
if content == "" {
content, _ = part["content"].(string)
}
if content == "" {
return
}
p.emitMetadata(map[string]any{
"reasoning": content,
})
}
func (p *streamParser) handleError(msg map[string]any) error {
p.closePendingExec(msg)
var errMsg string
if part, ok := msg["part"].(map[string]any); ok {
errMsg, _ = part["error"].(string)
if errMsg == "" {
errMsg, _ = part["message"].(string)
}
}
if errMsg == "" {
switch e := msg["error"].(type) {
case string:
errMsg = e
case map[string]any:
errMsg, _ = e["message"].(string)
if errMsg == "" {
if data, ok := e["data"].(map[string]any); ok {
errMsg, _ = data["message"].(string)
}
}
if errMsg == "" {
name, _ := e["name"].(string)
if name != "" {
errMsg = name
}
}
}
}
if errMsg == "" {
errMsg = "unknown OpenCode error"
}
if p.handler != nil {
p.handler(message.ChunkError, []byte(errMsg))
}
return fmt.Errorf("OpenCode CLI error: %s", errMsg)
}
// --- Pending exec helpers ---
// closePendingExec clears step placeholders recorded at step_start.
func (p *streamParser) closePendingExec(msg map[string]any) {
if len(p.pendingExec) == 0 {
return
}
// Best-effort: clear matching step or all if we can't match.
part, _ := msg["part"].(map[string]any)
if part != nil {
for _, key := range []string{"id", "messageID"} {
if id, _ := part[key].(string); id != "" {
if _, ok := p.pendingExec[id]; ok {
delete(p.pendingExec, id)
return
}
}
}
}
// Fallback: clear the single pending entry (most common).
if len(p.pendingExec) == 1 {
for k := range p.pendingExec {
delete(p.pendingExec, k)
}
}
}
// --- Utility ---
// extractSummary builds a short human-readable summary from the tool input.
func extractSummary(toolName string, inputJSON string) string {
if inputJSON == "" {
return ""
}
var obj map[string]any
if err := json.Unmarshal([]byte(inputJSON), &obj); err != nil {
return ""
}
switch strings.ToLower(toolName) {
case "bash", "execute":
if cmd, ok := obj["command"].(string); ok {
return truncate(cmd, 80)
}
case "write", "create":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "read":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "edit":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
}
for _, key := range []string{"path", "file_path", "command", "url", "query"} {
if v, ok := obj[key].(string); ok {
return truncate(v, 80)
}
}
return ""
}
func truncate(s string, max int) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > max {
return s[:max] + "..."
}
return s
}

View file

@ -1,528 +0,0 @@
package opencode
import (
"context"
"encoding/json"
"io"
"strings"
"sync"
"testing"
"github.com/yaoapp/yao/agent/output/message"
)
type chunkRecord struct {
eventType message.StreamChunkType
data string
}
func collectHandler(records *[]chunkRecord, mu *sync.Mutex) message.StreamFunc {
return func(chunkType message.StreamChunkType, data []byte) int {
mu.Lock()
defer mu.Unlock()
*records = append(*records, chunkRecord{eventType: chunkType, data: string(data)})
return 0
}
}
func makeJSONL(events ...map[string]any) string {
var lines []string
for _, e := range events {
data, _ := json.Marshal(e)
lines = append(lines, string(data))
}
return strings.Join(lines, "\n") + "\n"
}
func TestParse_StepStartEmitsMetadata(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "step_start",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"id": "prt_abc",
"type": "step-start",
"messageID": "msg_xyz",
"sessionID": "ses_123",
},
},
map[string]any{
"type": "text",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{"text": "Hi!"},
},
map[string]any{
"type": "step_finish",
"timestamp": 3000,
"sessionID": "ses_123",
"part": map[string]any{"reason": "stop"},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
mu.Lock()
defer mu.Unlock()
// step_start should only emit metadata (no execute widget for pure text).
hasMeta := false
hasRunningExec := false
for _, r := range records {
if r.eventType == message.ChunkMetadata {
var meta map[string]any
json.Unmarshal([]byte(r.data), &meta)
if _, ok := meta["opencode_session_id"]; ok {
hasMeta = true
}
}
if r.eventType == message.ChunkExecute {
var props map[string]any
json.Unmarshal([]byte(r.data), &props)
if props["status"] == "running" {
hasRunningExec = true
}
}
}
if !hasMeta {
t.Error("step_start should emit metadata with session ID")
}
if hasRunningExec {
t.Error("step_start should NOT emit a running execute for pure text steps")
}
}
func TestParse_TextEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "text",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "text",
"content": "Hello, world!",
},
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if !parser.completed {
t.Error("parser should be completed")
}
mu.Lock()
defer mu.Unlock()
hasText := false
for _, r := range records {
if r.eventType == message.ChunkText {
hasText = true
if r.data != "Hello, world!" {
t.Errorf("text data = %q, want 'Hello, world!'", r.data)
}
}
}
if !hasText {
t.Error("should have emitted a ChunkText event")
}
}
func TestParse_ToolUseEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "tool_use",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "tool",
"toolName": "bash",
"toolCallId": "call_abc",
"state": map[string]any{
"status": "completed",
"input": `{"command":"ls -la"}`,
"output": "file1.txt\nfile2.txt",
},
},
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
mu.Lock()
defer mu.Unlock()
hasCompletedExec := false
for _, r := range records {
if r.eventType == message.ChunkExecute {
var props map[string]any
json.Unmarshal([]byte(r.data), &props)
if props["tool"] == "bash" && props["status"] == "completed" {
hasCompletedExec = true
if props["runner"] != "opencode-cli" {
t.Errorf("runner = %v, want opencode-cli", props["runner"])
}
}
}
}
if !hasCompletedExec {
t.Error("should have emitted a ChunkExecute with tool=bash status=completed")
}
}
func TestParse_ErrorEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "error",
"timestamp": 1000,
"sessionID": "ses_123",
"error": "something went wrong",
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err == nil {
t.Fatal("expected error from parse")
}
if !strings.Contains(err.Error(), "something went wrong") {
t.Errorf("error should contain message, got: %v", err)
}
mu.Lock()
defer mu.Unlock()
hasError := false
for _, r := range records {
if r.eventType == message.ChunkError {
hasError = true
}
}
if !hasError {
t.Error("should have emitted a ChunkError event")
}
}
func TestParse_ErrorEvent_Nested(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "error",
"timestamp": 1000,
"sessionID": "ses_123",
"error": map[string]any{
"name": "APIError",
"data": map[string]any{
"message": "Authentication Fails",
"statusCode": 401,
},
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err == nil {
t.Fatal("expected error from parse")
}
if !strings.Contains(err.Error(), "Authentication Fails") {
t.Errorf("error should contain nested message, got: %v", err)
}
}
func TestParse_StepFinishToolCalls(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "step_finish",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "tool-calls",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if parser.completed {
t.Error("tool-calls finish reason should NOT mark as completed")
}
mu.Lock()
defer mu.Unlock()
hasTransition := false
for _, r := range records {
if r.eventType == message.ChunkMetadata {
var meta map[string]any
json.Unmarshal([]byte(r.data), &meta)
if _, ok := meta["step_transition"]; ok {
hasTransition = true
}
}
}
if !hasTransition {
t.Error("tool-calls step_finish should emit step_transition metadata")
}
}
func TestParse_MultiStepToolThenText(t *testing.T) {
input := makeJSONL(
map[string]any{"type": "step_start", "sessionID": "ses_1", "part": map[string]any{"id": "step-1"}},
map[string]any{
"type": "tool_use", "sessionID": "ses_1",
"part": map[string]any{
"toolName": "bash", "toolCallId": "call_1",
"state": map[string]any{"status": "completed", "input": `{"command":"echo hi"}`, "output": "hi"},
},
},
map[string]any{
"type": "step_finish", "sessionID": "ses_1",
"part": map[string]any{"finishReason": "tool-calls"},
},
map[string]any{"type": "step_start", "sessionID": "ses_1", "part": map[string]any{"id": "step-2"}},
map[string]any{
"type": "text", "sessionID": "ses_1",
"part": map[string]any{"text": "The output was: hi"},
},
map[string]any{
"type": "step_finish", "sessionID": "ses_1",
"part": map[string]any{"reason": "stop"},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if !parser.completed {
t.Error("multi-step stream should complete on final stop")
}
mu.Lock()
defer mu.Unlock()
var (
completedCount int
hasText bool
)
for _, r := range records {
if r.eventType == message.ChunkExecute {
var props map[string]any
json.Unmarshal([]byte(r.data), &props)
if props["status"] == "completed" {
completedCount++
}
}
if r.eventType == message.ChunkText && r.data == "The output was: hi" {
hasText = true
}
}
if completedCount < 1 {
t.Error("should have at least 1 completed exec from tool_use")
}
if !hasText {
t.Error("should have emitted ChunkText for final text reply")
}
}
func TestParse_ReasoningEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "reasoning",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "reasoning",
"text": "Let me think about this...",
},
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
mu.Lock()
defer mu.Unlock()
hasReasoning := false
for _, r := range records {
if r.eventType == message.ChunkMetadata {
var meta map[string]any
json.Unmarshal([]byte(r.data), &meta)
if _, ok := meta["reasoning"]; ok {
hasReasoning = true
}
}
}
if !hasReasoning {
t.Error("should have emitted reasoning metadata")
}
}
func TestParse_UnknownEventType(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "future_event_type",
"timestamp": 1000,
"sessionID": "ses_123",
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("unknown event type should not cause error: %v", err)
}
if !parser.completed {
t.Error("parser should still complete")
}
}
func TestExtractSummary(t *testing.T) {
cases := []struct {
tool, input, want string
}{
{"bash", `{"command":"ls -la /tmp"}`, "ls -la /tmp"},
{"read", `{"file_path":"main.go"}`, "main.go"},
{"write", `{"file_path":"output.txt"}`, "output.txt"},
{"unknown", `{"query":"select * from users"}`, "select * from users"},
{"bash", `{"no_command_key": true}`, ""},
{"bash", `invalid json`, ""},
{"bash", "", ""},
}
for _, tc := range cases {
got := extractSummary(tc.tool, tc.input)
if got != tc.want {
t.Errorf("extractSummary(%q, %q) = %q, want %q", tc.tool, tc.input, got, tc.want)
}
}
}
func TestTruncate(t *testing.T) {
short := "hello"
if truncate(short, 80) != "hello" {
t.Error("short string should not be truncated")
}
long := strings.Repeat("a", 100)
result := truncate(long, 80)
if len(result) != 83 { // 80 + "..."
t.Errorf("truncated len = %d, want 83", len(result))
}
withNewlines := "line1\nline2\nline3"
result = truncate(withNewlines, 80)
if strings.Contains(result, "\n") {
t.Error("truncate should replace newlines with spaces")
}
}

View file

@ -1,76 +0,0 @@
package opencode
import (
"fmt"
"strings"
)
// windowsPlatform implements the platform interface for Windows containers.
// Aligned with the Claude runner's plat_win.go: uses PowerShell for shell
// commands, backslash path joins, and full HOME-related env vars.
type windowsPlatform struct {
workDir string
shell string
}
func newWindowsPlatform(workDir, shell string) *windowsPlatform {
if shell == "" {
shell = "pwsh"
}
return &windowsPlatform{workDir: workDir, shell: shell}
}
func (w *windowsPlatform) OS() string { return "windows" }
func (w *windowsPlatform) Shell() string { return w.shell }
func (w *windowsPlatform) PathJoin(parts ...string) string {
return strings.Join(parts, `\`)
}
// HomeEnv sets HOME, USERPROFILE, HOMEDRIVE, and HOMEPATH so that Git for
// Windows, npm, and other tools resolve ~ correctly inside the container.
// See Claude runner plat_win.go and anthropics/claude-code#13138.
func (w *windowsPlatform) HomeEnv(workDir string) map[string]string {
env := map[string]string{
"HOME": workDir,
"USERPROFILE": workDir,
}
if len(workDir) >= 2 && workDir[1] == ':' {
env["HOMEDRIVE"] = workDir[:2]
env["HOMEPATH"] = workDir[2:]
}
return env
}
func (w *windowsPlatform) ShellCmd(script string) []string {
shell := strings.ToLower(w.shell)
switch shell {
case "pwsh":
return []string{"pwsh", "-NoProfile", "-Command", script}
case "powershell":
return []string{"powershell", "-NoProfile", "-Command", script}
case "cmd.exe", "cmd":
return []string{"cmd.exe", "/C", script}
default:
return []string{"pwsh", "-NoProfile", "-Command", script}
}
}
func (w *windowsPlatform) KillCmd(pattern string) []string {
script := fmt.Sprintf(
"Get-Process -ErrorAction SilentlyContinue | Where-Object {$_.ProcessName -like '*%s*'} | "+
"ForEach-Object { taskkill /F /T /PID $_.Id 2>$null }; "+
"Get-Process -ErrorAction SilentlyContinue | Where-Object {$_.ProcessName -like '*%s*'} | "+
"Stop-Process -Force -ErrorAction SilentlyContinue",
pattern, pattern)
return w.ShellCmd(script)
}
func (w *windowsPlatform) KillSessionCmd(sessionName string) []string {
script := fmt.Sprintf(
"Get-Process -ErrorAction SilentlyContinue | "+
"Where-Object { $_.CommandLine -like '*%s*' } | "+
"ForEach-Object { taskkill /F /T /PID $_.Id 2>$null }",
sessionName)
return w.ShellCmd(script)
}

View file

@ -1,66 +0,0 @@
package opencode
import (
"fmt"
"path"
"strings"
infra "github.com/yaoapp/yao/sandbox/v2"
)
// platform encapsulates OS-dependent behaviors for the target environment.
type platform interface {
OS() string
Shell() string
HomeEnv(workDir string) map[string]string
PathJoin(parts ...string) string
ShellCmd(script string) []string
KillCmd(pattern string) []string
KillSessionCmd(sessionName string) []string
}
type posixBase struct {
os string
workDir string
shell string
}
func (b *posixBase) OS() string { return b.os }
func (b *posixBase) Shell() string { return b.shell }
func (b *posixBase) PathJoin(parts ...string) string { return path.Join(parts...) }
func (b *posixBase) HomeEnv(workDir string) map[string]string {
return map[string]string{"HOME": workDir}
}
func (b *posixBase) ShellCmd(script string) []string {
return []string{"bash", "-c", script}
}
func (b *posixBase) KillCmd(pattern string) []string {
return []string{"sh", "-c", fmt.Sprintf("pkill -f '%s' || true", pattern)}
}
func (b *posixBase) KillSessionCmd(sessionName string) []string {
return []string{"sh", "-c", fmt.Sprintf("pkill -9 -f '%s' || true", sessionName)}
}
func resolvePlatform(computer infra.Computer) platform {
sys := computer.ComputerInfo().System
osName := strings.ToLower(sys.OS)
workDir := computer.GetWorkDir()
shell := sys.Shell
if osName == "windows" {
return newWindowsPlatform(workDir, shell)
}
base := posixBase{os: osName, workDir: workDir, shell: shell}
if base.shell == "" {
base.shell = "bash"
}
if base.os == "" {
base.os = "linux"
}
return &base
}

View file

@ -1,217 +0,0 @@
package opencode
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// POSIX posixBase tests
// ---------------------------------------------------------------------------
func TestPosixBase_Accessors(t *testing.T) {
b := &posixBase{os: "linux", workDir: "/workspace", shell: "bash"}
assert.Equal(t, "linux", b.OS())
assert.Equal(t, "bash", b.Shell())
assert.Equal(t, "/workspace/.yao/config", b.PathJoin("/workspace", ".yao", "config"))
}
func TestPosixBase_HomeEnv(t *testing.T) {
b := &posixBase{}
env := b.HomeEnv("/workspace")
assert.Equal(t, "/workspace", env["HOME"])
assert.Len(t, env, 1)
}
func TestPosixBase_ShellCmd(t *testing.T) {
b := &posixBase{}
cmd := b.ShellCmd("echo hello")
assert.Equal(t, []string{"bash", "-c", "echo hello"}, cmd)
}
func TestPosixBase_KillCmd(t *testing.T) {
b := &posixBase{}
cmd := b.KillCmd("opencode")
require.Len(t, cmd, 3)
assert.Equal(t, "sh", cmd[0])
assert.Contains(t, cmd[2], "pkill")
assert.Contains(t, cmd[2], "opencode")
}
func TestPosixBase_KillSessionCmd(t *testing.T) {
b := &posixBase{}
cmd := b.KillSessionCmd("yao-oc-session123")
require.Len(t, cmd, 3)
assert.Equal(t, "sh", cmd[0])
assert.Contains(t, cmd[2], "pkill -9 -f")
assert.Contains(t, cmd[2], "yao-oc-session123")
}
// ---------------------------------------------------------------------------
// Windows windowsPlatform tests
// ---------------------------------------------------------------------------
func TestWindows_NewDefaults(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "")
assert.Equal(t, "pwsh", w.Shell())
}
func TestWindows_Accessors(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "pwsh")
assert.Equal(t, "windows", w.OS())
assert.Equal(t, "pwsh", w.Shell())
}
func TestWindows_PathJoin(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "pwsh")
assert.Equal(t, `C:\workspace\.yao\config`, w.PathJoin(`C:\workspace`, ".yao", "config"))
assert.Equal(t, `a\b\c`, w.PathJoin("a", "b", "c"))
}
func TestWindows_HomeEnv(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "pwsh")
env := w.HomeEnv(`C:\workspace`)
assert.Equal(t, `C:\workspace`, env["HOME"])
assert.Equal(t, `C:\workspace`, env["USERPROFILE"])
assert.Equal(t, `C:`, env["HOMEDRIVE"])
assert.Equal(t, `\workspace`, env["HOMEPATH"])
assert.Len(t, env, 4)
}
func TestWindows_HomeEnv_NoDrive(t *testing.T) {
w := newWindowsPlatform("X", "pwsh")
env := w.HomeEnv("X")
assert.Equal(t, "X", env["HOME"])
assert.Equal(t, "X", env["USERPROFILE"])
_, hasDrive := env["HOMEDRIVE"]
assert.False(t, hasDrive, "should not set HOMEDRIVE for path without drive letter")
assert.Len(t, env, 2)
}
func TestWindows_ShellCmd_Pwsh(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "pwsh")
cmd := w.ShellCmd("echo hello")
assert.Equal(t, []string{"pwsh", "-NoProfile", "-Command", "echo hello"}, cmd)
}
func TestWindows_ShellCmd_Powershell(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "powershell")
cmd := w.ShellCmd("echo hello")
assert.Equal(t, "powershell", cmd[0])
assert.Equal(t, "-NoProfile", cmd[1])
}
func TestWindows_ShellCmd_Cmd(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "cmd.exe")
cmd := w.ShellCmd("echo hello")
assert.Equal(t, []string{"cmd.exe", "/C", "echo hello"}, cmd)
}
func TestWindows_ShellCmd_Default(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "unknown-shell")
cmd := w.ShellCmd("echo")
assert.Equal(t, "pwsh", cmd[0], "unknown shell should fall back to pwsh")
}
func TestWindows_KillCmd(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "pwsh")
cmd := w.KillCmd("opencode")
require.Len(t, cmd, 4)
assert.Equal(t, "pwsh", cmd[0])
assert.Contains(t, cmd[3], "opencode")
assert.Contains(t, cmd[3], "taskkill")
assert.Contains(t, cmd[3], "Stop-Process")
}
func TestWindows_KillSessionCmd(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "pwsh")
cmd := w.KillSessionCmd("yao-oc-session123")
require.Len(t, cmd, 4)
assert.Equal(t, "pwsh", cmd[0])
assert.Contains(t, cmd[3], "CommandLine")
assert.Contains(t, cmd[3], "yao-oc-session123")
assert.Contains(t, cmd[3], "taskkill")
}
// ---------------------------------------------------------------------------
// shellQuote / shellQuoteForPlatform tests
// ---------------------------------------------------------------------------
func TestShellQuote_POSIX(t *testing.T) {
result := shellQuote("opencode", "run", "--format", "json")
assert.Equal(t, "opencode run --format json", result)
}
func TestShellQuote_POSIX_SpecialChars(t *testing.T) {
result := shellQuote("opencode", "run", "hello world", "it's")
assert.Contains(t, result, "'hello world'")
assert.Contains(t, result, `'\''`)
}
func TestShellQuotePowerShell(t *testing.T) {
result := shellQuotePowerShell("opencode", "run", "--format", "json")
assert.Equal(t, "opencode run --format json", result)
}
func TestShellQuotePowerShell_SpecialChars(t *testing.T) {
result := shellQuotePowerShell("opencode", "run", "hello world", "it's")
assert.Contains(t, result, "'hello world'")
assert.Contains(t, result, "'it''s'")
assert.NotContains(t, result, `'\''`, "PowerShell should use '' not '\\''")
}
func TestShellQuoteForPlatform_POSIX(t *testing.T) {
p := &posixBase{os: "linux"}
result := shellQuoteForPlatform(p, "opencode", "it's")
assert.Contains(t, result, `'\''`)
}
func TestShellQuoteForPlatform_Windows(t *testing.T) {
p := newWindowsPlatform(`C:\ws`, "pwsh")
result := shellQuoteForPlatform(p, "opencode", "it's")
assert.Contains(t, result, "''s'")
assert.NotContains(t, result, `'\''`)
}
// ---------------------------------------------------------------------------
// buildSandboxEnvPrompt tests
// ---------------------------------------------------------------------------
func TestBuildSandboxEnvPrompt_Linux(t *testing.T) {
p := &posixBase{os: "linux", shell: "bash"}
prompt := buildSandboxEnvPrompt(p, "/workspace")
assert.Contains(t, prompt, "linux")
assert.Contains(t, prompt, "bash")
assert.Contains(t, prompt, "/workspace")
}
func TestBuildSandboxEnvPrompt_Windows(t *testing.T) {
p := newWindowsPlatform(`C:\workspace`, "pwsh")
prompt := buildSandboxEnvPrompt(p, `C:\workspace`)
assert.Contains(t, prompt, "windows")
assert.Contains(t, prompt, "pwsh")
assert.Contains(t, prompt, `C:\workspace`)
}
// ---------------------------------------------------------------------------
// Vision read.ts copy step generation logic
// ---------------------------------------------------------------------------
func TestVisionCopyStep_Linux(t *testing.T) {
p := &posixBase{os: "linux", workDir: "/workspace", shell: "bash"}
cmd := visionCopyCmd(p)
assert.Contains(t, cmd, "mkdir -p")
assert.Contains(t, cmd, "opencode-tools")
assert.NotContains(t, cmd, "PowerShell")
}
func TestVisionCopyStep_Windows(t *testing.T) {
p := newWindowsPlatform(`C:\workspace`, "pwsh")
cmd := visionCopyCmd(p)
assert.Contains(t, cmd, "Test-Path")
assert.Contains(t, cmd, "Copy-Item")
assert.Contains(t, cmd, `opencode-tools`)
assert.NotContains(t, cmd, "mkdir -p")
}

View file

@ -1,459 +0,0 @@
package opencode
import (
"encoding/json"
"testing"
"github.com/yaoapp/gou/connector"
gouTypes "github.com/yaoapp/gou/types"
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/xun/dbal/schema"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
type fakeConn struct {
id string
typ int
settings map[string]interface{}
}
func (f *fakeConn) Register(string, string, []byte) error { return nil }
func (f *fakeConn) Query() (query.Query, error) { return nil, nil }
func (f *fakeConn) Schema() (schema.Schema, error) { return nil, nil }
func (f *fakeConn) Close() error { return nil }
func (f *fakeConn) ID() string { return f.id }
func (f *fakeConn) Is(t int) bool { return f.typ == t }
func (f *fakeConn) Setting() map[string]interface{} { return f.settings }
func (f *fakeConn) GetMetaInfo() gouTypes.MetaInfo { return gouTypes.MetaInfo{} }
func newFakeOpenAI(id, host, model, key string) *fakeConn {
return &fakeConn{
id: id,
typ: connector.OPENAI,
settings: map[string]interface{}{
"host": host,
"model": model,
"key": key,
},
}
}
func newFakeAnthropic(id, host, model, key string) *fakeConn {
return &fakeConn{
id: id,
typ: connector.ANTHROPIC,
settings: map[string]interface{}{
"host": host,
"model": model,
"key": key,
},
}
}
// ---------------------------------------------------------------------------
// injectRoleProviders tests
// ---------------------------------------------------------------------------
func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis")
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"default": primaryConn,
"vision": visionConn,
},
}
injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"]
if !ok {
t.Fatal("should have injected 'vision' provider block")
}
vBlock := visionBlock.(map[string]any)
models := vBlock["models"].(map[string]any)
if _, ok := models["gpt-4o-mini"]; !ok {
t.Fatal("vision provider should contain gpt-4o-mini model")
}
enabled := cfg["enabled_providers"].([]string)
hasVision := false
for _, e := range enabled {
if e == "vision" {
hasVision = true
}
}
if !hasVision {
t.Error("enabled_providers should contain 'vision'")
}
}
func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) {
visionConn := newFakeOpenAI("vis", "", "gpt-4o-mini", "sk-oai")
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"default": primaryConn,
"vision": visionConn,
},
}
injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any)
if _, ok := providers["vision"]; !ok {
t.Fatal("should have separate 'vision' provider (different host from primary)")
}
}
func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
lightConn := newFakeOpenAI("moonshot", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"default": primaryConn,
"light": lightConn,
},
}
injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any)
if _, ok := providers["light"]; !ok {
t.Fatal("light role should have its own provider block when host differs from primary")
}
smallModel, ok := cfg["small_model"].(string)
if !ok || smallModel == "" {
t.Fatal("small_model should be set for light role")
}
if smallModel != "light/moonshot-v1-8k" {
t.Errorf("small_model = %q, want 'light/moonshot-v1-8k'", smallModel)
}
enabled := cfg["enabled_providers"].([]string)
hasLight := false
for _, e := range enabled {
if e == "light" {
hasLight = true
}
}
if !hasLight {
t.Error("enabled_providers should contain 'light'")
}
}
func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) {
lightConn := newFakeOpenAI("ds-light", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
primaryProviderID, primaryCfg, modelStr := buildProviderConfig(primaryConn)
cfg := map[string]any{
"provider": map[string]any{primaryProviderID: primaryCfg},
"model": modelStr,
"enabled_providers": []string{primaryProviderID},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"default": primaryConn,
"light": lightConn,
},
}
injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any)
if _, ok := providers["light"]; ok {
t.Error("light should merge into primary block when same host, not create separate block")
}
customBlock := providers["custom"].(map[string]any)
models := customBlock["models"].(map[string]any)
if _, ok := models["deepseek-chat"]; !ok {
t.Error("light model should be merged into primary's 'custom' provider models")
}
smallModel := cfg["small_model"].(string)
if smallModel != "custom/deepseek-chat" {
t.Errorf("small_model = %q, want 'custom/deepseek-chat'", smallModel)
}
}
func TestInjectRoleProviders_NoConnectors(t *testing.T) {
cfg := map[string]any{
"provider": map[string]any{"openai": map[string]any{}},
"model": "openai/gpt-4o",
"enabled_providers": []string{"openai"},
}
primaryConn := newFakeOpenAI("primary", "", "gpt-4o", "sk-oai")
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{},
}
injectRoleProviders(cfg, req, primaryConn)
enabled := cfg["enabled_providers"].([]string)
if len(enabled) != 1 || enabled[0] != "openai" {
t.Errorf("enabled_providers should be unchanged: %v", enabled)
}
}
func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
visionConn := newFakeAnthropic("claude-vis", "https://api.anthropic.com", "claude-sonnet-4-5-20250929", "sk-ant")
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"default": primaryConn,
"vision": visionConn,
},
}
injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"]
if !ok {
t.Fatal("should inject 'vision' provider for Anthropic connector")
}
vBlock := visionBlock.(map[string]any)
if vBlock["npm"] != nil {
t.Error("Anthropic provider should NOT have npm field")
}
}
// ---------------------------------------------------------------------------
// buildEnv role injection tests
// ---------------------------------------------------------------------------
func TestInjectRoleEnvVars_Vision(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis-key")
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"vision": visionConn,
},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
if env["YAO_VISION_KEY"] != "sk-vis-key" {
t.Errorf("YAO_VISION_KEY = %q, want 'sk-vis-key'", env["YAO_VISION_KEY"])
}
if env["YAO_VISION_BASE_URL"] != "https://api.mymaas.com/v1" {
t.Errorf("YAO_VISION_BASE_URL = %q, want 'https://api.mymaas.com/v1'", env["YAO_VISION_BASE_URL"])
}
if env["YAO_VISION_MODEL"] != "gpt-4o-mini" {
t.Errorf("YAO_VISION_MODEL = %q, want 'gpt-4o-mini'", env["YAO_VISION_MODEL"])
}
}
func TestInjectRoleEnvVars_Light(t *testing.T) {
lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"light": lightConn,
},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
if env["YAO_LIGHT_KEY"] != "sk-moon" {
t.Errorf("YAO_LIGHT_KEY = %q, want 'sk-moon'", env["YAO_LIGHT_KEY"])
}
if env["YAO_LIGHT_BASE_URL"] != "https://api.moonshot.cn/v1" {
t.Errorf("YAO_LIGHT_BASE_URL = %q, want 'https://api.moonshot.cn/v1'", env["YAO_LIGHT_BASE_URL"])
}
if env["YAO_LIGHT_MODEL"] != "moonshot-v1-8k" {
t.Errorf("YAO_LIGHT_MODEL = %q, want 'moonshot-v1-8k'", env["YAO_LIGHT_MODEL"])
}
}
func TestInjectRoleEnvVars_NoConnectors(t *testing.T) {
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
for _, prefix := range []string{"YAO_VISION", "YAO_LIGHT"} {
for _, suffix := range []string{"_KEY", "_BASE_URL", "_MODEL"} {
if v, ok := env[prefix+suffix]; ok {
t.Errorf("unexpected env %s=%s with no connectors", prefix+suffix, v)
}
}
}
}
func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.vision.com", "vis-model", "sk-vis")
lightConn := newFakeOpenAI("light-c", "https://api.light.com", "light-model", "sk-light")
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"vision": visionConn,
"light": lightConn,
},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
if env["YAO_VISION_KEY"] != "sk-vis" {
t.Errorf("YAO_VISION_KEY = %q", env["YAO_VISION_KEY"])
}
if env["YAO_LIGHT_KEY"] != "sk-light" {
t.Errorf("YAO_LIGHT_KEY = %q", env["YAO_LIGHT_KEY"])
}
}
// ---------------------------------------------------------------------------
// Full integration: buildOpenCodeConfig with role connectors
// ---------------------------------------------------------------------------
func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis")
lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
req := &types.PrepareRequest{
AssistantID: "test-assistant",
Connector: primaryConn,
Config: &types.SandboxConfig{},
Roles: map[string]connector.Connector{
"default": primaryConn,
"vision": visionConn,
"light": lightConn,
},
}
data := buildOpenCodeConfig(req, nil)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
providers := cfg["provider"].(map[string]any)
if _, ok := providers["custom"]; !ok {
t.Error("should have 'custom' provider for primary DeepSeek")
}
if _, ok := providers["vision"]; !ok {
t.Error("should have 'vision' provider block")
}
if _, ok := providers["light"]; !ok {
t.Error("should have 'light' provider block (different host from primary)")
}
if cfg["model"] != "custom/deepseek-v4-flash" {
t.Errorf("model = %v, want custom/deepseek-v4-flash", cfg["model"])
}
if cfg["small_model"] != "light/moonshot-v1-8k" {
t.Errorf("small_model = %v, want light/moonshot-v1-8k", cfg["small_model"])
}
enabled := cfg["enabled_providers"].([]any)
enabledSet := map[string]bool{}
for _, e := range enabled {
enabledSet[e.(string)] = true
}
for _, want := range []string{"custom", "vision", "light"} {
if !enabledSet[want] {
t.Errorf("enabled_providers should contain %q", want)
}
}
}
// ---------------------------------------------------------------------------
// resolvePrimaryConnector tests
// ---------------------------------------------------------------------------
func TestResolvePrimaryConnector_HeavyConfigured(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
heavyConn := newFakeOpenAI("heavy", "https://api.openai.com", "o3-pro", "sk-oai")
roles := map[string]connector.Connector{
"default": defaultConn,
"heavy": heavyConn,
}
result := resolvePrimaryConnector(defaultConn, roles)
if result != heavyConn {
t.Error("should return heavy connector when configured")
}
}
func TestResolvePrimaryConnector_NoHeavy(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
lightConn := newFakeOpenAI("light", "https://api.moonshot.cn", "moon-v1", "sk-moon")
roles := map[string]connector.Connector{
"default": defaultConn,
"light": lightConn,
}
result := resolvePrimaryConnector(defaultConn, roles)
if result != defaultConn {
t.Error("should fallback to default when heavy not configured")
}
}
func TestResolvePrimaryConnector_NilRoles(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
result := resolvePrimaryConnector(defaultConn, nil)
if result != defaultConn {
t.Error("should return default when roles is nil")
}
}

View file

@ -1,258 +0,0 @@
package opencode
import (
"context"
"fmt"
"strings"
"time"
"github.com/yaoapp/kun/log"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tools"
)
// Runner implements the sandbox Runner interface for OpenCode CLI.
type Runner struct {
mode string
hasMCP bool
mcpServers []types.MCPServer
lastCompleted bool
lastChatID string
logger *agentContext.RequestLogger
}
// New creates a new OpenCode Runner.
func New() *Runner {
return &Runner{mode: "cli"}
}
// Name returns the runner identifier. Must NOT be "yao" (see agent.go branching).
func (r *Runner) Name() string { return "opencode" }
// Prepare executes user-defined and runner-specific prepare steps.
func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
r.mode = req.Config.Runner.Mode
if r.mode == "" {
r.mode = "cli"
}
assistantID := req.AssistantID
prefix := ".yao/assistants/" + assistantID
if assistantID == "" {
prefix = ".opencode"
}
steps := append([]types.PrepareStep{}, req.Config.Prepare...)
// 0. Inject system tool SKILLs + prompts (before assistant-specific skills copy)
if ws := req.Computer.Workplace(); ws != nil {
if err := shared.InjectSystemSkills(ws, tools.SkillsFS, ".claude/skills"); err != nil {
log.Warn("[opencode-runner] inject system skills: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "CLAUDE.md", tools.SystemPrompt); err != nil {
log.Warn("[opencode-runner] append CLAUDE.md: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "AGENTS.md", tools.SystemPrompt); err != nil {
log.Warn("[opencode-runner] append AGENTS.md: %v", err)
}
}
// 1. Skills copy (aligned with Claude Runner)
if req.SkillsDir != "" {
ws := req.Computer.Workplace()
if ws != nil {
src := "local:///" + req.SkillsDir
dst := prefix + "/skills"
if _, err := ws.Copy(src, dst); err != nil {
log.Warn("[opencode-runner] copy skills %s -> %s: %v", src, dst, err)
}
}
}
// 2. MCP servers -> stored for opencode.json generation
if len(req.MCPServers) > 0 {
r.hasMCP = true
r.mcpServers = req.MCPServers
}
// 3. Create OPENCODE_*_DIR directories (data/config/state/cache) via
// workspace.FS so directory ownership matches the workspace mount.
// OpenCode writes files (e.g. .gitignore, SQLite DB) into these dirs
// on first startup and crashes if they don't exist.
if ws := req.Computer.Workplace(); ws != nil {
for _, sub := range []string{"data", "config", "state", "cache"} {
ws.MkdirAll(prefix+"/opencode/"+sub, 0777)
}
}
// 4. Copy custom tools (e.g. read.ts for vision) into OpenCode global
// config dir ($HOME/.config/opencode/tools/). Only needed when a
// vision connector is configured — the custom read tool overrides the
// built-in read to route image files through the vision API.
if _, ok := req.Roles["vision"]; ok {
p := resolvePlatform(req.Computer)
steps = append(steps, types.PrepareStep{
Action: "exec",
Cmd: visionCopyCmd(p),
Once: true,
IgnoreError: true,
})
}
// 5. Generate opencode.json (project config at workspace root)
configJSON := buildOpenCodeConfig(req, r.mcpServers)
steps = append(steps, types.PrepareStep{
Action: "file",
Path: "opencode.json",
Content: configJSON,
})
// 5. System prompt file
// Written via a prepare step so configHash dedup applies.
// opencode.json instructions field references this path.
// (System prompt is injected at Stream time if not a continuation.)
// 6. Execute all prepare steps via RunPrepareSteps (configHash dedup)
if req.RunSteps != nil && len(steps) > 0 {
if err := req.RunSteps(ctx, steps, req.Computer, req.AssistantID, req.ConfigHash, req.AssistantDir); err != nil {
return fmt.Errorf("opencode prepare steps: %w", err)
}
}
return nil
}
// Stream executes the OpenCode CLI and streams output to handler.
func (r *Runner) Stream(ctx context.Context, req *types.StreamRequest, handler message.StreamFunc) error {
computer := req.Computer
if computer == nil {
return fmt.Errorf("computer is nil")
}
p := resolvePlatform(computer)
// Resolve attachments (shared with Claude runner).
var attachmentPaths []string
if req.ChatID != "" {
if ws := computer.Workplace(); ws != nil {
_, resolved, err := shared.PrepareAttachments(ctx, req.Messages, req.ChatID, ws)
if err != nil {
return fmt.Errorf("prepareAttachments: %w", err)
}
workDir := computer.GetWorkDir()
for _, ar := range resolved {
attachmentPaths = append(attachmentPaths, p.PathJoin(workDir, ar.Path))
}
}
}
// Write system prompt if this is the first turn.
assistantID := req.AssistantID
chatID := req.ChatID
storeKey := "opencode-session:" + assistantID + ":" + chatID
isContinuation := chatID != "" && chatSessionExists(storeKey)
if !isContinuation && req.SystemPrompt != "" {
if ws := computer.Workplace(); ws != nil {
prefix := ".yao/assistants/" + assistantID
if assistantID == "" {
prefix = ".opencode"
}
promptPath := prefix + "/system-prompt.md"
envPrompt := buildSandboxEnvPrompt(p, computer.GetWorkDir())
fullPrompt := req.SystemPrompt + "\n\n" + envPrompt
ws.MkdirAll(prefix, 0755)
ws.WriteFile(promptPath, []byte(fullPrompt), 0644)
}
}
cmd := r.buildCommand(req, p, attachmentPaths)
r.logger = req.Logger
if r.logger == nil {
r.logger = agentContext.NoopLogger()
}
r.lastChatID = chatID
log.Trace("[opencode-runner] Stream started: assistantID=%s chatID=%s", assistantID, chatID)
r.logger.Debug("env vars passed to session (%d total):", len(cmd.env))
for k, v := range cmd.env {
if strings.HasPrefix(k, "CTX_") || k == "OPENCODE_DATA_DIR" || k == "HOME" || k == "WORKDIR" {
r.logger.Debug(" %s=%s", k, v)
} else {
r.logger.Debug(" %s=(set, len=%d)", k, len(v))
}
}
sess, err := startSession(ctx, computer, p, cmd, chatID, r.logger)
if err != nil {
return err
}
streamStart := time.Now()
completed, err := sess.runStream(handler)
r.lastCompleted = completed
elapsed := time.Since(streamStart).Round(time.Second)
log.Trace("[opencode-runner] Stream finished: assistantID=%s chatID=%s completed=%v elapsed=%v err=%v",
assistantID, chatID, completed, elapsed, err)
r.logger.Debug("Stream: runStream returned completed=%v err=%v elapsed=%v", completed, err, elapsed)
// Mark session in store after a clean run (err==nil) so future
// requests can use --continue --session to resume. This covers both
// completed=true (single-step stop) and completed=false (multi-step
// with tool-calls where the process exited normally).
if err == nil && chatID != "" {
sessionID := chatIDToSessionID(assistantID, chatID)
markChatSession(storeKey, sessionID, 90*24*time.Hour)
}
if completed || err == nil {
sess.shutdown()
}
return err
}
// Cleanup kills any remaining opencode processes. If the stream completed
// normally (received step_finish with stop), child processes are preserved.
func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error {
if computer == nil {
return nil
}
log.Trace("[opencode-runner] Cleanup: chatID=%s lastCompleted=%v", r.lastChatID, r.lastCompleted)
if r.lastCompleted {
if r.logger != nil {
r.logger.Info("cleanup: stream completed normally, preserving child processes")
}
return nil
}
if r.mode != "service" {
p := resolvePlatform(computer)
if r.lastChatID != "" {
computer.Exec(ctx, p.KillSessionCmd(sanitizeSessionName(r.lastChatID)))
} else {
computer.Exec(ctx, p.KillCmd("opencode"))
}
}
return nil
}
// visionCopyCmd returns the shell command to copy custom OpenCode tools
// (e.g. read.ts for vision) from the container image path into the user's
// config directory. Platform-aware: bash for POSIX, PowerShell for Windows.
func visionCopyCmd(p platform) string {
if p.OS() == "windows" {
return `$d = Join-Path $env:USERPROFILE '.config\opencode\tools'; ` +
`if (!(Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }; ` +
`Copy-Item 'C:\opt\opencode-tools\*.ts' $d -Force -ErrorAction SilentlyContinue`
}
return `mkdir -p $HOME/.config/opencode/tools && for f in /opt/opencode-tools/*.ts; do [ -f "$f" ] && cp -f "$f" $HOME/.config/opencode/tools/; done`
}

View file

@ -1,162 +0,0 @@
package opencode_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/caller"
agentcontext "github.com/yaoapp/yao/agent/context"
sandboxtestutils "github.com/yaoapp/yao/agent/sandbox/v2/testutils"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
const defaultTimeout = 3 * time.Minute
// ---------------------------------------------------------------------------
// Scenario 1: Oneshot — new container per request, no session persistence
// ---------------------------------------------------------------------------
func TestOpenCode_Oneshot(t *testing.T) {
sandboxtestutils.Prepare(t)
defer sandboxtestutils.Clean(t)
require.NotNil(t, caller.AgentGetterFunc)
const assistantID = "tests.sandbox-v2.opencode-oneshot-cli"
agent, err := caller.AgentGetterFunc(assistantID)
require.NoError(t, err)
chatID := fmt.Sprintf("e2e-oneshot-%d", time.Now().UnixMilli())
ctx := agentcontext.New(
context.Background(),
&oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"},
chatID,
)
resp := streamAndWait(t, agent, ctx, "Reply exactly with: hello opencode sandbox", defaultTimeout)
require.NotNil(t, resp.Completion)
assert.Equal(t, "assistant", resp.Completion.Role)
content := contentString(t, resp)
t.Logf("Oneshot response: %s", content)
assert.Contains(t, strings.ToLower(content), "hello opencode sandbox")
}
// ---------------------------------------------------------------------------
// Scenario 2 & 3: Session — first turn (new conversation) + continuation
// ---------------------------------------------------------------------------
func TestOpenCode_Session(t *testing.T) {
sandboxtestutils.Prepare(t)
defer sandboxtestutils.Clean(t)
require.NotNil(t, caller.AgentGetterFunc)
const assistantID = "tests.sandbox-v2.opencode-session-cli"
agent, err := caller.AgentGetterFunc(assistantID)
require.NoError(t, err)
chatID := fmt.Sprintf("e2e-session-%d", time.Now().UnixMilli())
// ── Turn 1: first message — creates a new session ──────────────────
t.Run("turn1_new_session", func(t *testing.T) {
ctx := agentcontext.New(
context.Background(),
&oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"},
chatID,
)
resp := streamAndWait(t, agent, ctx,
"Remember this secret code: PINEAPPLE-42. Reply with: understood",
defaultTimeout,
)
require.NotNil(t, resp.Completion)
assert.Equal(t, "assistant", resp.Completion.Role)
content := contentString(t, resp)
t.Logf("Turn 1 response: %s", content)
assert.Contains(t, strings.ToLower(content), "understood")
})
// ── Turn 2: continuation — reuses the session ──────────────────────
t.Run("turn2_continue_session", func(t *testing.T) {
ctx := agentcontext.New(
context.Background(),
&oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"},
chatID,
)
resp := streamAndWait(t, agent, ctx,
"What was the secret code I told you? Reply with just the code.",
defaultTimeout,
)
require.NotNil(t, resp.Completion)
assert.Equal(t, "assistant", resp.Completion.Role)
content := contentString(t, resp)
t.Logf("Turn 2 response: %s", content)
assert.Contains(t, strings.ToLower(content), "pineapple-42",
"continuation should recall secret from turn 1")
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func streamAndWait(
t *testing.T,
agent caller.AgentCaller,
ctx *agentcontext.Context,
prompt string,
timeout time.Duration,
) *agentcontext.Response {
t.Helper()
messages := []agentcontext.Message{{Role: "user", Content: prompt}}
done := make(chan struct{})
var resp *agentcontext.Response
var streamErr error
go func() {
defer close(done)
resp, streamErr = agent.Stream(ctx, messages)
}()
select {
case <-done:
case <-time.After(timeout):
t.Fatalf("timeout after %v", timeout)
}
if streamErr != nil {
t.Logf("Stream error: %v", streamErr)
}
require.NoError(t, streamErr, "Stream should not return error")
require.NotNil(t, resp, "response should not be nil")
if resp.Completion != nil {
t.Logf("Completion: role=%s content=%v", resp.Completion.Role, resp.Completion.Content)
}
require.NotNil(t, ctx.Buffer, "ctx.Buffer should not be nil")
msgs := ctx.Buffer.GetMessages()
t.Logf("buffer message count: %d", len(msgs))
for _, m := range msgs {
t.Logf(" seq=%d role=%s type=%s streaming=%v",
m.Sequence, m.Role, m.Type, m.IsStreaming)
}
return resp
}
func contentString(t *testing.T, resp *agentcontext.Response) string {
t.Helper()
s, ok := resp.Completion.Content.(string)
require.True(t, ok, "Content should be string, got %T", resp.Completion.Content)
return s
}

View file

@ -1,265 +0,0 @@
package opencode
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
infra "github.com/yaoapp/yao/sandbox/v2"
)
// session encapsulates a single OpenCode CLI execution lifecycle:
// process start, stderr collection, kill on cancel, Wait with timeout.
type session struct {
ctx context.Context
computer infra.Computer
plat platform
exec *infra.ExecStream
stderr strings.Builder
stderrMu sync.Mutex
logger *agentContext.RequestLogger
chatID string
}
func startSession(ctx context.Context, computer infra.Computer, p platform, cmd command, chatID string, logger *agentContext.RequestLogger) (*session, error) {
opts := []infra.ExecOption{infra.WithWorkDir(cmd.workDir), infra.WithEnv(cmd.env)}
logger.Info("opencode session starting: cmd=%v workDir=%s platform=%s chatID=%s",
cmd.shell, cmd.workDir, p.OS(), chatID)
execStream, err := computer.Stream(ctx, cmd.shell, opts...)
if err != nil {
return nil, fmt.Errorf("computer.Stream: %w", err)
}
// Write user message to stdin, then close. OpenCode reads the prompt
// from stdin when no positional message is given (same as Claude runner).
// Closing after write signals EOF so OpenCode begins processing.
if execStream.Stdin != nil {
if cmd.stdin != "" {
if _, err := io.WriteString(execStream.Stdin, cmd.stdin); err != nil {
logger.Warn("failed to write stdin: %v", err)
}
}
execStream.Stdin.Close()
}
return &session{
ctx: ctx,
computer: computer,
plat: p,
exec: execStream,
logger: logger,
chatID: chatID,
}, nil
}
// runStream executes the main stream processing loop.
// Returns (completed, error) where completed=true means OpenCode CLI sent
// a step_finish with reason=stop and the stream finished normally.
func (s *session) runStream(handler message.StreamFunc) (completed bool, err error) {
s.collectStderr()
cleanup := s.watchCancel()
defer cleanup()
// Tee stdout to a debug log so we can inspect raw JSONL timing.
stdout := s.teeStdout()
parser := newStreamParser(handler)
parseErr := parser.parse(s.ctx, stdout)
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
if !parser.completed && parseErr != nil {
s.exec.Cancel()
}
if parser.completed {
s.logger.Info("opencode stream completed normally")
return true, nil
}
exitErr := s.waitForExit(parseErr)
if exitErr != nil {
if handler != nil {
handler(message.ChunkError, []byte(exitErr.Error()))
}
return false, exitErr
}
s.stderrMu.Lock()
stderrStr := strings.TrimSpace(s.stderr.String())
s.stderrMu.Unlock()
if stderrStr != "" {
s.logger.Warn("opencode exited with code 0 but stream incomplete and stderr present: %s", stderrStr)
errMsg := fmt.Errorf("opencode CLI setup failed: %s", stderrStr)
if handler != nil {
handler(message.ChunkError, []byte(errMsg.Error()))
}
return false, errMsg
}
return false, nil
}
// teeStdout wraps exec.Stdout with a TeeReader that writes a copy to a
// timestamped log file. Returns the original Stdout if tee setup fails.
func (s *session) teeStdout() io.ReadCloser {
logDir := os.Getenv("YAO_LOG_PATH")
if logDir == "" {
logDir = "/tmp"
}
logFile := filepath.Join(logDir, fmt.Sprintf("opencode-stream-%s-%d.jsonl", s.chatID, time.Now().Unix()))
f, err := os.Create(logFile)
if err != nil {
s.logger.Debug("teeStdout: cannot create %s: %v", logFile, err)
return s.exec.Stdout
}
s.logger.Info("teeStdout: raw JSONL -> %s", logFile)
tee := io.TeeReader(s.exec.Stdout, f)
return &teeReadCloser{Reader: tee, closers: []io.Closer{s.exec.Stdout, f}}
}
type teeReadCloser struct {
io.Reader
closers []io.Closer
}
func (t *teeReadCloser) Close() error {
var firstErr error
for _, c := range t.closers {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *session) collectStderr() {
go func() {
buf := make([]byte, 4096)
for {
n, err := s.exec.Stderr.Read(buf)
if n > 0 {
chunk := string(buf[:n])
s.stderrMu.Lock()
s.stderr.WriteString(chunk)
s.stderrMu.Unlock()
s.logger.Debug("opencode stderr: %s", chunk)
}
if err != nil {
return
}
}
}()
}
// killProcess terminates the OpenCode CLI process (Node.js).
func (s *session) killProcess(ctx context.Context) {
if s.chatID != "" {
name := sanitizeSessionName(s.chatID)
result, err := s.computer.Exec(ctx, s.plat.KillSessionCmd(name))
s.logger.Debug("killProcess: KillSessionCmd(%s) exitCode=%d err=%v", name, result.ExitCode, err)
return
}
// OpenCode is a Node.js process; match both "opencode" and "node.*opencode"
result, err := s.computer.Exec(ctx, s.plat.KillCmd("opencode"))
s.logger.Debug("killProcess: KillCmd(opencode) exitCode=%d err=%v", result.ExitCode, err)
}
func (s *session) watchCancel() func() {
done := make(chan struct{})
go func() {
select {
case <-s.ctx.Done():
s.logger.Info("context cancelled, killing opencode: %v", s.ctx.Err())
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.killProcess(killCtx)
s.exec.Cancel()
case <-done:
}
}()
return func() { close(done) }
}
// shutdown cleans up after normal stream completion.
//
// OpenCode exits cleanly after step_finish(stop), but we still need to
// release the Docker exec connection. Like Claude runner, we first kill
// only the opencode process with SIGKILL (which cannot be caught, so
// OpenCode has no chance to propagate signals to child processes), then
// close the exec connection. Children (browsers, servers, etc.) that were
// launched via nohup/setsid survive because they are in separate sessions.
func (s *session) shutdown() {
s.logger.Info("shutting down completed opencode exec session: chatID=%s", s.chatID)
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.killProcess(killCtx)
s.exec.Cancel()
}
func (s *session) waitForExit(parseErr error) error {
s.logger.Info("opencode stream did not complete normally, waiting for exit")
type waitResult struct {
exitCode int
err error
}
ch := make(chan waitResult, 1)
go func() {
code, err := s.exec.Wait()
ch <- waitResult{code, err}
}()
var exitCode int
var waitErr error
select {
case wr := <-ch:
exitCode, waitErr = wr.exitCode, wr.err
case <-s.ctx.Done():
select {
case wr := <-ch:
exitCode, waitErr = wr.exitCode, wr.err
case <-time.After(10 * time.Second):
s.exec.Cancel()
s.logger.Error("opencode did not exit after kill, timeout")
return fmt.Errorf("opencode did not exit after kill (timeout)")
}
}
s.stderrMu.Lock()
stderrStr := strings.TrimSpace(s.stderr.String())
s.stderrMu.Unlock()
if parseErr != nil {
if stderrStr != "" {
return fmt.Errorf("%w (stderr: %s)", parseErr, stderrStr)
}
return parseErr
}
if waitErr != nil {
if stderrStr != "" {
return fmt.Errorf("%w (stderr: %s)", waitErr, stderrStr)
}
return waitErr
}
if exitCode != 0 {
s.logger.Warn("opencode exited with non-zero code: exitCode=%d stderr=%s", exitCode, stderrStr)
if stderrStr != "" {
return fmt.Errorf("opencode CLI exited with code %d: %s", exitCode, stderrStr)
}
return fmt.Errorf("opencode CLI exited with code %d", exitCode)
}
return nil
}

View file

@ -166,18 +166,12 @@ func runExecStep(ctx context.Context, computer infra.Computer, step types.Prepar
} }
} }
workDir := computer.GetWorkDir() rootDir := "/"
if workDir == "" {
workDir = "/"
if isWindowsComputer(computer) { if isWindowsComputer(computer) {
workDir = `C:\` rootDir = `C:\`
}
} }
result, err := computer.Exec(ctx, shellWrap(kind, script), result, err := computer.Exec(ctx, shellWrap(kind, script), infra.WithWorkDir(rootDir))
infra.WithWorkDir(workDir),
infra.WithEnv(map[string]string{"HOME": workDir}),
)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,251 +0,0 @@
package shared
import (
"context"
"fmt"
"path/filepath"
"strings"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/attachment"
workspace "github.com/yaoapp/yao/tai/workspace"
)
// AttachmentResult holds the resolved attachment info after copying to workspace.
type AttachmentResult struct {
Path string // workspace-relative path (e.g. ".attachments/{chatID}/image.png")
ContentType string
Filename string
Bytes int
}
// PrepareAttachments resolves __yao.attachment:// URLs in user messages,
// copies actual files into the workspace .attachments/{chatID}/ directory,
// and returns processed messages plus a list of resolved file paths.
//
// The returned messages have multimodal content replaced with text references
// (for runners like Claude that need text-only). Callers that need the raw
// file paths (like OpenCode's --file) can use the returned []AttachmentResult.
func PrepareAttachments(ctx context.Context, messages []agentContext.Message, chatID string, ws workspace.FS) ([]agentContext.Message, []AttachmentResult, error) {
usedNames := make(map[string]int)
attachDir := ".attachments/" + chatID
var resolved []AttachmentResult
result := make([]agentContext.Message, len(messages))
copy(result, messages)
for i, msg := range result {
if msg.Role != "user" {
continue
}
parts, ok := msg.Content.([]interface{})
if !ok {
if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok {
iparts := make([]interface{}, len(typedParts))
for j, p := range typedParts {
m := map[string]interface{}{"type": string(p.Type)}
if p.Text != "" {
m["text"] = p.Text
}
if p.ImageURL != nil {
m["image_url"] = map[string]interface{}{
"url": p.ImageURL.URL,
"detail": string(p.ImageURL.Detail),
}
}
if p.File != nil {
m["file"] = map[string]interface{}{
"url": p.File.URL,
"filename": p.File.Filename,
}
}
iparts[j] = m
}
parts = iparts
} else {
continue
}
}
if len(parts) == 0 {
continue
}
var textParts []string
for _, item := range parts {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
partType, _ := m["type"].(string)
switch partType {
case "text":
if text, ok := m["text"].(string); ok && text != "" {
textParts = append(textParts, text)
}
case "image_url":
imgData, _ := m["image_url"].(map[string]interface{})
if imgData == nil {
continue
}
url, _ := imgData["url"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[Image: %s]", url))
continue
}
ar, ref, err := resolveAttachment(ctx, uploaderName, fileID, "", attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached image: failed to load]")
continue
}
resolved = append(resolved, *ar)
textParts = append(textParts, ref)
case "file":
fileData, _ := m["file"].(map[string]interface{})
if fileData == nil {
continue
}
url, _ := fileData["url"].(string)
hintName, _ := fileData["filename"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[File: %s]", url))
continue
}
ar, ref, err := resolveAttachment(ctx, uploaderName, fileID, hintName, attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached file: failed to load]")
continue
}
resolved = append(resolved, *ar)
textParts = append(textParts, ref)
}
}
if len(textParts) > 0 {
newMsg := result[i]
newMsg.Content = strings.Join(textParts, "\n\n")
result[i] = newMsg
}
}
return result, resolved, nil
}
// resolveAttachment gets the local path of an attachment and copies it into
// the workspace via ws.Copy("local:///abs/path", ".attachments/{chatID}/filename").
func resolveAttachment(
ctx context.Context,
uploaderName, fileID, hintName, attachDir string,
usedNames map[string]int,
ws workspace.FS,
) (*AttachmentResult, string, error) {
manager, exists := attachment.Managers[uploaderName]
if !exists {
return nil, "", fmt.Errorf("attachment manager not found: %s", uploaderName)
}
fileInfo, err := manager.Info(ctx, fileID)
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
absPath, _, err := manager.LocalPath(ctx, fileID)
if err != nil {
return nil, "", fmt.Errorf("failed to get local path: %w", err)
}
filename := fileInfo.Filename
if filename == "" && hintName != "" {
filename = hintName
}
if filename == "" {
ext := ExtensionFromContentType(fileInfo.ContentType)
filename = fileID + ext
}
baseName := filename
if count, exists := usedNames[baseName]; exists {
ext := filepath.Ext(filename)
name := strings.TrimSuffix(filename, ext)
filename = fmt.Sprintf("%s_%d%s", name, count+1, ext)
usedNames[baseName] = count + 1
} else {
usedNames[baseName] = 0
}
dstPath := attachDir + "/" + filename
src := "local:///" + absPath
if _, err := ws.Copy(src, dstPath); err != nil {
return nil, "", fmt.Errorf("failed to copy attachment to workspace: %w", err)
}
sizeStr := FormatFileSize(fileInfo.Bytes)
ref := fmt.Sprintf("[Attached file: %s (%s, %s)]", dstPath, fileInfo.ContentType, sizeStr)
ar := &AttachmentResult{
Path: dstPath,
ContentType: fileInfo.ContentType,
Filename: filename,
Bytes: fileInfo.Bytes,
}
return ar, ref, nil
}
// ExtensionFromContentType maps common MIME types to file extensions.
func ExtensionFromContentType(contentType string) string {
switch contentType {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/svg+xml":
return ".svg"
case "application/pdf":
return ".pdf"
case "text/plain":
return ".txt"
case "text/html":
return ".html"
case "text/css":
return ".css"
case "text/javascript", "application/javascript":
return ".js"
case "application/json":
return ".json"
case "application/zip":
return ".zip"
default:
return ""
}
}
// FormatFileSize returns a human-readable file size string.
func FormatFileSize(bytes int) string {
switch {
case bytes >= 1024*1024:
return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024))
case bytes >= 1024:
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
default:
return fmt.Sprintf("%dB", bytes)
}
}

View file

@ -1,79 +0,0 @@
package shared
import (
"bytes"
"errors"
"io/fs"
"os"
"path"
"strings"
)
const systemToolsMarker = "<!-- Yao System Tools (auto-injected) -->"
// writerFS is the minimal filesystem interface needed by the injection helpers.
// workspace.FS satisfies this interface.
type writerFS interface {
ReadFile(name string) ([]byte, error)
WriteFile(name string, data []byte, perm os.FileMode) error
MkdirAll(name string, perm os.FileMode) error
}
// InjectSystemSkills copies SKILL files from an embed.FS into the workspace.
// The skills parameter should be an embed.FS produced by `//go:embed skills`,
// where each file has a path like "skills/yao-web/SKILL.md". This function
// strips the "skills/" prefix and writes files into targetDir (e.g. ".claude/skills").
func InjectSystemSkills(ws writerFS, skills fs.FS, targetDir string) error {
return fs.WalkDir(skills, "skills", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel := strings.TrimPrefix(p, "skills/")
dst := path.Join(targetDir, rel)
data, err := fs.ReadFile(skills, p)
if err != nil {
return err
}
dir := path.Dir(dst)
if err := ws.MkdirAll(dir, 0755); err != nil {
return err
}
return ws.WriteFile(dst, data, 0644)
})
}
// AppendSystemPrompt injects content into a file in the workspace using an
// idempotent marker. If the marker already exists, the injected section is
// replaced with the new content (so updates propagate to existing sandboxes).
// If the file does not exist it is created with just the marker + content.
func AppendSystemPrompt(ws writerFS, filename string, content []byte) error {
existing, err := ws.ReadFile(filename)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
header := []byte(systemToolsMarker + "\n\n")
return ws.WriteFile(filename, append(header, content...), 0644)
}
idx := bytes.Index(existing, []byte(systemToolsMarker))
if idx >= 0 {
injected := append([]byte(systemToolsMarker+"\n\n"), content...)
prefix := existing[:idx]
merged := append(bytes.TrimRight(prefix, "\n\r\t "), []byte("\n\n---\n\n")...)
if idx == 0 {
merged = nil
}
return ws.WriteFile(filename, append(merged, injected...), 0644)
}
separator := []byte("\n\n---\n\n" + systemToolsMarker + "\n\n")
merged := append(existing, append(separator, content...)...)
return ws.WriteFile(filename, merged, 0644)
}

View file

@ -1,209 +0,0 @@
package shared
import (
"io/fs"
"os"
"path/filepath"
"testing"
"testing/fstest"
)
func TestInjectSystemSkills_CopiesAllFiles(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
skills := fstest.MapFS{
"skills/yao-web/SKILL.md": {Data: []byte("web skill")},
"skills/yao-process/SKILL.md": {Data: []byte("process skill")},
"skills/yao-doc/SKILL.md": {Data: []byte("doc skill")},
}
if err := InjectSystemSkills(ws, skills, ".claude/skills"); err != nil {
t.Fatalf("InjectSystemSkills: %v", err)
}
for _, tc := range []struct {
path string
want string
}{
{".claude/skills/yao-web/SKILL.md", "web skill"},
{".claude/skills/yao-process/SKILL.md", "process skill"},
{".claude/skills/yao-doc/SKILL.md", "doc skill"},
} {
data, err := os.ReadFile(filepath.Join(dir, tc.path))
if err != nil {
t.Errorf("ReadFile(%s): %v", tc.path, err)
continue
}
if string(data) != tc.want {
t.Errorf("%s = %q, want %q", tc.path, data, tc.want)
}
}
}
func TestAppendSystemPrompt_CreatesNewFile(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
content := []byte("## Yao System Tools\ntai tool ...")
if err := AppendSystemPrompt(ws, "CLAUDE.md", content); err != nil {
t.Fatalf("AppendSystemPrompt: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if got := string(data); got == "" {
t.Fatal("file should not be empty")
}
assertContains(t, string(data), systemToolsMarker)
assertContains(t, string(data), "Yao System Tools")
}
func TestAppendSystemPrompt_AppendsToExisting(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
existing := []byte("# My Project\n\nExisting content.\n")
if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), existing, 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
content := []byte("## System Tools\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", content); err != nil {
t.Fatalf("AppendSystemPrompt: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
got := string(data)
assertContains(t, got, "My Project")
assertContains(t, got, systemToolsMarker)
assertContains(t, got, "System Tools")
}
func TestAppendSystemPrompt_Idempotent(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
content := []byte("## Yao System Tools\n")
if err := AppendSystemPrompt(ws, "AGENTS.md", content); err != nil {
t.Fatalf("first call: %v", err)
}
first, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err := AppendSystemPrompt(ws, "AGENTS.md", content); err != nil {
t.Fatalf("second call: %v", err)
}
second, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if string(first) != string(second) {
t.Errorf("second call modified the file (not idempotent):\n--- first ---\n%s\n--- second ---\n%s", first, second)
}
}
func TestAppendSystemPrompt_UpdatesExistingContent(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
oldContent := []byte("## Old Tools\nweb_search only\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", oldContent); err != nil {
t.Fatalf("first call: %v", err)
}
newContent := []byte("## Updated Tools\nweb_search + image_read\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", newContent); err != nil {
t.Fatalf("second call: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
got := string(data)
assertContains(t, got, "image_read")
assertContains(t, got, systemToolsMarker)
if containsStr(got, "Old Tools") {
t.Error("old content should have been replaced")
}
}
func TestAppendSystemPrompt_UpdatesPreservesUserContent(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
userContent := []byte("# My Project\n\nUser notes.\n")
if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), userContent, 0644); err != nil {
t.Fatal(err)
}
oldContent := []byte("## Old Tools\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", oldContent); err != nil {
t.Fatal(err)
}
newContent := []byte("## Updated Tools\nimage_read added\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", newContent); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
got := string(data)
assertContains(t, got, "My Project")
assertContains(t, got, "User notes")
assertContains(t, got, "image_read")
if containsStr(got, "Old Tools") {
t.Error("old injected content should have been replaced")
}
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func assertContains(t *testing.T, s, sub string) {
t.Helper()
if len(s) < len(sub) {
t.Errorf("string does not contain %q", sub)
return
}
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return
}
}
t.Errorf("string does not contain %q:\n%s", sub, s)
}
// dirFS is a minimal workspace.FS backed by a real directory (for testing).
type dirFS struct {
root string
}
func newDirFS(root string) *dirFS { return &dirFS{root: root} }
func (d *dirFS) Open(name string) (fs.File, error) {
return os.Open(filepath.Join(d.root, name))
}
func (d *dirFS) ReadFile(name string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(d.root, name))
if err != nil {
return nil, err
}
return data, nil
}
func (d *dirFS) WriteFile(name string, data []byte, perm os.FileMode) error {
return os.WriteFile(filepath.Join(d.root, name), data, perm)
}
func (d *dirFS) MkdirAll(name string, perm os.FileMode) error {
return os.MkdirAll(filepath.Join(d.root, name), perm)
}

View file

@ -1,43 +0,0 @@
package shared
import "bufio"
// MaxLineSize is the safety threshold for a single JSONL line.
// Lines exceeding this limit are drained and discarded to prevent
// unbounded memory growth (e.g. Claude CLI "result" events containing
// full conversation history).
const MaxLineSize = 50 * 1024 * 1024 // 50MB
// ReadJSONLine reads one complete line from a bufio.Reader.
//
// Returns:
// - line: complete line bytes (without \n); nil when skipped
// - skipped: true if line exceeded MaxLineSize and was drained
// - err: io.EOF at end of stream, or other IO error
func ReadJSONLine(r *bufio.Reader) ([]byte, bool, error) {
var buf []byte
for {
chunk, isPrefix, err := r.ReadLine()
if err != nil {
return nil, false, err
}
buf = append(buf, chunk...)
if len(buf) > MaxLineSize {
if !isPrefix {
buf = nil
return nil, true, nil
}
for isPrefix {
_, isPrefix, err = r.ReadLine()
if err != nil {
return nil, true, err
}
}
buf = nil
return nil, true, nil
}
if !isPrefix {
return buf, false, nil
}
}
}

Some files were not shown because too many files have changed in this diff Show more