Files
Agent-System-Backend/internal/runtime/sub_loop.go
T
weichen 904742ca4b
ci / vet-and-test (pull_request) Successful in 40s
Wire GenAI telemetry: settings surface, service name, and agent names (N3 WP4)
Why: spans now follow the OTel GenAI convention (Agent-Native#32); the
product needs the settings surface for the exporter, a meaningful
service.name, and per-profile agent names.
Evidence: go test -count=1 ./... green; TestTelemetryFlagsDefaults
(otlpEndpoint registered default empty = no export, serviceName
default agent-system-backend); nested pins carry the convention work.
Impact: nothing is exported unless telemetry.otlpEndpoint (or the env
var) is set; agent spans are named by profile id.
Refs: Agent-Native#32, Agent-Squad#36, N3 plan WP4
2026-10-07 15:41:01 +00:00

251 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runtime
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"time"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/prompts"
core "agent-system-core"
)
const subagentFallback = "你是被派生的子代理。独立完成给定子任务,输出完整、结构化的结果;需要分点时分条列出,不要为了简短而省略关键内容。"
const defaultSubagentID = "agent.sub.general"
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",
}
}
func (l *SubLLMRunner) Name() string { return "llm-subagent" }
func (l *SubLLMRunner) Execute(rc RunCtx) error {
id := defaultSubagentID
if src, ok := rc.(interface{ SubagentID() string }); ok {
if v := strings.TrimSpace(src.SubagentID()); v != "" {
id = v
}
}
spec := fallbackSubagentSpec()
if l.Prompts != nil {
if got, ok := l.Prompts.SubagentSpec(rc.Profile().Owner, id); ok {
spec = got
}
}
maxTurns := spec.MaxTurns
if maxTurns <= 0 {
maxTurns = math.MaxInt32
}
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
if spec.Model == "chat" {
route = ResolveChatModel(rc.Profile(), rc.Settings())
}
stream := l.Stream
if stream == nil {
if route.Model == "" || route.Base == "" {
return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base)
}
client := openLLMClient(route.Base, route.Key)
stream = client.Stream
}
if spec.Model == "chat" {
stream = agentllm.WithFallback(stream, ChatFallbacks(rc.Settings()), nil)
}
decls := subagentDecls(rc, spec)
st := &subRun{rc: rc}
reg := agenttools.NewRegistry("")
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))
}})
}
wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
in := stream(ctx, model, messages, decls, opts)
out := make(chan agentllm.Event)
go func() {
defer close(out)
for ev := range in {
if ev.Kind == agentllm.KindMessageEnd {
if ev.Message.Role == "" {
ev.Message.Role = agentllm.RoleAssistant
}
if len(ev.Message.ToolCalls) > 0 && ev.Message.StopReason == "" {
ev.Message.StopReason = agentllm.StopToolCalls
}
}
select {
case out <- ev:
case <-ctx.Done():
return
}
}
}()
return out
}
_ = rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing})
user := agentllm.Message{Role: agentllm.RoleUser, Content: subagentUserText(rc)}
window, reserve, buffer := contextWindow(rc.Settings())
res, err := nruntime.Run(rc.Context(), nruntime.Config{
Model: route.Model,
SystemPrompt: l.subagentPrompt(rc, id),
Tools: reg,
Stream: wrapped,
MaxTurns: maxTurns,
RepeatGuard: true,
ContinueOnLength: true,
MaxLengthContinuations: -1,
ContextWindow: window,
OutputReserve: reserve,
ContextBuffer: buffer,
OnEvent: st.onEvent,
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
AgentName: agentNameOf(rc.Profile()),
}, user)
if len(st.parts) == 0 {
st.parts = append(st.parts, core.Part{Type: core.PartText, Text: "(子代理未返回内容)"})
}
_ = rc.SetAssistantParts(st.parts)
if err != nil {
return err
}
if res.Err != nil {
return res.Err
}
return nil
}
func (l *SubLLMRunner) subagentPrompt(rc RunCtx, id string) string {
if l.Prompts == nil {
return subagentFallback
}
if body, err := l.Prompts.AgentPrompt(rc.Profile().Owner, id); err == nil && strings.TrimSpace(body) != "" {
return body
}
return subagentFallback
}
func subagentUserText(rc RunCtx) string {
text := rc.Subtask()
if src, ok := rc.(interface{ Subhint() string }); ok {
if hint := strings.TrimSpace(src.Subhint()); hint != "" {
text += "\n\n执行提示:" + hint
}
}
return text
}
func subagentDecls(rc RunCtx, spec prompts.SubagentSpec) []agentllm.ToolDecl {
allow := map[string]bool{}
for _, name := range spec.Tools {
allow[name] = true
}
var decls []agentllm.ToolDecl
for _, tool := range rc.Tools() {
if !allow[tool.Name] || !rc.Profile().Tools.ToolAllowed(tool.Name) {
continue
}
decls = append(decls, agentllm.ToolDecl{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters})
}
return decls
}
type subSlot struct {
call agentllm.ToolCall
start time.Time
summary string
status string
artifact *core.ArtifactRef
}
type subRun struct {
rc RunCtx
parts []core.Part
queued []agentllm.ToolCall
inflight *subSlot
turnText strings.Builder
}
func (st *subRun) onEvent(ev nruntime.Event) {
switch ev.Type {
case "turn_start":
st.turnText.Reset()
case "text_delta":
st.turnText.WriteString(ev.Text)
_ = st.rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: st.rc.Run().ID, Note: truncateRun(st.turnText.String(), 80)})
case "message_end":
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
hasCalls := len(ev.Message.ToolCalls) > 0
if body := strings.TrimSpace(st.turnText.String()); body != "" {
st.parts = append(st.parts, core.Part{Type: core.PartText, Text: body, Process: hasCalls})
}
case "tool_execution_start":
call := agentllm.ToolCall{Name: ev.Tool, Arguments: ev.Detail}
if len(st.queued) > 0 {
call = st.queued[0]
st.queued = st.queued[1:]
}
st.inflight = &subSlot{call: call, start: time.Now()}
_ = st.rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: st.rc.Run().ID, Note: "调用 " + call.Name})
case "tool_execution_end":
slot := st.inflight
if slot == nil {
return
}
end := time.Now()
st.parts = append(st.parts,
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: slot.call.ID, Name: slot.call.Name, Arguments: slot.call.Arguments, Duration: durationMs(slot.start, end)}},
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary}},
)
if slot.artifact != nil {
st.parts = append(st.parts, core.Part{Type: core.PartArtifactRef, Artifact: slot.artifact})
}
st.inflight = nil
}
}
func (st *subRun) runTool(name, args string) agenttools.Result {
call := agentllm.ToolCall{Name: name, Arguments: args}
if st.inflight != nil && st.inflight.call.Name != "" {
call = st.inflight.call
}
res := st.rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)})
summary := res.Output
status := res.Status
if status == "" {
status = "ok"
}
var ref *core.ArtifactRef
if res.Artifact != nil {
ref = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
}
if st.inflight != nil {
st.inflight.summary = summary
st.inflight.status = status
st.inflight.artifact = ref
}
if status != "ok" {
return agenttools.Result{Content: summary, IsError: true}
}
return agenttools.Result{Content: summary}
}
func subagentProfileID(agent string) string {
switch agent {
case "explore", "research", "plan", "verify", "general", "writer":
return "agent.sub." + agent
default:
return defaultSubagentID
}
}