Run tool scripts through the shared host and keep orchestration off #60

Merged
weichen merged 1 commits from feature/code-orchestration into main 2026-10-03 18:07:12 +00:00
9 changed files with 153 additions and 265 deletions
+29 -4
View File
@@ -107,6 +107,9 @@ func Run(opt Options) error {
if err := tools.RegisterInterpreterTools(reg); err != nil {
return fmt.Errorf("interpreter tools: %w", err)
}
if err := tools.RegisterCodeTool(reg); err != nil {
return fmt.Errorf("code orchestration tool: %w", err)
}
setSvc := settings.NewService(st)
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
@@ -188,9 +191,26 @@ func Run(opt Options) error {
if pluginHost != nil {
rawMCP = mergeMCPServers(rawMCP, pluginHost.MCPServers())
}
if mgr := mcpManagerFor(rawMCP); mgr != nil {
if mgr := mcpManagerFor(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil {
cfg.MCP = mgr
}
cfg.CodeOrchestration = effNow.GetBool("runtime.codeOrchestration", false)
if ask, ok := rc.(interface {
AskUser(card core.HITLCard) (core.HITLResponse, error)
}); ok {
cfg.Ask = func(_ context.Context, question string) (string, error) {
resp, err := ask.AskUser(core.HITLCard{
Question: question, Kind: core.HITLKindInput, AllowFreeInput: true,
})
if err != nil {
return "", err
}
if strings.TrimSpace(resp.FreeText) != "" {
return strings.TrimSpace(resp.FreeText), nil
}
return resp.ChoiceID, nil
}
}
cfg.Trace = traceFn
if host, ok := rc.(interface {
OpenCheckpoints() (*checkpoint.Log, error)
@@ -524,9 +544,10 @@ var (
mcpManager *agentmcp.Manager
)
func mcpManagerFor(raw string) *agentmcp.Manager {
func mcpManagerFor(raw, overrides string) *agentmcp.Manager {
mcpMu.Lock()
defer mcpMu.Unlock()
key := raw + "\n" + overrides
if raw == "" {
if mcpManager != nil {
mcpManager.Close()
@@ -535,7 +556,7 @@ func mcpManagerFor(raw string) *agentmcp.Manager {
mcpKey = ""
return nil
}
if raw == mcpKey && mcpManager != nil {
if key == mcpKey && mcpManager != nil {
return mcpManager
}
var servers []agentmcp.ServerConfig
@@ -548,10 +569,14 @@ func mcpManagerFor(raw string) *agentmcp.Manager {
log.Printf("squad agent-native: MCP config invalid, MCP disabled: %v", err)
return nil
}
if err := mgr.ApplyOverrides(overrides); err != nil {
log.Printf("squad agent-native: MCP overrides invalid, MCP disabled: %v", err)
return nil
}
if mcpManager != nil {
mcpManager.Close()
}
mcpKey, mcpManager = raw, mgr
mcpKey, mcpManager = key, mgr
return mgr
}
+8 -8
View File
@@ -21,24 +21,24 @@ func TestMcpManagerForCacheAndRebuild(t *testing.T) {
cfgA := `[{"name":"a","command":"/bin/true"}]`
cfgB := `[{"name":"b","command":"/bin/true"}]`
m1 := mcpManagerFor(cfgA)
m1 := mcpManagerFor(cfgA, "")
if m1 == nil {
t.Fatal("valid config must build a manager")
}
if m2 := mcpManagerFor(cfgA); m2 != m1 {
if m2 := mcpManagerFor(cfgA, ""); m2 != m1 {
t.Fatal("same config must reuse the cached manager")
}
m3 := mcpManagerFor(cfgB)
m3 := mcpManagerFor(cfgB, "")
if m3 == nil || m3 == m1 {
t.Fatal("changed config must rebuild a fresh manager")
}
if mcpManagerFor("") != nil {
if mcpManagerFor("", "") != nil {
t.Fatal("empty config disables MCP and clears the cache")
}
if mcpManagerFor(`{bad json`) != nil {
if mcpManagerFor(`{bad json`, "") != nil {
t.Fatal("invalid JSON must disable MCP (nil), not panic")
}
if mcpManagerFor(`[{"name":"bad name","command":"x"}]`) != nil {
if mcpManagerFor(`[{"name":"bad name","command":"x"}]`, "") != nil {
t.Fatal("invalid server config must disable MCP (nil)")
}
// 并发调用不死锁、返回同一实例或各自重建(缓存一致性由锁保证)。
@@ -47,7 +47,7 @@ func TestMcpManagerForCacheAndRebuild(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
_ = mcpManagerFor(cfgB)
_ = mcpManagerFor(cfgB, "")
}()
}
wg.Wait()
@@ -66,7 +66,7 @@ func TestMcpCacheConcurrencyWithSettingsResolution(t *testing.T) {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = mcpManagerFor(cfgs[i%len(cfgs)])
_ = mcpManagerFor(cfgs[i%len(cfgs)], "")
}(i)
}
wg.Wait()
+4
View File
@@ -290,10 +290,14 @@ func (c *runCtx) Tools() []core.ToolDesc {
list := c.mgr.registry.List()
out := make([]core.ToolDesc, 0, len(list))
fs := c.eff.GetBool("runtime.fsTools", false)
code := c.eff.GetBool("runtime.codeOrchestration", false)
for _, t := range list {
if t.Hidden && !fs {
continue
}
if t.Name == "run_tool_script" && !code {
continue
}
readOnly := t.ReadOnly || agentpolicy.TraitsFor(t.Name).ReadOnly
if !agentpolicy.Visible(c.permissionMode, t.Name, readOnly) {
continue
-51
View File
@@ -1,51 +0,0 @@
"""工作流脚本回调。用法:import agentwf as wf。
请求写到 fd 3,响应从 fd 4 按行读回。宿主一次只处理一条。
"""
import json
import os
_seq = 0
_out = os.fdopen(3, "w", encoding="utf-8", buffering=1)
_inp = os.fdopen(4, "r", encoding="utf-8", buffering=1)
def _rpc(op, **fields):
global _seq
_seq += 1
msg = {"id": str(_seq), "op": op}
msg.update(fields)
_out.write(json.dumps(msg, ensure_ascii=False) + "\n")
_out.flush()
line = _inp.readline()
if not line:
raise RuntimeError("workflow host closed")
resp = json.loads(line)
if not resp.get("ok"):
raise RuntimeError(resp.get("error") or "workflow call failed")
return resp.get("output", "")
def tool(name, args=None):
return _rpc("tool.call", name=name, args=args or {})
def ask(question, choices=None):
return _rpc("hitl.ask", question=question, choices=choices or [])
def agent(name, task):
return _rpc("agent.spawn", agent=name, task=task)
def artifact(name, content):
return _rpc("artifact.register", name=name, content=content)
def card(title, kind="result"):
return _rpc("card.create", title=title, kind=kind)
def step(step_id, title, status):
return _rpc("step", stepId=step_id, title=title, status=status)
+22 -193
View File
@@ -1,27 +1,21 @@
package runtime
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"agent-native/runtime/codeorch"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
core "agent-system-core"
_ "embed"
)
//go:embed agentwf.py
var agentwfSource string
const (
scriptDefaultTimeout = 600 * time.Second
scriptMaxTimeout = 1800 * time.Second
@@ -29,28 +23,6 @@ const (
scriptMaxLine = 1 << 20
)
var (
agentwfOnce sync.Once
agentwfDir string
agentwfErr error
)
func agentwfHelperDir() (string, error) {
agentwfOnce.Do(func() {
dir, err := os.MkdirTemp("", "agentwf-*")
if err != nil {
agentwfErr = err
return
}
if err := os.WriteFile(filepath.Join(dir, "agentwf.py"), []byte(agentwfSource), 0o644); err != nil {
agentwfErr = err
return
}
agentwfDir = dir
})
return agentwfDir, agentwfErr
}
func scriptsEnabled(rc RunCtx) bool {
return rc.Settings().GetBool("runtime.workflowScripts", false)
}
@@ -176,176 +148,33 @@ func runScriptFile(rc RunCtx, runner WorkflowRunner, path string) (string, []*co
if timeout <= 0 {
timeout = scriptDefaultTimeout
}
if timeout > scriptMaxTimeout {
timeout = scriptMaxTimeout
}
helper, err := agentwfHelperDir()
if err != nil {
return "", nil, err
}
parent := rc.Context()
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
cmd, err := tools.ScriptCommand(ctx, sandbox, path)
if err != nil {
return "", nil, err
}
cmd.Env = prependPythonPath(cmd.Env, helper)
reqR, reqW, err := os.Pipe()
if err != nil {
return "", nil, err
}
respR, respW, err := os.Pipe()
if err != nil {
reqR.Close()
reqW.Close()
return "", nil, err
}
cmd.ExtraFiles = []*os.File{reqW, respR}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
return "", nil, err
}
reqW.Close()
respR.Close()
defer reqR.Close()
defer respW.Close()
arts, callErr := serveScript(ctx, rc, reqR, respW)
if callErr != nil {
cancel()
}
waitErr := cmd.Wait()
text := clipScriptOutput(&stdout, &stderr)
if callErr != nil {
return text, arts, callErr
}
if ctx.Err() == context.DeadlineExceeded {
return text, arts, fmt.Errorf("workflow: 脚本超时")
}
if ctx.Err() == context.Canceled {
return text, arts, fmt.Errorf("workflow: 脚本已取消")
}
if waitErr != nil {
return text, arts, fmt.Errorf("workflow: 脚本退出:%v\n%s", waitErr, strings.TrimSpace(stderr.String()))
}
return text, arts, nil
}
func clipScriptOutput(stdout, stderr *bytes.Buffer) string {
text := strings.TrimSpace(stdout.String())
errText := strings.TrimSpace(stderr.String())
if errText != "" {
if text != "" {
text += "\n"
}
text += errText
}
if len(text) > 16000 {
text = text[:16000] + "…(超长截断)"
}
return text
}
func prependPythonPath(env []string, dir string) []string {
for i, kv := range env {
if strings.HasPrefix(kv, "PYTHONPATH=") {
env[i] = "PYTHONPATH=" + dir + string(os.PathListSeparator) + strings.TrimPrefix(kv, "PYTHONPATH=")
return env
}
}
return append(env, "PYTHONPATH="+dir)
}
type scriptRequest struct {
ID string `json:"id"`
Op string `json:"op"`
Name string `json:"name"`
Args json.RawMessage `json:"args"`
Question string `json:"question"`
Choices []core.HITLChoice `json:"choices"`
Agent string `json:"agent"`
Task string `json:"task"`
Content string `json:"content"`
Title string `json:"title"`
Kind string `json:"kind"`
Status string `json:"status"`
StepID string `json:"stepId"`
}
type scriptResponse struct {
ID string `json:"id"`
OK bool `json:"ok"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
func serveScript(ctx context.Context, rc RunCtx, reqR, respW *os.File) ([]*core.ArtifactRef, error) {
sc := bufio.NewScanner(reqR)
sc.Buffer(make([]byte, 64*1024), scriptMaxLine)
var arts []*core.ArtifactRef
calls := 0
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
calls++
var req scriptRequest
if err := json.Unmarshal([]byte(line), &req); err != nil {
_ = writeScriptResponse(respW, scriptResponse{OK: false, Error: "请求不是合法 JSON"})
continue
}
if calls > scriptMaxCalls {
_ = writeScriptResponse(respW, scriptResponse{ID: req.ID, OK: false, Error: "脚本回调超过 50 次"})
return arts, fmt.Errorf("workflow: 脚本回调超过 50 次")
}
if ctx.Err() != nil {
return arts, ctx.Err()
}
output, got, err := dispatchScript(rc, req)
out := codeorch.RunFile(parent, sandbox, path, codeorch.Limits{
Timeout: timeout,
MaxTimeout: scriptMaxTimeout,
MaxCalls: scriptMaxCalls,
MaxResultBytes: scriptMaxLine,
MaxModelChars: 16000,
FullDir: sandbox,
}, func(ctx context.Context, dir, scriptPath string) (*exec.Cmd, error) {
return tools.ScriptCommand(ctx, dir, scriptPath)
}, func(_ context.Context, call codeorch.Call) (string, error) {
output, got, err := dispatchScript(rc, call)
arts = append(arts, got...)
if err != nil {
_ = writeScriptResponse(respW, scriptResponse{ID: req.ID, OK: false, Error: err.Error()})
if ctx.Err() != nil {
return arts, ctx.Err()
}
continue
}
if err := writeScriptResponse(respW, scriptResponse{ID: req.ID, OK: true, Output: output}); err != nil {
return arts, err
}
}
if err := sc.Err(); err != nil {
if err == bufio.ErrTooLong {
return arts, fmt.Errorf("workflow: 单行请求超过 1MB")
}
if ctx.Err() != nil {
return arts, ctx.Err()
}
return arts, err
}
return arts, nil
return output, err
})
return out.ModelText(), arts, out.Err
}
func writeScriptResponse(w io.Writer, resp scriptResponse) error {
raw, err := json.Marshal(resp)
if err != nil {
return err
func dispatchScript(rc RunCtx, req codeorch.Call) (string, []*core.ArtifactRef, error) {
var choices []core.HITLChoice
if len(req.Choices) > 0 {
_ = json.Unmarshal(req.Choices, &choices)
}
_, err = w.Write(append(raw, '\n'))
return err
}
func dispatchScript(rc RunCtx, req scriptRequest) (string, []*core.ArtifactRef, error) {
switch req.Op {
case "tool.call":
if _, denied := workflow.DeniedTools[req.Name]; denied {
@@ -363,10 +192,10 @@ func dispatchScript(rc RunCtx, req scriptRequest) (string, []*core.ArtifactRef,
return res.Output, arts, nil
case "hitl.ask":
card := core.HITLCard{Question: req.Question, Kind: core.HITLKindInput, AllowFreeInput: true}
if len(req.Choices) > 0 {
if len(choices) > 0 {
card.Kind = core.HITLKindChoice
card.AllowFreeInput = false
card.Choices = req.Choices
card.Choices = choices
for i := range card.Choices {
if card.Choices[i].Impact == "" {
card.Choices[i].Impact = "会按这个选择继续"
+10 -7
View File
@@ -31,13 +31,14 @@ func SystemDefaults() map[string]any {
"tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具)
},
"runtime": map[string]any{
"mock": false, // true = 全部会话走 mock runner(演示/测试)
"squad": true, // 小队编排(Agent-Squad 引擎)可用
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
"verify": true, // 终轮确定性检查与独立验证
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
"mock": false, // true = 全部会话走 mock runner(演示/测试)
"squad": true, // 小队编排(Agent-Squad 引擎)可用
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
"verify": true, // 终轮确定性检查与独立验证
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
"codeOrchestration": false, // 模型用脚本批量调用工具;默认关
},
"storage": map[string]any{
"dataDir": "./data", // 启动注入实际值(--data)
@@ -85,6 +86,7 @@ func Schema() []core.SettingDesc {
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
@@ -94,6 +96,7 @@ func Schema() []core.SettingDesc {
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"},
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
+78
View File
@@ -0,0 +1,78 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"agent-native/runtime/codeorch"
core "agent-system-core"
)
// RegisterCodeTool 注册 run_tool_script。开关关闭时执行会被拒绝,清单侧另行隐藏。
func RegisterCodeTool(r *Registry) error {
return r.Register(core.ToolDesc{
Name: "run_tool_script",
Description: "用一段 Python 依次调用已有工具。禁止再调用解释器、本工具、派发和工作流。失败时会列出已经执行的调用",
Source: core.SourceBuiltin,
Risk: "medium",
Parameters: json.RawMessage(`{"type":"object","properties":{"script":{"type":"string"},"timeout_sec":{"type":"integer"}},"required":["script"]}`),
}, execCodeScript)
}
type toolCaller interface {
ExecuteTool(call core.ToolCallRequest) core.ToolCallResult
}
func execCodeScript(rc ToolExecCtx, args json.RawMessage) (string, error) {
if !rc.SettingBool("runtime.codeOrchestration", false) {
return "", fmt.Errorf("代码编排未开启")
}
caller, ok := rc.(toolCaller)
if !ok {
return "", fmt.Errorf("代码编排未接入工具执行")
}
var req struct {
Script string `json:"script"`
Timeout int `json:"timeout_sec"`
}
if err := json.Unmarshal(args, &req); err != nil {
return "", err
}
if strings.TrimSpace(req.Script) == "" {
return "", fmt.Errorf("script 不能为空")
}
root := ""
if s, ok := rc.(interface{ ConversationSandbox() string }); ok {
root = s.ConversationSandbox()
}
if root == "" {
root = rc.WorkspaceRoot()
}
if root == "" {
return "", fmt.Errorf("代码编排没有会话目录")
}
lim := codeorch.Limits{FullDir: root}
if req.Timeout > 0 {
lim.Timeout = time.Duration(req.Timeout) * time.Second
}
out := codeorch.Run(context.Background(), root, req.Script, lim, nil, func(_ context.Context, call codeorch.Call) (string, error) {
if call.Op != "tool.call" {
return "", fmt.Errorf("不能调用 %s", call.Op)
}
if codeorch.Forbidden(call.Name) {
return "", fmt.Errorf("不能调用 %s", call.Name)
}
res := caller.ExecuteTool(core.ToolCallRequest{Name: call.Name, Arguments: call.Args})
if res.Status == "error" {
return "", fmt.Errorf("%s", res.Output)
}
return res.Output, nil
})
if out.Err != nil {
return out.ModelText(), fmt.Errorf("%s", out.ModelText())
}
return out.ModelText(), nil
}