Merge pull request 'Enable parallel tool execution, fix delegate truncation, sandbox deps layer (E1)' (#127) from feature/agent-e1-parallel-fixes into main
ci / vet-and-test (push) Successful in 46s
ci / vet-and-test (push) Successful in 46s
This commit was merged in pull request #127.
This commit is contained in:
@@ -46,6 +46,7 @@ func main() {
|
||||
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
|
||||
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
|
||||
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := visited(fs)
|
||||
|
||||
@@ -53,7 +54,7 @@ func main() {
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -63,7 +64,7 @@ func main() {
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend,
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
@@ -108,12 +109,13 @@ func runConfig(args []string) int {
|
||||
allowedHosts := fs.String("allowed-hosts", "", "")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "")
|
||||
execBackend := fs.String("exec-backend", "", "")
|
||||
execDepsDir := fs.String("exec-deps-dir", "", "")
|
||||
fs.Parse(args[1:])
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-native updated: 3b92cb63b4...c348441691
@@ -79,6 +79,9 @@ type Options struct {
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
|
||||
// 硬关闭 DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string
|
||||
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
|
||||
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
|
||||
ExecDepsDir string
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -138,6 +141,10 @@ func Run(opt Options) error {
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
|
||||
}
|
||||
if opt.ExecDepsDir != "" {
|
||||
tools.SetExecDepsDir(opt.ExecDepsDir)
|
||||
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
|
||||
}
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ type Config struct {
|
||||
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
|
||||
// 隔离档)。DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string `json:"execBackend"`
|
||||
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
|
||||
ExecDepsDir string `json:"execDepsDir"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -51,6 +53,7 @@ var envKey = map[string]string{
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||||
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
@@ -176,6 +179,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.DisableExecTools
|
||||
case "execBackend":
|
||||
dst = &c.ExecBackend
|
||||
case "execDepsDir":
|
||||
dst = &c.ExecDepsDir
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -207,6 +212,8 @@ func stringField(c Config, f string) string {
|
||||
return c.AllowedHostsRaw
|
||||
case "execBackend":
|
||||
return c.ExecBackend
|
||||
case "execDepsDir":
|
||||
return c.ExecDepsDir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -257,6 +264,8 @@ func setString(c *Config, f, v string) {
|
||||
c.AllowedHostsRaw = v
|
||||
case "execBackend":
|
||||
c.ExecBackend = v
|
||||
case "execDepsDir":
|
||||
c.ExecDepsDir = v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -242,7 +242,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
if stream == nil {
|
||||
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
|
||||
// 此处不再显式包装(防双重)。
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
stream = client.Stream
|
||||
}
|
||||
|
||||
@@ -488,8 +488,15 @@ func agentNameOf(prof core.AgentProfile) string {
|
||||
}
|
||||
|
||||
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
|
||||
func openLLMClient(base, key string) *agentllm.Client {
|
||||
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
|
||||
func openLLMClient(base, key, model string) *agentllm.Client {
|
||||
c := &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
|
||||
// MiniMax 系(interleaved thinking)的 OpenAI 兼容接口默认把思考混在
|
||||
// content 的 <think> 块里;reasoning_split=true 让思考改走独立
|
||||
// reasoning_content 字段(SSE 捕获路径不变,正文流不再夹带思考)。
|
||||
if strings.HasPrefix(model, "MiniMax-M") {
|
||||
c.ReasoningSplit = true
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。
|
||||
|
||||
+131
-27
@@ -49,7 +49,8 @@ type directRun struct {
|
||||
responding bool
|
||||
acting bool
|
||||
queued []agentllm.ToolCall
|
||||
inflight *toolSlot
|
||||
inflight map[string]*toolSlot // 并行执行下按内核事件 CallID 配对
|
||||
toolMu sync.Mutex // 并行 onEvent 下 inflight/queued/finalParts 互斥
|
||||
turnBody string
|
||||
|
||||
evidence []toolEvidence
|
||||
@@ -97,7 +98,7 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
|
||||
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
|
||||
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow, inflight: map[string]*toolSlot{}}
|
||||
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
|
||||
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
|
||||
st.memoryStore = ms.StoreMemory()
|
||||
@@ -127,7 +128,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
for _, d := range decls {
|
||||
name := d.Name
|
||||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||||
return st.runTool(name, string(args))
|
||||
return st.runToolCtx(ctx, name, string(args))
|
||||
}})
|
||||
}
|
||||
// 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、
|
||||
@@ -153,7 +154,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
wsReg := ws
|
||||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||||
res := wsReg.Execute(ctx, name, args)
|
||||
st.noteKernelResult(res)
|
||||
st.noteKernelResult(ctx, res)
|
||||
return res
|
||||
}})
|
||||
}
|
||||
@@ -267,6 +268,8 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
})
|
||||
res, err := nruntime.Run(rc.Context(), nruntime.Config{
|
||||
Model: model,
|
||||
ToolExecution: toolExecMode(eff),
|
||||
SerialTools: serialTools,
|
||||
History: history,
|
||||
Session: sessStore,
|
||||
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
|
||||
@@ -367,20 +370,41 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
|
||||
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
|
||||
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
|
||||
func (st *directRun) noteKernelResult(res agenttools.Result) {
|
||||
if st.inflight == nil {
|
||||
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
|
||||
call, ok := nruntime.ToolCallFromContext(ctx)
|
||||
slot := st.slotFor("")
|
||||
if ok {
|
||||
slot = st.slotFor(call.ID)
|
||||
}
|
||||
if slot == nil {
|
||||
return
|
||||
}
|
||||
summary := summarize(res.Content, nil)
|
||||
if res.IsError {
|
||||
summary = "error: " + summary
|
||||
}
|
||||
st.inflight.summary = summary
|
||||
st.inflight.status = statusOf(nil)
|
||||
slot.summary = summary
|
||||
slot.status = statusOf(nil)
|
||||
if res.IsError {
|
||||
st.inflight.status = "error"
|
||||
slot.status = "error"
|
||||
}
|
||||
st.inflight.verifyText = tailRunes(res.Content, 600)
|
||||
slot.verifyText = tailRunes(res.Content, 600)
|
||||
}
|
||||
|
||||
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
|
||||
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
|
||||
func (st *directRun) slotFor(callID string) *toolSlot {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
if callID != "" {
|
||||
return st.inflight[callID]
|
||||
}
|
||||
if len(st.inflight) == 1 {
|
||||
for _, slot := range st.inflight {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||
@@ -451,7 +475,9 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
|
||||
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
|
||||
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
|
||||
st.toolMu.Lock()
|
||||
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
|
||||
st.toolMu.Unlock()
|
||||
hasCalls := len(ev.Message.ToolCalls) > 0
|
||||
if st.turnThink.Len() > 0 {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartThinking, Text: st.turnThink.String(), Process: true, DurationMs: durationMs(st.thinkStart, st.thinkEnd)})
|
||||
@@ -484,13 +510,19 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
if len(st.queued) == 0 {
|
||||
return
|
||||
}
|
||||
call := st.queued[0]
|
||||
st.queued = st.queued[1:]
|
||||
st.inflight = &toolSlot{call: call, start: st.l.now()}
|
||||
call, ok := st.takeQueued(ev.CallID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
st.toolMu.Lock()
|
||||
st.inflight[call.ID] = &toolSlot{call: call, start: st.l.now()}
|
||||
st.toolMu.Unlock()
|
||||
_ = st.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
|
||||
case "tool_execution_end":
|
||||
slot := st.inflight
|
||||
st.toolMu.Lock()
|
||||
slot := st.takeInflight(ev.CallID)
|
||||
if slot == nil {
|
||||
st.toolMu.Unlock()
|
||||
return
|
||||
}
|
||||
end := st.l.now()
|
||||
@@ -502,8 +534,8 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
st.deliverables = append(st.deliverables, slot.artifact)
|
||||
}
|
||||
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
|
||||
st.toolMu.Unlock()
|
||||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
|
||||
st.inflight = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,10 +690,17 @@ func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nru
|
||||
}
|
||||
|
||||
func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
call := agentllm.ToolCall{Name: name, Arguments: args}
|
||||
if st.inflight != nil {
|
||||
call = st.inflight.call
|
||||
return st.runToolCtx(context.Background(), name, args)
|
||||
}
|
||||
|
||||
// runToolCtx 执行一个工具调用;ctx 携带内核注入的当前调用(并行执行下按
|
||||
// CallID 定位在途槽位),无注入时回落唯一在途槽位(旧内核/测试直调)。
|
||||
func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttools.Result {
|
||||
call, _ := nruntime.ToolCallFromContext(ctx)
|
||||
if call.ID == "" {
|
||||
call = agentllm.ToolCall{Name: name, Arguments: args}
|
||||
}
|
||||
slot := st.slotFor(call.ID)
|
||||
var out string
|
||||
var err error
|
||||
switch call.Name {
|
||||
@@ -710,7 +749,10 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
if serr != nil {
|
||||
out = "子代理失败:" + serr.Error()
|
||||
} else {
|
||||
out = orDefault(res.Summary, "子代理完成")
|
||||
// F1 修复:回传子代理结论全文(capSubagentResult 已做 32k 截断与
|
||||
// 超长落盘指针),Summary 只是 160 字摘要——只回摘要会让父代理在
|
||||
// 摘要的摘要上凭空补细节。
|
||||
out = orDefault(res.Text, orDefault(res.Summary, "子代理完成"))
|
||||
st.pinSubagentArtifacts(res.Summary + "\n" + res.Text)
|
||||
}
|
||||
case toolReview:
|
||||
@@ -752,8 +794,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
} else {
|
||||
out = res.Output
|
||||
}
|
||||
if res.Artifact != nil && st.inflight != nil {
|
||||
st.inflight.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
if res.Artifact != nil && slot != nil {
|
||||
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
if st.cardflow && res.Truncated && !st.explicitCard {
|
||||
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
|
||||
if ferr == nil {
|
||||
@@ -761,8 +803,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.inflight != nil {
|
||||
st.inflight.truncated = res.Truncated
|
||||
if slot != nil {
|
||||
slot.truncated = res.Truncated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,10 +814,10 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
summary = "error: " + err.Error()
|
||||
out = summary
|
||||
}
|
||||
if st.inflight != nil {
|
||||
st.inflight.summary = summary
|
||||
st.inflight.status = status
|
||||
st.inflight.verifyText = tailRunes(out, 600)
|
||||
if slot != nil {
|
||||
slot.summary = summary
|
||||
slot.status = status
|
||||
slot.verifyText = tailRunes(out, 600)
|
||||
}
|
||||
if r := []rune(out); len(r) > 16000 {
|
||||
out = string(r[:16000]) + "…(超长截断)"
|
||||
@@ -966,3 +1008,65 @@ func errString(err error) string {
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// ---- 并行工具执行的辅助(E1/F5)----
|
||||
|
||||
// serialTools 顺序敏感或带会话级副作用的工具:作为内核 SerialTools 传入——
|
||||
// 并行执行下这些调用按模型声明顺序逐个执行(互斥且保序;只互斥不保序会让
|
||||
// 流式卡 card_begin→append→commit 乱序,实测踩过)。卡片流四步有先后依赖,
|
||||
// 计划/待办是整表更新,工作流与小队编排派生 run,执行原语与文件编辑共享会话
|
||||
// 工作区。读检索类与 delegate_task/review_findings 并行。
|
||||
var serialTools = map[string]bool{
|
||||
toolPlan: true, toolTodos: true,
|
||||
toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true,
|
||||
toolRunWorkflow: true, toolSquad: true,
|
||||
"run_shell": true, "run_python": true, "register_artifact": true,
|
||||
"edit": true, "multi_edit": true,
|
||||
}
|
||||
|
||||
// toolExecMode 同回合多工具调用的执行模式:runtime.parallelTools(缺省开)
|
||||
// 为 parallel(顺序敏感工具经 Config.SerialTools 由内核保序),关闭时空串
|
||||
// 回落内核缺省 sequential。
|
||||
func toolExecMode(eff core.EffectiveSettings) string {
|
||||
if eff.GetBool("runtime.parallelTools", true) {
|
||||
return nruntime.ExecParallel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// takeQueued 按内核事件 CallID 取出队列中对应调用(并行下 start 事件乱序到达);
|
||||
// CallID 为空(旧内核/未知来源)时按原先进先出。调用方须持有 toolMu。
|
||||
func (st *directRun) takeQueued(callID string) (agentllm.ToolCall, bool) {
|
||||
if callID != "" {
|
||||
for i, c := range st.queued {
|
||||
if c.ID == callID {
|
||||
st.queued = append(st.queued[:i], st.queued[i+1:]...)
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return agentllm.ToolCall{}, false
|
||||
}
|
||||
if len(st.queued) == 0 {
|
||||
return agentllm.ToolCall{}, false
|
||||
}
|
||||
c := st.queued[0]
|
||||
st.queued = st.queued[1:]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// takeInflight 按事件 CallID 取出并删除在途槽位;CallID 为空且恰有一个在途
|
||||
// 槽位时取之(兼容旧内核)。调用方须持有 toolMu。
|
||||
func (st *directRun) takeInflight(callID string) *toolSlot {
|
||||
if callID != "" {
|
||||
slot := st.inflight[callID]
|
||||
delete(st.inflight, callID)
|
||||
return slot
|
||||
}
|
||||
if len(st.inflight) == 1 {
|
||||
for _, slot := range st.inflight {
|
||||
clear(st.inflight)
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- F1:delegate_task 回传子代理全文,不再只有 160 字摘要 ----
|
||||
|
||||
func TestDelegateToolReturnsFullText(t *testing.T) {
|
||||
full := strings.Repeat("子代理结论细节。", 60) // 480 字,远超 160
|
||||
f := &fakeRunCtx{spawnAgentFn: func(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||||
return core.AgentResultPayload{Status: core.RunCompleted, Text: full, Summary: "完成(160字内摘要)"}, nil
|
||||
}}
|
||||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||||
res := st.runTool("delegate_task", `{"name":"调研","task":"查证X"}`)
|
||||
if res.IsError {
|
||||
t.Fatalf("delegate failed: %s", res.Content)
|
||||
}
|
||||
if !strings.Contains(res.Content, "子代理结论细节。子代理结论细节。子代理结论细节。") {
|
||||
t.Fatalf("delegate result must carry the full child text, got: %.120s…", res.Content)
|
||||
}
|
||||
if strings.Contains(res.Content, "160字内摘要") {
|
||||
t.Fatalf("summary must be fallback only, got: %.120s…", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F5:并行执行下工具事件按 CallID 配对 ----
|
||||
|
||||
func TestToolEventsPairByCallID(t *testing.T) {
|
||||
f := &fakeRunCtx{}
|
||||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||||
st.queued = []agentllm.ToolCall{
|
||||
{ID: "c1", Name: "knowledge_search", Arguments: `{}`},
|
||||
{ID: "c2", Name: "web_search", Arguments: `{}`},
|
||||
}
|
||||
// 乱序进出:c1 先开始,c2 先结束(并行下的真实时序)。
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "knowledge_search", CallID: "c1"})
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "web_search", CallID: "c2"})
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "web_search", CallID: "c2"})
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "knowledge_search", CallID: "c1"})
|
||||
|
||||
if len(st.inflight) != 0 {
|
||||
t.Fatalf("inflight must be empty after both ends: %v", st.inflight)
|
||||
}
|
||||
if len(st.finalParts) != 4 {
|
||||
t.Fatalf("finalParts = %d (want 4: call+result ×2)", len(st.finalParts))
|
||||
}
|
||||
// 结束顺序决定部件顺序:先 c2 后 c1。
|
||||
if got := st.finalParts[0].ToolCall.ID; got != "c2" {
|
||||
t.Fatalf("first tool part = %s (want c2)", got)
|
||||
}
|
||||
if got := st.finalParts[1].ToolResult.CallID; got != "c2" {
|
||||
t.Fatalf("first result part = %s (want c2)", got)
|
||||
}
|
||||
if got := st.finalParts[2].ToolCall.ID; got != "c1" {
|
||||
t.Fatalf("second tool part = %s (want c1)", got)
|
||||
}
|
||||
if got := st.finalParts[3].ToolResult.CallID; got != "c1" {
|
||||
t.Fatalf("second result part = %s (want c1)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F5:执行模式与串行集合 ----
|
||||
|
||||
func TestToolExecModeAndSerialSet(t *testing.T) {
|
||||
if got := toolExecMode(core.EffectiveSettings{}); got != nruntime.ExecParallel {
|
||||
t.Fatalf("default must be parallel, got %q", got)
|
||||
}
|
||||
off := core.EffectiveSettings{"runtime": map[string]any{"parallelTools": false}}
|
||||
if got := toolExecMode(off); got != "" {
|
||||
t.Fatalf("parallelTools=false must fall back to sequential, got %q", got)
|
||||
}
|
||||
// 顺序敏感工具必须串行;可并行的读检索与委派不得误入。
|
||||
for _, name := range []string{"update_plan", "update_todos", "submit_card", "card_begin", "card_append", "card_commit", "run_workflow", "squad_orchestrate", "run_shell", "run_python", "register_artifact"} {
|
||||
if !serialTools[name] {
|
||||
t.Fatalf("serialTools missing %s", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"delegate_task", "review_findings", "knowledge_search", "web_search", "fs_read_file", "write_artifact"} {
|
||||
if serialTools[name] {
|
||||
t.Fatalf("serialTools must not contain %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F2:MiniMax 前缀自动置位 reasoning_split ----
|
||||
|
||||
func TestOpenLLMClientReasoningSplitAuto(t *testing.T) {
|
||||
if c := openLLMClient("http://x", "k", "MiniMax-M2.7-highspeed"); !c.ReasoningSplit {
|
||||
t.Fatal("MiniMax model must set ReasoningSplit")
|
||||
}
|
||||
if c := openLLMClient("http://x", "k", "MiniMax-M3"); !c.ReasoningSplit {
|
||||
t.Fatal("MiniMax M3 must set ReasoningSplit")
|
||||
}
|
||||
if c := openLLMClient("http://x", "k", "claude-sonnet-5"); c.ReasoningSplit {
|
||||
t.Fatal("non-MiniMax model must not set ReasoningSplit")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type fakeRunCtx struct {
|
||||
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
|
||||
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
|
||||
bgCalls int
|
||||
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) Context() context.Context { return f.ctx }
|
||||
@@ -78,6 +79,15 @@ func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
func (f *fakeRunCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
|
||||
return core.AgentResultPayload{}, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// SpawnAgent 供 delegate_task 直连分支(E1/F1 测试注入);未设置时与
|
||||
// SpawnSubagent 一致报错。
|
||||
func (f *fakeRunCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||||
if f.spawnAgentFn != nil {
|
||||
return f.spawnAgentFn(agentID, name, task, hint)
|
||||
}
|
||||
return core.AgentResultPayload{}, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (f *fakeRunCtx) UpdatePlan(p core.PlanSnapshot) error { return nil }
|
||||
func (f *fakeRunCtx) UpdateTodos(l core.TodoList) error { return nil }
|
||||
func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
|
||||
@@ -139,7 +139,7 @@ func newModelRouter(rc RunCtx) modelRouter {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return modelRouter{}
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
return modelRouter{stream: client.Stream, model: route.Model}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base)
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
stream = client.Stream
|
||||
}
|
||||
if spec.Model == "chat" {
|
||||
|
||||
@@ -41,7 +41,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
|
||||
stream = agentllm.WithFallback(
|
||||
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
|
||||
|
||||
@@ -79,7 +79,7 @@ func judgeStreamFor(rc RunCtx) agentllm.StreamFunc {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return nil
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
model := route.Model
|
||||
return func(ctx context.Context, _ string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
return client.Stream(ctx, model, msgs, tools, opts)
|
||||
|
||||
@@ -91,6 +91,7 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
|
||||
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
|
||||
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
|
||||
{Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"},
|
||||
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
|
||||
{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: "终轮确定性检查与独立验证"},
|
||||
|
||||
@@ -30,6 +30,10 @@ type ExecSpec struct {
|
||||
Dir string // 会话沙箱工作目录(sandbox 档唯一可写挂载)
|
||||
Env []string // 最小环境(execEnv 口径)
|
||||
SkillsDir string // 技能根(sandbox 档只读挂载;空 = 不挂)
|
||||
// DepsDir 依赖层(sandbox 档只读挂载;空 = 不挂):python-docx 等装在
|
||||
// ~/.local 下会被 --tmpfs /home 遮蔽,只读挂回其原路径并并入 PYTHONPATH,
|
||||
// 交付物生成链路(F3)在隔离档下才可用。
|
||||
DepsDir string
|
||||
Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用)
|
||||
}
|
||||
|
||||
@@ -168,6 +172,10 @@ func sandboxArgv(spec ExecSpec) []string {
|
||||
if spec.SkillsDir != "" {
|
||||
argv = append(argv, "--ro-bind", spec.SkillsDir, spec.SkillsDir)
|
||||
}
|
||||
if spec.DepsDir != "" {
|
||||
// 必须后于 --tmpfs /home:先遮蔽再只读挂回依赖层(挂载顺序敏感)。
|
||||
argv = append(argv, "--ro-bind", spec.DepsDir, spec.DepsDir)
|
||||
}
|
||||
argv = append(argv, "--chdir", spec.Dir, "--clearenv")
|
||||
for _, kv := range spec.Env {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 {
|
||||
|
||||
@@ -283,3 +283,45 @@ func TestDirectBackendRunInterpreterUnchanged(t *testing.T) {
|
||||
t.Fatalf("direct timeout regression: err=%v got=%q", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxArgvDepsLayer F3:依赖层在 --tmpfs /home 之后只读挂回原路径
|
||||
// (顺序敏感——先遮蔽再挂回),且挂载点存在性由 bwrap 创建。
|
||||
func TestSandboxArgvDepsLayer(t *testing.T) {
|
||||
argv := sandboxArgv(ExecSpec{
|
||||
Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"},
|
||||
Dir: "/data/s", DepsDir: "/home/u/.local/lib/python3.14/site-packages",
|
||||
})
|
||||
joined := strings.Join(argv, "\x00")
|
||||
deps := "--ro-bind\x00/home/u/.local/lib/python3.14/site-packages\x00/home/u/.local/lib/python3.14/site-packages"
|
||||
if !strings.Contains(joined, deps) {
|
||||
t.Fatalf("sandbox argv missing deps ro-bind: %v", argv)
|
||||
}
|
||||
tmpHome := strings.Index(joined, "--tmpfs\x00/home")
|
||||
depsAt := strings.Index(joined, deps)
|
||||
if tmpHome < 0 || depsAt < tmpHome {
|
||||
t.Fatalf("deps bind must come after --tmpfs /home: %v", argv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecEnvJoinsPythonPath F3:依赖层与技能根合成一条 PYTHONPATH,
|
||||
// 不允许重复键互相覆盖(否则只挂载了依赖层却 import 不到)。
|
||||
func TestExecEnvJoinsPythonPath(t *testing.T) {
|
||||
oldSkills, oldDeps := skillsRoot, execDepsDir
|
||||
defer func() { skillsRoot, execDepsDir = oldSkills, oldDeps }()
|
||||
skillsRoot = "/data/skills"
|
||||
execDepsDir = "/opt/pydeps"
|
||||
env := execEnv()
|
||||
count, value := 0, ""
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||
count++
|
||||
value = strings.TrimPrefix(kv, "PYTHONPATH=")
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("PYTHONPATH entries = %d(必须合成一条): %v", count, env)
|
||||
}
|
||||
if !strings.Contains(value, "/opt/pydeps") || !strings.Contains(value, "/data/skills/house-style") {
|
||||
t.Fatalf("PYTHONPATH = %q(应同时含依赖层与 house-style)", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ var skillsRoot string
|
||||
// SetSkillsRoot 让 run_python / run_shell 能 import housestyle,并看到技能目录。
|
||||
func SetSkillsRoot(dir string) { skillsRoot = dir }
|
||||
|
||||
// execDepsDir 是 sandbox 档的只读依赖层(site-packages 等);组合根启动时注入。
|
||||
var execDepsDir string
|
||||
|
||||
// SetExecDepsDir 配置 sandbox 档依赖层目录(空 = 不挂载,现状)。
|
||||
func SetExecDepsDir(dir string) { execDepsDir = dir }
|
||||
|
||||
// execEnv 最小环境:PATH/HOME/LANG/TZ/TERM 继承(pip --user、字体、中文 locale),
|
||||
// 其余变量不带入子进程。技能根配置后追加 AGENT_SKILLS_DIR 与 PYTHONPATH。
|
||||
func execEnv() []string {
|
||||
@@ -68,11 +74,16 @@ func execEnv() []string {
|
||||
}
|
||||
}
|
||||
out = append(out, "PYTHONDONTWRITEBYTECODE=1")
|
||||
var pypaths []string
|
||||
if execDepsDir != "" {
|
||||
pypaths = append(pypaths, execDepsDir)
|
||||
}
|
||||
if skillsRoot != "" {
|
||||
out = append(out,
|
||||
"AGENT_SKILLS_DIR="+skillsRoot,
|
||||
"PYTHONPATH="+filepath.Join(skillsRoot, "house-style"),
|
||||
)
|
||||
out = append(out, "AGENT_SKILLS_DIR="+skillsRoot)
|
||||
pypaths = append(pypaths, filepath.Join(skillsRoot, "house-style"))
|
||||
}
|
||||
if len(pypaths) > 0 { // 合成一条,避免重复键互相覆盖
|
||||
out = append(out, "PYTHONPATH="+strings.Join(pypaths, string(os.PathListSeparator)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -148,7 +159,7 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
|
||||
defer cancel()
|
||||
spec := ExecSpec{
|
||||
Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnv(),
|
||||
SkillsDir: skillsRoot, Timeout: time.Duration(timeoutS) * time.Second,
|
||||
SkillsDir: skillsRoot, DepsDir: execDepsDir, Timeout: time.Duration(timeoutS) * time.Second,
|
||||
}
|
||||
pr, pw := io.Pipe()
|
||||
var buf bytes.Buffer
|
||||
|
||||
Reference in New Issue
Block a user