ci / vet-and-test (pull_request) Successful in 41s
Why: N1 increment three host side - plugin agent assets must reject hooks/mcp/permissionMode declarations (WP1 acceptance), and squad members need the kernel ask port wired to the HITL card (WP4). Evidence: go vet clean; go test ./... 29 packages ok, including new agent-asset tests (banned keys located to the field, unknown keys, missing id) and the V2 asset suite. Nested pins at native c5b9dca (PR #18) and squad fc9620e (PR #17). Impact: plugins agents/ assets now parse via parseAgentAsset (strict JSON with banned-key rejection); PermissionAskOf exported and takes any host with the optional method; app.go wires cfg.PermissionAsk for the squad path. Legacy behavior unchanged when nothing registers the port. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
250 lines
7.6 KiB
Go
250 lines
7.6 KiB
Go
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,
|
||
}, 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
|
||
}
|
||
}
|