diff --git a/runtime/llm/sse.go b/runtime/llm/sse.go index d7588ae..823b253 100644 --- a/runtime/llm/sse.go +++ b/runtime/llm/sse.go @@ -164,7 +164,7 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) { select { case <-ctx.Done(): emitAbort(out, content.String(), ctx.Err()) - case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage}: + case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished}: } } diff --git a/runtime/llm/sse_test.go b/runtime/llm/sse_test.go index 56c0455..3a4f801 100644 --- a/runtime/llm/sse_test.go +++ b/runtime/llm/sse_test.go @@ -38,6 +38,29 @@ func TestParseSSETextAndUsage(t *testing.T) { if end.Usage.TotalTokens != 5 { t.Fatalf("usage %#v", end.Usage) } + if end.FinishReason != "stop" { + t.Fatalf("finish reason %q, want stop", end.FinishReason) + } +} + +func TestParseSSEFinishReasonLengthKeepsStopEnd(t *testing.T) { + body := strings.Join([]string{ + `data: {"choices":[{"delta":{"content":"半"},"finish_reason":"length"}]}`, + `data: [DONE]`, + "", + }, "\n") + var end Event + for ev := range ParseSSE(context.Background(), strings.NewReader(body)) { + if ev.Kind == KindMessageEnd { + end = ev + } + } + if end.FinishReason != "length" { + t.Fatalf("finish reason %q, want length", end.FinishReason) + } + if end.Message.StopReason != StopEnd { + t.Fatalf("stop reason %s, want end so existing callers stay unchanged", end.Message.StopReason) + } } func TestParseSSEToolCallIncrements(t *testing.T) { diff --git a/runtime/llm/types.go b/runtime/llm/types.go index 623e0fc..abf7035 100644 --- a/runtime/llm/types.go +++ b/runtime/llm/types.go @@ -90,9 +90,12 @@ type Event struct { ArgumentsDelta string Message Message Usage Usage - Err error - Retryable bool - ErrorMessage string + // FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。 + // 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。 + FinishReason string + Err error + Retryable bool + ErrorMessage string } // StreamOptions tunes one provider request. diff --git a/runtime/loop.go b/runtime/loop.go index 475152e..5c560a8 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -18,8 +18,15 @@ import ( const ( ExecSequential = "sequential" ExecParallel = "parallel" + + maxStopContinuations = 3 + maxLengthContinuations = 3 + maxRepeatReminders = 3 ) +const repeatReminderText = "你已连续 3 次以相同参数调用同一工具。如果结果不符合预期,请更换参数或做法,不要再重复这一调用。" +const lengthContinuePrompt = "上一段回复因为达到长度上限被截断。请从断点接着写,不要重复已经写出的内容。" + // Event is a lifecycle notice for logs and UI. type Event struct { Type string @@ -29,9 +36,11 @@ type Event struct { // Detail carries tool arguments/results or reasoning deltas for // process-visualization consumers; log writers may ignore it. Detail string - // PromptTokens / CompletionTokens 只在 type=usage 时有意义。 + // PromptTokens / CompletionTokens / TotalTokens 在 type=usage 或 + // type=message_end 时有意义,供调用方累计用量。 PromptTokens int CompletionTokens int + TotalTokens int } // Config drives one agent session. @@ -57,6 +66,41 @@ type Config struct { // Budget 可选。任一上限命中即停止并返回 budget_exceeded 错误(StopError); // 零值字段不设限。截止时刻与外层 ctx 先到者生效。 Budget *Budget + // History 是会话转录为空时的初始转录。以 system 开头时,不再前置 + // SystemPrompt 和工具清单。不回写进 Session。 + History []llm.Message + // Stop 在模型不再调用工具、且本轮没有 follow-up 时询问是否续跑。 + // nil 表示不询问。内核最多接受 3 次 Continue。 + Stop func(last llm.Message, info StopInfo) StopDecision + // BeforeTool 返回错误时不执行工具,但仍写一条 IsError 的 tool 结果。 + BeforeTool func(call llm.ToolCall) error + // UnknownTool 处理注册表里没有的调用。nil 时仍返回 unknown tool。 + UnknownTool func(call llm.ToolCall) tools.Result + // AfterTool 只观察工具结果,不能改写。 + AfterTool func(call llm.ToolCall, res llm.Message) + // Reminders 在每次调用模型前提供提醒。正文包在 system-reminder 里, + // 只进入本次请求,不写进 Session。 + Reminders func(turn int) []string + // RepeatGuard 为真时,同名且参数按键排序后相同的调用连续 3 次,注入一条提醒。 + // 每个 run 最多 3 条,不中断循环。 + RepeatGuard bool + // ContinueOnLength 为真时,FinishReason=length 且没有工具调用就续写,最多 3 次。 + // 续写提示不写进 Session。第 4 次截断返回错误。 + ContinueOnLength bool +} + +// StopInfo 是 Stop 钩子看到的本 run 进度。 +type StopInfo struct { + Continuations int + ToolCalls int +} + +// StopDecision 决定模型停下之后是否再跑一轮。 +type StopDecision struct { + Continue bool + Replacement string + Reason string + Notice string } // Budget 是循环层的执行预算;与 exec.Budget 字段同构,由适配层换算。 @@ -73,6 +117,7 @@ type Result struct { Messages []llm.Message StopReason llm.StopReason Usage llm.Usage + Notice string Err error Retryable bool } @@ -96,6 +141,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { } messages = loaded } + if len(messages) == 0 && len(cfg.History) > 0 { + messages = append([]llm.Message{}, cfg.History...) + } if len(messages) == 0 || messages[0].Role != llm.RoleSystem { sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)} messages = append([]llm.Message{sys}, messages...) @@ -112,6 +160,10 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { var last llm.Message var usage llm.Usage toolCalls := 0 + stopContinues := 0 + lengthContinues := 0 + lengthNudge := false + var repeats repeatState for turn := 0; turn < cfg.MaxTurns; turn++ { if ctx.Err() != nil { cfg.logger().Error("runtime turn aborted", ctx.Err()) @@ -122,7 +174,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { } emit(cfg, Event{Type: "turn_start"}) emit(cfg, Event{Type: "message_start"}) - end, errEv := consume(ctx, cfg, messages) + request := withEphemeral(messages, turnReminders(cfg, turn, &repeats, &lengthNudge)) + end, errEv := consume(ctx, cfg, request) if errEv != nil { cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable) return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil @@ -135,7 +188,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { return Result{}, err } messages = append(messages, last) - emit(cfg, Event{Type: "message_end", Message: last}) + emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens}) if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 { if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls { @@ -143,6 +196,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { return budgetStop(cfg, last, messages, usage, reason), nil } toolCalls += len(last.ToolCalls) + if cfg.RepeatGuard { + repeats.observe(last.ToolCalls) + } results := executeTools(ctx, cfg, last) for _, res := range results { if err := persist(cfg, res); err != nil { @@ -162,6 +218,16 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { continue } emit(cfg, Event{Type: "turn_end", Message: last}) + if cfg.ContinueOnLength && end.FinishReason == "length" && len(last.ToolCalls) == 0 { + if lengthContinues >= maxLengthContinuations { + errCap := errString("length continuation exceeded") + cfg.logger().Error("runtime length continuation exceeded", errCap) + return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: errCap}, nil + } + lengthContinues++ + lengthNudge = true + continue + } if follow := drain(cfg.GetFollowUp); len(follow) > 0 { for _, m := range follow { if err := persist(cfg, m); err != nil { @@ -171,10 +237,26 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { } continue } - emit(cfg, Event{Type: "agent_end", Message: last}) - emit(cfg, Event{Type: "agent_settled", Message: last}) - emitUsage(cfg, usage) - return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil + if cfg.Stop != nil { + decision := cfg.Stop(last, StopInfo{Continuations: stopContinues, ToolCalls: toolCalls}) + if decision.Continue && stopContinues < maxStopContinuations { + stopContinues++ + if decision.Replacement != "" && len(messages) > 0 { + messages[len(messages)-1].Content = decision.Replacement + last.Content = decision.Replacement + } + if decision.Reason != "" { + reasonMsg := llm.Message{Role: llm.RoleUser, Content: decision.Reason} + if err := persist(cfg, reasonMsg); err != nil { + return Result{}, err + } + messages = append(messages, reasonMsg) + } + continue + } + return settle(cfg, last, messages, usage, decision.Notice), nil + } + return settle(cfg, last, messages, usage, ""), nil } errCap := errString("max turns exceeded") cfg.logger().Error("runtime turn cap hit", errCap) @@ -290,19 +372,118 @@ func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []l func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments}) + if cfg.BeforeTool != nil { + if err := cfg.BeforeTool(call); err != nil { + msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true} + emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content}) + if cfg.AfterTool != nil { + cfg.AfterTool(call, msg) + } + return msg + } + } emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments}) res := tools.Result{Content: "no tools configured", IsError: true} - if cfg.Tools != nil { + known := cfg.Tools != nil && cfg.Tools.Has(call.Name) + switch { + case known: + res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments)) + case cfg.UnknownTool != nil: + res = cfg.UnknownTool(call) + case cfg.Tools != nil: res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments)) } - emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content}) - return llm.Message{ + msg := llm.Message{ Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: res.Content, IsError: res.IsError, } + emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content}) + if cfg.AfterTool != nil { + cfg.AfterTool(call, msg) + } + return msg +} + +func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, notice string) Result { + emit(cfg, Event{Type: "agent_end", Message: last}) + emit(cfg, Event{Type: "agent_settled", Message: last}) + emitUsage(cfg, usage) + return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice} +} + +func reminderMessage(text string) llm.Message { + return llm.Message{Role: llm.RoleUser, Content: "\n" + text + "\n"} +} + +func turnReminders(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message { + var extra []llm.Message + if cfg.Reminders != nil { + for _, text := range cfg.Reminders(turn) { + if strings.TrimSpace(text) == "" { + continue + } + extra = append(extra, reminderMessage(text)) + } + } + if lengthNudge != nil && *lengthNudge { + extra = append(extra, llm.Message{Role: llm.RoleUser, Content: lengthContinuePrompt}) + *lengthNudge = false + } + if cfg.RepeatGuard && repeats != nil { + due := repeats.streak/3 - repeats.reminders + room := maxRepeatReminders - repeats.reminders + if due > room { + due = room + } + for i := 0; i < due; i++ { + extra = append(extra, reminderMessage(repeatReminderText)) + repeats.reminders++ + } + } + return extra +} + +func withEphemeral(messages, extra []llm.Message) []llm.Message { + if len(extra) == 0 { + return messages + } + out := make([]llm.Message, 0, len(messages)+len(extra)) + out = append(out, messages...) + out = append(out, extra...) + return out +} + +type repeatState struct { + sig string + streak int + reminders int +} + +func (s *repeatState) observe(calls []llm.ToolCall) { + for _, call := range calls { + sig := call.Name + "\n" + canonicalArgs(call.Arguments) + if sig == s.sig { + s.streak++ + continue + } + s.sig = sig + s.streak = 1 + } +} + +func canonicalArgs(raw string) string { + var v any + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return raw + } + b, err := json.Marshal(v) + if err != nil { + return raw + } + return string(b) } func persist(cfg Config, msg llm.Message) error { @@ -320,7 +501,7 @@ func drain(fn func() []llm.Message) []llm.Message { } func emitUsage(cfg Config, usage llm.Usage) { - emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens}) + emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens}) } func emit(cfg Config, ev Event) { diff --git a/runtime/loop_harness_test.go b/runtime/loop_harness_test.go new file mode 100644 index 0000000..6c32c2d --- /dev/null +++ b/runtime/loop_harness_test.go @@ -0,0 +1,332 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "sync" + "testing" + + "agent-native/runtime/llm" + "agent-native/runtime/session" + "agent-native/runtime/tools" +) + +func TestLoopStopContinuesUntilCap(t *testing.T) { + var calls int + res, err := Run(context.Background(), Config{ + Model: "fake", + MaxTurns: 8, + Stream: script( + llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd}, + llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd}, + llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd}, + llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd}, + ), + Stop: func(last llm.Message, info StopInfo) StopDecision { + calls++ + if info.Continuations != calls-1 { + t.Errorf("continuations = %d, want %d", info.Continuations, calls-1) + } + return StopDecision{Continue: true, Replacement: "已作废", Reason: "再试", Notice: "到顶了"} + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if calls != 4 { + t.Fatalf("stop calls = %d, want 4", calls) + } + if res.Err != nil || res.Notice != "到顶了" { + t.Fatalf("notice %q err %v", res.Notice, res.Err) + } + replaced, forged := 0, 0 + for _, m := range res.Messages { + if m.Role != llm.RoleAssistant { + continue + } + if m.Content == "已作废" { + replaced++ + } + if m.Content == "forged-claim" { + forged++ + } + } + if replaced != 3 || forged != 1 { + t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages)) + } + if strings.Count(transcriptText(res.Messages), "再试") != 3 { + t.Fatalf("reasons = %s", transcriptText(res.Messages)) + } +} + +func TestLoopBeforeToolRejectPairsResult(t *testing.T) { + hits := 0 + reg := tools.NewRegistry(t.TempDir()) + reg.Register(hitTool{hits: &hits}) + var seen llm.Message + res, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "echo", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + BeforeTool: func(call llm.ToolCall) error { + return errors.New("不允许") + }, + AfterTool: func(call llm.ToolCall, msg llm.Message) { + seen = msg + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if hits != 0 { + t.Fatalf("tool executed %d times", hits) + } + if !seen.IsError || seen.Content != "不允许" || seen.ToolCallID != "c1" { + t.Fatalf("after = %#v", seen) + } + paired := false + for _, m := range res.Messages { + if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "不允许" { + paired = true + } + } + if !paired { + t.Fatalf("missing paired tool result: %s", transcriptText(res.Messages)) + } +} + +func TestLoopRemindersStayOutOfSession(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + st, err := session.Open(path) + if err != nil { + t.Fatal(err) + } + var saw bool + _, err = Run(context.Background(), Config{ + Model: "fake", + Session: st, + Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + for _, m := range messages { + if strings.Contains(m.Content, "") && strings.Contains(m.Content, "先核对交付物") { + saw = true + } + } + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}} + close(ch) + return ch + }, + Reminders: func(turn int) []string { + if turn != 0 { + t.Errorf("turn %d", turn) + } + return []string{"先核对交付物"} + }, + }, llm.Message{Role: llm.RoleUser, Content: "ping"}) + if err != nil { + t.Fatal(err) + } + if !saw { + t.Fatal("reminder missing from the provider request") + } + loaded, err := st.Load() + if err != nil { + t.Fatal(err) + } + if strings.Contains(transcriptText(loaded), "先核对交付物") { + t.Fatalf("reminder persisted: %#v", loaded) + } +} + +func TestLoopRepeatGuardFiresAndCaps(t *testing.T) { + var mu sync.Mutex + var requests []string + turns := make([]llm.Message, 0, 13) + for i := 0; i < 12; i++ { + args := `{"b":1,"a":2}` + if i%2 == 1 { + args = `{"a":2,"b":1}` + } + turns = append(turns, llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c", Name: "echo", Arguments: args, + }}}) + } + turns = append(turns, llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}) + var i int + stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + mu.Lock() + requests = append(requests, transcriptText(messages)) + n := i + i++ + mu.Unlock() + ch := make(chan llm.Event, 1) + msg := turns[len(turns)-1] + if n < len(turns) { + msg = turns[n] + } + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg} + close(ch) + return ch + } + res, err := Run(context.Background(), Config{ + Model: "fake", + MaxTurns: 20, + RepeatGuard: true, + Stream: stream, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if res.Text != "done" { + t.Fatalf("text %q", res.Text) + } + reminders := 0 + for _, req := range requests { + reminders += strings.Count(req, repeatReminderText) + } + if reminders != 3 { + t.Fatalf("reminders = %d, want 3; requests=%d", reminders, len(requests)) + } + if strings.Contains(transcriptText(res.Messages), repeatReminderText) { + t.Fatal("repeat reminder was written into the transcript") + } + if !strings.Contains(requests[3], repeatReminderText) { + t.Fatal("first reminder should arrive on the request after the third identical call") + } +} + +func TestLoopContinueOnLengthCaps(t *testing.T) { + var requests []string + var n int + stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + requests = append(requests, transcriptText(messages)) + n++ + ch := make(chan llm.Event, 1) + ch <- llm.Event{ + Kind: llm.KindMessageEnd, + FinishReason: "length", + Message: llm.Message{Role: llm.RoleAssistant, Content: "part", StopReason: llm.StopEnd}, + } + close(ch) + return ch + } + res, err := Run(context.Background(), Config{ + Model: "fake", + MaxTurns: 8, + ContinueOnLength: true, + Stream: stream, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if res.Err == nil || !strings.Contains(res.Err.Error(), "length continuation exceeded") { + t.Fatalf("err = %v", res.Err) + } + if n != 4 { + t.Fatalf("model calls = %d, want 4", n) + } + if len(res.Messages) == 0 || !strings.Contains(transcriptText(res.Messages), "part") { + t.Fatal("truncated transcript was dropped") + } + if strings.Contains(transcriptText(res.Messages), lengthContinuePrompt) { + t.Fatal("continuation prompt was persisted") + } + prompts := 0 + for _, req := range requests { + prompts += strings.Count(req, lengthContinuePrompt) + } + if prompts != 3 { + t.Fatalf("continuation prompts = %d, want 3", prompts) + } +} + +func TestLoopHistorySkipsGeneratedSystem(t *testing.T) { + var first string + _, err := Run(context.Background(), Config{ + Model: "fake", + SystemPrompt: "LEAK", + History: []llm.Message{ + {Role: llm.RoleSystem, Content: "FROM_HISTORY"}, + {Role: llm.RoleUser, Content: "earlier"}, + }, + Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + if len(messages) > 0 { + first = messages[0].Content + } + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}} + close(ch) + return ch + }, + }, llm.Message{Role: llm.RoleUser, Content: "now"}) + if err != nil { + t.Fatal(err) + } + if first != "FROM_HISTORY" || strings.Contains(first, "LEAK") { + t.Fatalf("system = %q", first) + } +} + +func TestLoopUnknownToolFallsThrough(t *testing.T) { + res, err := Run(context.Background(), Config{ + Model: "fake", + UnknownTool: func(call llm.ToolCall) tools.Result { + return tools.Result{Content: "via " + call.Name} + }, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "make_report", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + found := false + for _, m := range res.Messages { + if m.Role == llm.RoleTool && m.Content == "via make_report" && !m.IsError { + found = true + } + } + if !found { + t.Fatalf("unknown tool was not delegated: %s", transcriptText(res.Messages)) + } +} + +func TestLoopTurnCapKeepsTranscript(t *testing.T) { + res, err := Run(context.Background(), Config{ + Model: "fake", + MaxTurns: 1, + Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "missing", Arguments: `{}`, + }}}), + }, llm.Message{Role: llm.RoleUser, Content: "ping"}) + if err != nil { + t.Fatal(err) + } + if res.Err == nil || !strings.Contains(res.Err.Error(), "max turns exceeded") { + t.Fatalf("err = %v", res.Err) + } + if !strings.Contains(transcriptText(res.Messages), "ping") { + t.Fatalf("transcript dropped: %s", transcriptText(res.Messages)) + } +} + +type hitTool struct{ hits *int } + +func (h hitTool) Name() string { return "echo" } +func (h hitTool) Description() string { return "echo" } +func (h hitTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } +func (h hitTool) Execute(context.Context, json.RawMessage) (tools.Result, error) { + *h.hits++ + return tools.Result{Content: "ran"}, nil +} diff --git a/runtime/session_agent_test.go b/runtime/session_agent_test.go index ed432a2..dce0947 100644 --- a/runtime/session_agent_test.go +++ b/runtime/session_agent_test.go @@ -8,6 +8,30 @@ import ( "agent-native/runtime/llm" ) +func TestAgentSessionPromptForwardsStop(t *testing.T) { + called := false + sess := NewAgentSession(Config{ + Model: "m", + Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd}} + close(ch) + return ch + }, + Stop: func(last llm.Message, info StopInfo) StopDecision { + called = true + return StopDecision{} + }, + }) + res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if !called || res.Text != "hi" { + t.Fatalf("called=%v text=%q", called, res.Text) + } +} + func TestAgentSessionSteerFollowAndAbort(t *testing.T) { var mu sync.Mutex step := 0