Stream tool progress and drop hard agent limits
Why: Tool calls stayed invisible until execution finished, and turn, tool, timeout, and output caps stopped long tasks early. Evidence: go test ./internal/runtime/ ./internal/tools/ ./internal/prompts/ ./internal/squadadapter/ ./internal/runmgr/ ./internal/app/ ./internal/settings/ Impact: tool.call.delta and tool.output reach the client; default profile, subagents, and squad no longer cap turns or tool calls; exec timeout defaults to 600s with no hard ceiling. A profile or subagent maxTurns above 0 still applies. Refs: feature/stream-display-overhaul Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-native updated: a34689c78f...c6d867cde7
Vendored
+1
-1
Submodule deps/agent-system-core updated: f93f473a0e...f08a4a90c6
+3
-3
@@ -149,10 +149,10 @@ func Run(opt Options) error {
|
||||
return squad.AgentNativeConfig{}, false
|
||||
}
|
||||
cfg := squad.AgentNativeConfig{
|
||||
Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key},
|
||||
Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}},
|
||||
DefaultModel: route.Model,
|
||||
MaxTurns: 12,
|
||||
Timeout: 10 * time.Minute,
|
||||
MaxTurns: 0,
|
||||
Timeout: 0,
|
||||
Subagent: squadadapter.SubagentFactory(rc),
|
||||
}
|
||||
if keys := splitKeyPool(effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
|
||||
|
||||
@@ -19,8 +19,6 @@ const DefaultProfileID = "default"
|
||||
const defaultSystemPrompt = ""
|
||||
|
||||
func DefaultProfile() core.AgentProfile {
|
||||
maxTools := 40
|
||||
maxTurns := 16
|
||||
return core.AgentProfile{
|
||||
ID: DefaultProfileID,
|
||||
Name: "通用助手",
|
||||
@@ -32,7 +30,8 @@ func DefaultProfile() core.AgentProfile {
|
||||
Tools: core.ToolPolicy{Allow: []string{"*"}, Confirm: []string{"http."}},
|
||||
Context: core.ContextPolicy{MaxHistoryMessages: 40, MaxToolResultChars: 20000},
|
||||
HITL: core.HITLPolicy{AskOnAmbiguity: true},
|
||||
Budget: core.RunBudget{MaxToolCalls: &maxTools, MaxTurns: &maxTurns},
|
||||
// 空预算 = 不限制轮次、工具调用数和输出 token。用户自建画像可以自行设上限。
|
||||
Budget: core.RunBudget{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 探索子代理(只读)
|
||||
description: 只读检索专家——定位文件/内容/事实,不做任何修改
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
|
||||
model: lightweight
|
||||
maxTurns: 8
|
||||
---
|
||||
你是只读探索子代理,职责是高效且彻底地定位目标信息。
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 通用子代理
|
||||
description: 独立完成子任务并返回结构化结果(默认子代理提示词)
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review
|
||||
model: lightweight
|
||||
maxTurns: 12
|
||||
---
|
||||
你是被派生的子代理,负责独立完成一个子任务。
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 规划子代理(只出计划)
|
||||
description: 分析并产出可执行的分步实现计划,不执行实现
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
|
||||
model: lightweight
|
||||
maxTurns: 6
|
||||
---
|
||||
你是规划子代理:负责分析任务并产出详细、可执行的实现计划。你是规划者,不是实现者——不修改任何对象,只输出计划。
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 调研子代理
|
||||
description: 围绕问题多源调研,输出带依据的结论
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
|
||||
model: lightweight
|
||||
maxTurns: 10
|
||||
---
|
||||
你是调研子代理,围绕给定问题收集信息并给出结论。
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 评审子代理(只读找问题)
|
||||
description: 只读审查材料,输出 JSON 问题清单,不修改任何内容
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, quality_review
|
||||
model: lightweight
|
||||
maxTurns: 8
|
||||
---
|
||||
你是只读评审子代理。只找问题,不修改材料,也不下最终通过结论。
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 验证子代理(对抗审查)
|
||||
description: 对交付物做对抗性验证,必须输出明确裁决
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, quality_review
|
||||
model: lightweight
|
||||
maxTurns: 8
|
||||
---
|
||||
你是验证子代理,对指定交付物做对抗性验证:假设它有问题,努力找出问题。
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 工作流规划子代理
|
||||
description: 把目标写成可校验的工作流 JSON,不执行步骤
|
||||
tools: knowledge_search, time_now
|
||||
model: lightweight
|
||||
maxTurns: 4
|
||||
---
|
||||
你是工作流规划子代理。用户给你的是待规划的目标,不是要你现在完成的任务。不要调用工具,不要回答目标本身。只输出一个 JSON 对象,不要解释,不要写脚本。
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ title: 撰稿子代理
|
||||
description: 把要点与调研结论整理为完整成文的交付文档
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, run_python, register_artifact
|
||||
model: chat
|
||||
maxTurns: 12
|
||||
---
|
||||
你是撰稿子代理,负责把前序要点与调研结论整理成完整的交付文档。
|
||||
|
||||
|
||||
@@ -133,15 +133,13 @@ type SubagentSpec struct {
|
||||
MaxTurns int
|
||||
}
|
||||
|
||||
// specFromModule 由模块字段派生 profile。未写的 model / maxTurns 用默认值。
|
||||
// specFromModule 由模块字段派生 profile。未写 model 时用 lightweight;
|
||||
// maxTurns 为 0 表示不限制轮次。
|
||||
func specFromModule(m core.PromptModule) SubagentSpec {
|
||||
spec := SubagentSpec{Tools: m.Tools, Model: m.Model, MaxTurns: m.MaxTurns}
|
||||
if spec.Model == "" {
|
||||
spec.Model = "lightweight"
|
||||
}
|
||||
if spec.MaxTurns <= 0 {
|
||||
spec.MaxTurns = 8
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
|
||||
@@ -360,12 +360,12 @@ func TestBuiltinSubagentSpecs(t *testing.T) {
|
||||
tool string
|
||||
ban string
|
||||
}{
|
||||
"agent.sub.explore": {"lightweight", 8, "knowledge_search", "run_python"},
|
||||
"agent.sub.research": {"lightweight", 10, "knowledge_search", "write_artifact"},
|
||||
"agent.sub.plan": {"lightweight", 6, "knowledge_search", "register_artifact"},
|
||||
"agent.sub.verify": {"lightweight", 8, "quality_review", "run_python"},
|
||||
"agent.sub.general": {"lightweight", 12, "write_artifact", "run_python"},
|
||||
"agent.sub.writer": {"chat", 12, "run_python", "delegate_task"},
|
||||
"agent.sub.explore": {"lightweight", 0, "knowledge_search", "run_python"},
|
||||
"agent.sub.research": {"lightweight", 0, "knowledge_search", "write_artifact"},
|
||||
"agent.sub.plan": {"lightweight", 0, "knowledge_search", "register_artifact"},
|
||||
"agent.sub.verify": {"lightweight", 0, "quality_review", "run_python"},
|
||||
"agent.sub.general": {"lightweight", 0, "write_artifact", "run_python"},
|
||||
"agent.sub.writer": {"chat", 0, "run_python", "delegate_task"},
|
||||
}
|
||||
for id, w := range want {
|
||||
spec, ok := svc.SubagentSpec("", id)
|
||||
@@ -437,7 +437,6 @@ func TestSubagentOverrideRejected(t *testing.T) {
|
||||
cases := []core.PromptModule{
|
||||
{ID: "task.summarize", Layer: core.LayerTask, Title: "总结", Body: "正文", Tools: []string{"time_now"}},
|
||||
{ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Model: "huge"},
|
||||
{ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", MaxTurns: 31},
|
||||
{ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", MaxTurns: -1},
|
||||
{ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"delegate_task"}},
|
||||
{ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"squad_orchestrate"}},
|
||||
|
||||
@@ -198,8 +198,8 @@ func validateSubagentOverride(m core.PromptModule) error {
|
||||
if m.Model != "" && m.Model != "lightweight" && m.Model != "chat" {
|
||||
return fmt.Errorf("model 只能是 lightweight 或 chat")
|
||||
}
|
||||
if m.MaxTurns < 0 || m.MaxTurns > 30 {
|
||||
return fmt.Errorf("maxTurns 必须在 0 到 30 之间")
|
||||
if m.MaxTurns < 0 {
|
||||
return fmt.Errorf("maxTurns 不能为负;0 表示不限制")
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if _, bad := dispatchTools[name]; bad {
|
||||
|
||||
@@ -73,7 +73,7 @@ func (eb *eventBuffer) stop() {
|
||||
// isDelta 标记高频可缓冲事件。
|
||||
func isDelta(typ core.StreamEventType) bool {
|
||||
switch typ {
|
||||
case core.EvMessageDelta, core.EvThinkingDelta, core.EvAgentProgress:
|
||||
case core.EvMessageDelta, core.EvThinkingDelta, core.EvAgentProgress, core.EvToolCallDelta, core.EvToolOutput:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -591,7 +591,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||||
}
|
||||
}
|
||||
tc := &toolCtx{rc: c}
|
||||
tc := &toolCtx{rc: c, callID: call.CallID}
|
||||
res := c.mgr.registry.Execute(tc, call)
|
||||
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
|
||||
// 写入的工件(write_artifact / fs_write_file / register_artifact / 未来执行
|
||||
@@ -621,8 +621,9 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
// toolCtx 包装 runCtx 供工具执行;arts 记录本调用期间写入的工件,
|
||||
// ExecuteTool 据此回填 res.Artifact(对话内交付物卡片的来源)。
|
||||
type toolCtx struct {
|
||||
rc *runCtx
|
||||
arts []core.Artifact
|
||||
rc *runCtx
|
||||
arts []core.Artifact
|
||||
callID string
|
||||
}
|
||||
|
||||
func (t *toolCtx) ProjectID() string { return t.rc.run.ProjectID }
|
||||
@@ -642,6 +643,14 @@ func (t *toolCtx) OwnerID() string { return t.rc.run.Owner
|
||||
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
|
||||
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
|
||||
|
||||
// EmitToolOutput 把解释器执行中的输出增量发到会话事件流。
|
||||
func (t *toolCtx) EmitToolOutput(stream, text string) {
|
||||
if t.callID == "" || text == "" {
|
||||
return
|
||||
}
|
||||
_ = t.rc.Emit(core.EvToolOutput, core.ToolOutputPayload{CallID: t.callID, Stream: stream, Text: text})
|
||||
}
|
||||
|
||||
// ---- 跨会话读取门禁(提案 2026-09-30-cross-session-reading;fail-closed)----
|
||||
//
|
||||
// canRead(A→B) = crossSession.read(项目闸门) AND A.allowCrossRead(出站,仅读他人时)
|
||||
@@ -888,6 +897,9 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
m.mu.Lock()
|
||||
parts := append([]core.Part{}, rc.h.assistant...)
|
||||
m.mu.Unlock()
|
||||
if errPart != nil {
|
||||
parts = append(parts, core.Part{Type: core.PartError, Err: errPart})
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
msg := core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
|
||||
if err := m.st.AppendMessage(&msg); err == nil {
|
||||
@@ -905,11 +917,6 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
}
|
||||
}
|
||||
}
|
||||
if errPart != nil {
|
||||
m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
|
||||
}
|
||||
} else if errPart != nil {
|
||||
m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
|
||||
}
|
||||
|
||||
// 流式卡兜底(D45):run 终态时仍有未 commit 的流式卡——按 run 结果置
|
||||
|
||||
+43
-14
@@ -3,10 +3,13 @@ package runtime
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/prompts"
|
||||
@@ -196,7 +199,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
|
||||
stream := l.Stream
|
||||
if stream == nil {
|
||||
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
stream = client.Stream
|
||||
}
|
||||
|
||||
@@ -279,22 +282,48 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
}
|
||||
}
|
||||
|
||||
maxTurns := 16
|
||||
if prof.Budget.MaxTurns != nil {
|
||||
maxTurns = *prof.Budget.MaxTurns
|
||||
}
|
||||
maxTools := 40
|
||||
if prof.Budget.MaxToolCalls != nil {
|
||||
maxTools = *prof.Budget.MaxToolCalls
|
||||
}
|
||||
// 输出预算:画像 budget.maxTokens 配置时下发(0 = 走服务端默认,不限制输出长度)。
|
||||
streamOpts := agentllm.StreamOptions{}
|
||||
if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 {
|
||||
streamOpts.MaxTokens = *prof.Budget.MaxTokens
|
||||
}
|
||||
maxTurns, maxTools := loopCaps(prof.Budget)
|
||||
streamOpts := agentllm.StreamOptions{MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens")}
|
||||
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
|
||||
}
|
||||
|
||||
// loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制;
|
||||
// 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。
|
||||
func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) {
|
||||
maxTurns = math.MaxInt32
|
||||
if budget.MaxTurns != nil && *budget.MaxTurns > 0 {
|
||||
maxTurns = *budget.MaxTurns
|
||||
}
|
||||
if budget.MaxToolCalls != nil && *budget.MaxToolCalls > 0 {
|
||||
maxTools = *budget.MaxToolCalls
|
||||
}
|
||||
return maxTurns, maxTools
|
||||
}
|
||||
|
||||
func toolBudget(maxTools int) *nruntime.Budget {
|
||||
if maxTools <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &nruntime.Budget{MaxToolCalls: maxTools}
|
||||
}
|
||||
|
||||
// outputTokenCap 优先用画像 budget.maxTokens,否则用设置项。两者都是 0 表示不下发,由服务端决定。
|
||||
func outputTokenCap(budget core.RunBudget, eff core.EffectiveSettings, key string) int {
|
||||
if budget.MaxTokens != nil && *budget.MaxTokens > 0 {
|
||||
return *budget.MaxTokens
|
||||
}
|
||||
n := int(eff.GetNumber(key, 0))
|
||||
if n > 0 {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
|
||||
func openLLMClient(base, key string) *agentllm.Client {
|
||||
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
|
||||
}
|
||||
|
||||
// SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。
|
||||
type SubLLMRunner struct {
|
||||
// Prompts 提示词库(nil = 使用内联回退文案与只读回退 spec)。
|
||||
|
||||
@@ -118,6 +118,9 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
go func() {
|
||||
defer close(out)
|
||||
for ev := range in {
|
||||
if ev.Kind == agentllm.KindToolCallDelta {
|
||||
st.noteToolDelta(ev)
|
||||
}
|
||||
if ev.Kind == agentllm.KindMessageEnd {
|
||||
if ev.Message.Role == "" {
|
||||
ev.Message.Role = agentllm.RoleAssistant
|
||||
@@ -137,17 +140,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
}
|
||||
|
||||
res, err := nruntime.Run(rc.Context(), nruntime.Config{
|
||||
Model: model,
|
||||
History: history,
|
||||
Tools: reg,
|
||||
Stream: wrapped,
|
||||
MaxTurns: maxTurns,
|
||||
Budget: &nruntime.Budget{MaxToolCalls: maxTools},
|
||||
RepeatGuard: true,
|
||||
ContinueOnLength: true,
|
||||
OnEvent: st.onEvent,
|
||||
Stop: st.stop,
|
||||
Reminders: st.reminders,
|
||||
Model: model,
|
||||
History: history,
|
||||
Tools: reg,
|
||||
Stream: wrapped,
|
||||
MaxTurns: maxTurns,
|
||||
Budget: toolBudget(maxTools),
|
||||
RepeatGuard: true,
|
||||
ContinueOnLength: true,
|
||||
MaxLengthContinuations: -1,
|
||||
OnEvent: st.onEvent,
|
||||
Stop: st.stop,
|
||||
Reminders: st.reminders,
|
||||
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
|
||||
return st.runTool(call.Name, call.Arguments)
|
||||
},
|
||||
@@ -177,6 +181,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||
// 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。
|
||||
func (st *directRun) noteToolDelta(ev agentllm.Event) {
|
||||
if !st.acting {
|
||||
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
|
||||
st.acting = true
|
||||
}
|
||||
_ = st.rc.Emit(core.EvToolCallDelta, core.ToolCallDeltaPayload{
|
||||
Index: ev.ToolIndex, CallID: ev.ToolID, Name: ev.ToolName, Delta: ev.ArgumentsDelta,
|
||||
})
|
||||
}
|
||||
|
||||
func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
switch ev.Type {
|
||||
case "turn_start":
|
||||
@@ -262,7 +278,7 @@ 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})
|
||||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated})
|
||||
_ = 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,6 +987,59 @@ func TestLLMRunnerContinuesWhenTruncated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallDeltaBeforeExecution(t *testing.T) {
|
||||
call := toolCall("echo_tool", `{"script":"print(1)"}`)
|
||||
turns := [][]agentllm.Event{
|
||||
{
|
||||
{Kind: agentllm.KindTextDelta, Text: "我来跑一下。"},
|
||||
{Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ToolID: call.ID, ToolName: call.Name, ArgumentsDelta: `{"script":`},
|
||||
{Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ArgumentsDelta: `"print(1)"}`},
|
||||
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{call}}},
|
||||
},
|
||||
textDeltaTurn("跑完了。"),
|
||||
}
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
|
||||
r := &LLMRunner{Stream: scriptStream(turns, nil)}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []core.StreamEventType
|
||||
for _, ev := range rc.events {
|
||||
switch ev.typ {
|
||||
case core.EvToolCallDelta, core.EvToolCall, core.EvToolResult:
|
||||
order = append(order, ev.typ)
|
||||
}
|
||||
}
|
||||
want := []core.StreamEventType{core.EvToolCallDelta, core.EvToolCallDelta, core.EvToolCall, core.EvToolResult}
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("order = %v", order)
|
||||
}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("order = %v", order)
|
||||
}
|
||||
}
|
||||
var delta core.ToolCallDeltaPayload
|
||||
for _, ev := range rc.events {
|
||||
if ev.typ == core.EvToolCallDelta {
|
||||
delta = ev.payload.(core.ToolCallDeltaPayload)
|
||||
break
|
||||
}
|
||||
}
|
||||
if delta.Name != call.Name || delta.Index != 0 || delta.Delta == "" {
|
||||
t.Fatalf("first delta = %+v", delta)
|
||||
}
|
||||
var result core.ToolResultPayload
|
||||
for _, ev := range rc.events {
|
||||
if ev.typ == core.EvToolResult {
|
||||
result = ev.payload.(core.ToolResultPayload)
|
||||
}
|
||||
}
|
||||
if result.CallID != call.ID || result.DurationMs < 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptOf(msgs []agentllm.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
|
||||
@@ -29,14 +29,7 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
return rc.Context().Err()
|
||||
}
|
||||
prof := rc.Profile()
|
||||
maxTurns := 12
|
||||
if prof.Budget.MaxTurns != nil && *prof.Budget.MaxTurns > 0 {
|
||||
maxTurns = *prof.Budget.MaxTurns
|
||||
}
|
||||
maxTools := 0
|
||||
if prof.Budget.MaxToolCalls != nil && *prof.Budget.MaxToolCalls > 0 {
|
||||
maxTools = *prof.Budget.MaxToolCalls
|
||||
}
|
||||
maxTurns, maxTools := loopCaps(prof.Budget)
|
||||
produced := &artifactList{}
|
||||
var streamed strings.Builder
|
||||
var toolParts []core.Part
|
||||
@@ -143,7 +136,7 @@ func newModelRouter(rc RunCtx) modelRouter {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return modelRouter{}
|
||||
}
|
||||
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
return modelRouter{stream: client.Stream, model: route.Model}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,13 +19,10 @@ const subagentFallback = "你是被派生的子代理。独立完成给定子任
|
||||
|
||||
const defaultSubagentID = "agent.sub.general"
|
||||
|
||||
const subagentToolCap = 20
|
||||
|
||||
func fallbackSubagentSpec() prompts.SubagentSpec {
|
||||
return prompts.SubagentSpec{
|
||||
Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"},
|
||||
Model: "lightweight",
|
||||
MaxTurns: 8,
|
||||
Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"},
|
||||
Model: "lightweight",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +41,9 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
spec = got
|
||||
}
|
||||
}
|
||||
if spec.MaxTurns <= 0 {
|
||||
spec.MaxTurns = 8
|
||||
maxTurns := spec.MaxTurns
|
||||
if maxTurns <= 0 {
|
||||
maxTurns = math.MaxInt32
|
||||
}
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
if spec.Model == "chat" {
|
||||
@@ -55,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 := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
stream = client.Stream
|
||||
}
|
||||
decls := subagentDecls(rc, spec)
|
||||
@@ -93,15 +92,15 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
_ = rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing})
|
||||
user := agentllm.Message{Role: agentllm.RoleUser, Content: subagentUserText(rc)}
|
||||
res, err := nruntime.Run(rc.Context(), nruntime.Config{
|
||||
Model: route.Model,
|
||||
SystemPrompt: l.subagentPrompt(rc, id),
|
||||
Tools: reg,
|
||||
Stream: wrapped,
|
||||
MaxTurns: spec.MaxTurns,
|
||||
Budget: &nruntime.Budget{MaxToolCalls: subagentToolCap},
|
||||
RepeatGuard: true,
|
||||
ContinueOnLength: true,
|
||||
OnEvent: st.onEvent,
|
||||
Model: route.Model,
|
||||
SystemPrompt: l.subagentPrompt(rc, id),
|
||||
Tools: reg,
|
||||
Stream: wrapped,
|
||||
MaxTurns: maxTurns,
|
||||
RepeatGuard: true,
|
||||
ContinueOnLength: true,
|
||||
MaxLengthContinuations: -1,
|
||||
OnEvent: st.onEvent,
|
||||
}, user)
|
||||
if len(st.parts) == 0 {
|
||||
st.parts = append(st.parts, core.Part{Type: core.PartText, Text: "(子代理未返回内容)"})
|
||||
|
||||
@@ -139,11 +139,12 @@ func TestSubagentWriterUsesChatModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubagentMaxTurns(t *testing.T) {
|
||||
turns := make([][]agentllm.Event, 7)
|
||||
for i := range turns {
|
||||
func TestSubagentMaxTurnsUnbounded(t *testing.T) {
|
||||
turns := make([][]agentllm.Event, 8)
|
||||
for i := 0; i < 7; i++ {
|
||||
turns[i] = textDeltaTurn("", toolCall("knowledge_search", `{}`))
|
||||
}
|
||||
turns[7] = textDeltaTurn("计划写完了。")
|
||||
calls := 0
|
||||
base := scriptStream(turns, nil)
|
||||
rc := &subRC{
|
||||
@@ -159,12 +160,11 @@ func TestSubagentMaxTurns(t *testing.T) {
|
||||
return base(ctx, model, msgs, tools, opts)
|
||||
},
|
||||
}
|
||||
err := r.Execute(rc)
|
||||
if err == nil || !strings.Contains(err.Error(), "max turns") {
|
||||
t.Fatalf("err = %v", err)
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 6 {
|
||||
t.Fatalf("stream calls = %d", calls)
|
||||
if calls != 8 {
|
||||
t.Fatalf("stream calls = %d, want past the old cap of 6", calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
stream = client.Stream
|
||||
}
|
||||
model := route.Model
|
||||
|
||||
@@ -358,7 +358,7 @@ func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) veri
|
||||
return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"}
|
||||
}
|
||||
model = route.Model
|
||||
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
stream = client.Stream
|
||||
}
|
||||
prompt := fmt.Sprintf("你是验收员。目标:%s\n验收标准:%s\n这一步的产出:\n%s\n标准里要求出现的句子,产出里必须逐字包含,否则 passed 必须为 false,nextAction 写出要补上的原句。只输出一个 JSON 对象,字段为 passed、reason、nextAction。", goal, criteria, output)
|
||||
|
||||
@@ -64,9 +64,11 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
|
||||
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||||
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"},
|
||||
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||
{Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||||
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"},
|
||||
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/subagent"
|
||||
@@ -78,7 +77,7 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) {
|
||||
Planner: squad.NewRulePlanner(),
|
||||
Backends: backends,
|
||||
DefaultBackend: "agent-system",
|
||||
StepTimeout: 5 * time.Minute,
|
||||
StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟)
|
||||
HitlPolicy: squad.HitlPolicy{Mode: squad.HitlInteractive},
|
||||
Checker: quality.Checker{},
|
||||
RepairBudget: 2,
|
||||
|
||||
@@ -2,10 +2,10 @@ package squadadapter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
@@ -66,7 +66,7 @@ func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) {
|
||||
cfg.MaxTurns = opt.MaxTurns
|
||||
}
|
||||
if cfg.MaxTurns <= 0 {
|
||||
cfg.MaxTurns = 12
|
||||
cfg.MaxTurns = math.MaxInt32
|
||||
}
|
||||
cfg.MaxToolCalls = opt.MaxToolCalls
|
||||
|
||||
@@ -101,7 +101,7 @@ func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) {
|
||||
Planner: squad.NewRulePlanner(),
|
||||
Backends: backends,
|
||||
DefaultBackend: "agent-native",
|
||||
StepTimeout: 5 * time.Minute,
|
||||
StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟)
|
||||
HitlPolicy: opt.Hitl,
|
||||
Checker: checker,
|
||||
RepairBudget: repair,
|
||||
|
||||
@@ -20,8 +20,7 @@ import (
|
||||
|
||||
const (
|
||||
execMaxOutput = 64 << 10 // 单次输出上限 64KB(头尾保留)
|
||||
execDefaultTimeoutS = 60
|
||||
execMaxTimeoutS = 300
|
||||
execDefaultTimeoutS = 600 // 未指定 timeout_sec 时的默认超时;模型可自行设更长,没有硬上限
|
||||
execMaxArtifactBytes = 10 << 20 // register_artifact 登记上限 10MB
|
||||
execScriptDir = ".agent" // 脚本落位(会话工作区内,可审计)
|
||||
execSetting = "runtime.exec"
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
//
|
||||
// 语义:模型提交的脚本原样落盘到会话工作区 .agent/ 下(字节精确、执行后保留以便审计),
|
||||
// 再以该文件为唯一参数启动白名单解释器(bash / python3 的绝对路径)——模型输入不进入
|
||||
// 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 60s、上限 300s)
|
||||
// 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。非零退出、超时与取消作为文本反馈返回
|
||||
// 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 600s,无硬上限)
|
||||
// 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。执行中的输出经可选回调流出。
|
||||
// 非零退出、超时与取消作为文本反馈返回
|
||||
// (模型据此自纠),仅启动失败算工具错误。
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -31,7 +34,7 @@ func RegisterInterpreterTools(r *Registry) error {
|
||||
Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"script":{"type":"string","description":"bash 脚本(可多行)"},` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
|
||||
`,"required":["script"]}`),
|
||||
}, execShell); err != nil {
|
||||
return err
|
||||
@@ -41,7 +44,7 @@ func RegisterInterpreterTools(r *Registry) error {
|
||||
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
|
||||
`,"required":["script"]}`),
|
||||
}, execPython)
|
||||
}
|
||||
@@ -129,16 +132,13 @@ func resolveInterpreter(name string) (string, error) {
|
||||
|
||||
// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。
|
||||
// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。
|
||||
func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int) (string, error) {
|
||||
func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string)) (string, error) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
if timeoutS <= 0 {
|
||||
timeoutS = execDefaultTimeoutS
|
||||
}
|
||||
if timeoutS > execMaxTimeoutS {
|
||||
timeoutS = execMaxTimeoutS
|
||||
}
|
||||
bin, err := resolveInterpreter(interpreter)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", interpreter, err)
|
||||
@@ -160,8 +160,39 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
|
||||
}
|
||||
return err
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
text := truncateOutput(out)
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
var buf bytes.Buffer
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
tmp := make([]byte, 4096)
|
||||
for {
|
||||
n, rerr := pr.Read(tmp)
|
||||
if n > 0 {
|
||||
if buf.Len() < 1<<20 {
|
||||
buf.Write(tmp[:n])
|
||||
}
|
||||
if sink != nil {
|
||||
sink("stdout", string(tmp[:n]))
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
_ = pw.Close()
|
||||
<-readDone
|
||||
return "", fmt.Errorf("%s: %w", interpreter, err)
|
||||
}
|
||||
err = cmd.Wait()
|
||||
_ = pw.Close()
|
||||
<-readDone
|
||||
text := truncateOutput(buf.Bytes())
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return fmt.Sprintf("exit=timeout(%ds)\n%s", timeoutS, text), nil
|
||||
}
|
||||
@@ -208,7 +239,7 @@ func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec))
|
||||
return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc))
|
||||
}
|
||||
|
||||
// ScriptCommand 准备一条以当前用户身份、工作目录为会话沙箱的 python3 命令。
|
||||
@@ -264,7 +295,19 @@ func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec))
|
||||
return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc))
|
||||
}
|
||||
|
||||
// toolOutputSink 在执行上下文实现了 EmitToolOutput 时,把 stdout 增量交出去。
|
||||
func toolOutputSink(rc ToolExecCtx) func(stream, text string) {
|
||||
type emitter interface {
|
||||
EmitToolOutput(stream, text string)
|
||||
}
|
||||
e, ok := rc.(emitter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return e.EmitToolOutput
|
||||
}
|
||||
|
||||
func firstPos(vals ...int) int {
|
||||
|
||||
@@ -18,6 +18,7 @@ type execFake struct {
|
||||
execOn bool
|
||||
settings map[string]bool
|
||||
ctx context.Context
|
||||
output []string
|
||||
}
|
||||
|
||||
func (c *execFake) ProjectID() string { return "p1" }
|
||||
@@ -30,6 +31,8 @@ func (c *execFake) Context() context.Context {
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
func (c *execFake) EmitToolOutput(stream, text string) { c.output = append(c.output, stream+":"+text) }
|
||||
|
||||
func (c *execFake) SettingBool(key string, def bool) bool {
|
||||
if c.settings != nil {
|
||||
if v, ok := c.settings[key]; ok {
|
||||
@@ -113,6 +116,19 @@ func TestRunShellEmptyAndGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShellStreamsOutput(t *testing.T) {
|
||||
r := newInterpRegistry(t)
|
||||
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
||||
res := interpCall(t, r, rc, "run_shell", `{"script":"echo streamed-line"}`)
|
||||
if res.Status != "ok" || !strings.Contains(res.Output, "streamed-line") {
|
||||
t.Fatalf("result: %s %s", res.Status, res.Output)
|
||||
}
|
||||
joined := strings.Join(rc.output, "")
|
||||
if !strings.Contains(joined, "stdout:") || !strings.Contains(joined, "streamed-line") {
|
||||
t.Fatalf("streamed chunks: %q", rc.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShellTimeoutKillsProcessGroup(t *testing.T) {
|
||||
r := newInterpRegistry(t)
|
||||
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
||||
|
||||
Reference in New Issue
Block a user