feat(agent): Agent 实体(指令/工具/guardrail/handoff + 单步 RunStep,含多模态路由/调用记录)
This commit is contained in:
1 parent
b798e129c1
commit
be67d74822
6 files changed
+843
No files matched your search
@@ -0,0 +1,110 @@
|
||||
// Package agent implements the Agent entity of the Cyrene v2 runtime
|
||||
// (DevDocs/design/04-运行时.md §1). An Agent is an instruction string plus an
|
||||
// optional set of tools, an optional guardrail pipeline and an optional
|
||||
// handoff, wired to a model Adapter (and an optional per-role Router). It
|
||||
// executes a single step of the agent loop — assemble a request, run input and
|
||||
// output guardrails, call the model and decode any tool calls — without owning
|
||||
// the loop itself, which lives in internal/runtime/loop (M2).
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the Agent. Use errors.Is to test for them; they
|
||||
// are returned directly or wrapped with %w so the underlying cause stays
|
||||
// available.
|
||||
var (
|
||||
// ErrEmptyInstructions is returned by New when instructions is empty.
|
||||
ErrEmptyInstructions = errors.New("agent: empty instructions")
|
||||
// ErrBadOptions is returned by New when an option value is out of range.
|
||||
ErrBadOptions = errors.New("agent: invalid options")
|
||||
// ErrNoAdapter is returned by RunStep when no model Adapter was injected.
|
||||
ErrNoAdapter = errors.New("agent: no model adapter")
|
||||
)
|
||||
|
||||
// Agent is a single, self-contained runtime entity: an instruction string, an
|
||||
// optional tool Dispatcher, an optional guardrail Pipeline, the model backend
|
||||
// and an optional handoff placeholder. It is safe for concurrent use as long as
|
||||
// its fields are not mutated after construction; RunStep builds a local Request
|
||||
// and delegates to the (concurrency-safe) adapter, dispatcher and guardrail.
|
||||
type Agent struct {
|
||||
instructions string
|
||||
tools *tool.Dispatcher
|
||||
guards *guardrail.Pipeline
|
||||
adapter model.Adapter
|
||||
router *model.Router
|
||||
recorder model.Recorder
|
||||
handoff *Handoff
|
||||
opts Options
|
||||
}
|
||||
|
||||
// New builds an Agent from an instruction string, an optional tool Dispatcher,
|
||||
// an optional guardrail Pipeline and zero or more options applied in order over
|
||||
// the defaults (default MaxRounds is 8). It returns ErrEmptyInstructions when
|
||||
// instructions is blank and ErrBadOptions when an option value is out of range.
|
||||
//
|
||||
// The concrete adapter/router used by RunStep are injected through the options
|
||||
// (see WithModel); tools and guardrails may be nil to run a bare step, and the
|
||||
// agent still works as long as an adapter is supplied before RunStep.
|
||||
func New(instructions string, tools *tool.Dispatcher, guards *guardrail.Pipeline, opts ...Option) (*Agent, error) {
|
||||
a := &Agent{
|
||||
instructions: instructions,
|
||||
tools: tools,
|
||||
guards: guards,
|
||||
opts: Options{MaxRounds: defaultMaxRounds},
|
||||
}
|
||||
if a.instructions == "" {
|
||||
return nil, ErrEmptyInstructions
|
||||
}
|
||||
for _, o := range opts {
|
||||
if o != nil {
|
||||
o(a)
|
||||
}
|
||||
}
|
||||
if err := validateOptions(a.opts); err != nil {
|
||||
return nil, fmt.Errorf("agent: new: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Instructions returns the agent's instruction/system prompt.
|
||||
func (a *Agent) Instructions() string { return a.instructions }
|
||||
|
||||
// Tools returns the agent's tool dispatcher, or nil when none was configured.
|
||||
func (a *Agent) Tools() *tool.Dispatcher { return a.tools }
|
||||
|
||||
// Guardrails returns the agent's guardrail pipeline, or nil when none was
|
||||
// configured.
|
||||
func (a *Agent) Guardrails() *guardrail.Pipeline { return a.guards }
|
||||
|
||||
// Adapter returns the injected model adapter.
|
||||
func (a *Agent) Adapter() model.Adapter { return a.adapter }
|
||||
|
||||
// Router returns the injected per-role router, or nil.
|
||||
func (a *Agent) Router() *model.Router { return a.router }
|
||||
|
||||
// Model returns the configured model name. Empty means the injected Router or
|
||||
// the adapter's own default decides.
|
||||
func (a *Agent) Model() string { return a.opts.Model }
|
||||
|
||||
// Temperature returns the configured sampling temperature.
|
||||
func (a *Agent) Temperature() float64 { return a.opts.Temperature }
|
||||
|
||||
// MaxRounds returns the tool-loop upper bound consumed by the loop (M2,
|
||||
// external to this package).
|
||||
func (a *Agent) MaxRounds() int { return a.opts.MaxRounds }
|
||||
|
||||
// Purpose returns the call-purpose label recorded on each CallRecord.
|
||||
func (a *Agent) Purpose() string { return a.purpose() }
|
||||
|
||||
// MaxTokens returns the token cap passed to the model.
|
||||
func (a *Agent) MaxTokens() int { return a.opts.MaxTokens }
|
||||
|
||||
// Options returns a copy of the agent's current config options.
|
||||
func (a *Agent) Options() Options { return a.opts }
|
||||
@@ -0,0 +1,418 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// mockAdapter is a configurable model.Adapter for agent tests. It captures the
|
||||
// request it was handed so tests can assert on routing/tool advertising.
|
||||
type mockAdapter struct {
|
||||
name string
|
||||
resp model.Response
|
||||
err error
|
||||
gotReq model.Request
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *mockAdapter) Name() string { return m.name }
|
||||
|
||||
func (m *mockAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
m.gotReq = req
|
||||
m.calls++
|
||||
return m.resp, m.err
|
||||
}
|
||||
|
||||
func (m *mockAdapter) ChatStream(_ context.Context, _ model.Request, _ func(string)) (model.Response, error) {
|
||||
return m.resp, m.err
|
||||
}
|
||||
|
||||
// mockGuard is a configurable guardrail.Guardrail for agent tests.
|
||||
type mockGuard struct {
|
||||
inputVerdict *guardrail.Verdict
|
||||
outputVerdict *guardrail.Verdict
|
||||
}
|
||||
|
||||
func (m *mockGuard) CheckInput(_ context.Context, _ *model.Request) (*guardrail.Verdict, error) {
|
||||
return m.inputVerdict, nil
|
||||
}
|
||||
|
||||
func (m *mockGuard) CheckOutput(_ context.Context, _ *model.Response) (*guardrail.Verdict, error) {
|
||||
return m.outputVerdict, nil
|
||||
}
|
||||
|
||||
// mockTool is a minimal sdk.Tool used only to populate a Dispatcher.
|
||||
type mockTool struct {
|
||||
name string
|
||||
desc string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
func (m mockTool) Name() string { return m.name }
|
||||
func (m mockTool) Description() string { return m.desc }
|
||||
func (m mockTool) Parameters() (map[string]any, error) { return m.params, nil }
|
||||
func (m mockTool) Execute(context.Context, any) (any, error) { return nil, nil }
|
||||
|
||||
// dispatcherWith builds a Dispatcher backed by a fresh registry and registers
|
||||
// the given tools.
|
||||
func dispatcherWith(t *testing.T, tools ...sdk.Tool) *tool.Dispatcher {
|
||||
t.Helper()
|
||||
reg := registry.New()
|
||||
d, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
for _, tk := range tools {
|
||||
if err := d.Register(tk); err != nil {
|
||||
t.Fatalf("Register %q: %v", tk.Name(), err)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// newTestAgent builds an Agent with the given adapter, guardrails and extra
|
||||
// options, wiring WithModel(adapter, nil) by default.
|
||||
func newTestAgent(t *testing.T, adapter model.Adapter, guards *guardrail.Pipeline, opts ...Option) *Agent {
|
||||
t.Helper()
|
||||
base := []Option{WithModel(adapter, nil)}
|
||||
base = append(base, opts...)
|
||||
a, err := New("You are a helpful assistant.", nil, guards, base...)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestNew_EmptyInstructions(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := New("", nil, nil); !errors.Is(err, ErrEmptyInstructions) {
|
||||
t.Fatalf("New(\"\") error = %v, want ErrEmptyInstructions", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_BadOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
opts Options
|
||||
}{
|
||||
{name: "temperature below range", opts: Options{Temperature: -0.1}},
|
||||
{name: "temperature above range", opts: Options{Temperature: 2.5}},
|
||||
{name: "negative max rounds", opts: Options{MaxRounds: -1}},
|
||||
{name: "negative max tokens", opts: Options{MaxTokens: -1}},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := New("instr", nil, nil, WithOptions(tt.opts)); !errors.Is(err, ErrBadOptions) {
|
||||
t.Fatalf("New error = %v, want ErrBadOptions", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DefaultsMaxRounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, err := New("instr", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if a.MaxRounds() != defaultMaxRounds {
|
||||
t.Fatalf("MaxRounds = %d, want %d", a.MaxRounds(), defaultMaxRounds)
|
||||
}
|
||||
if a.Purpose() != defaultPurpose {
|
||||
t.Fatalf("Purpose = %q, want %q", a.Purpose(), defaultPurpose)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_NoToolCalls_FinalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{name: "mock", resp: model.Response{Model: "gpt-test", Content: "Hello!", TotalTokens: 7}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
res, err := a.RunStep(context.Background(), "sess-1", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
||||
if err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if !res.Final {
|
||||
t.Fatalf("Final = false, want true")
|
||||
}
|
||||
if res.Text != "Hello!" {
|
||||
t.Fatalf("Text = %q, want Hello!", res.Text)
|
||||
}
|
||||
if res.StopReason != StopReasonStop {
|
||||
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonStop)
|
||||
}
|
||||
if len(res.ToolCalls) != 0 {
|
||||
t.Fatalf("ToolCalls = %v, want none", res.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_ToolCalls(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
||||
ad := &mockAdapter{resp: model.Response{Model: "m", Content: content}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
||||
if err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if res.Final {
|
||||
t.Fatalf("Final = true, want false")
|
||||
}
|
||||
if res.StopReason != StopReasonToolCalls {
|
||||
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonToolCalls)
|
||||
}
|
||||
if len(res.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls = %v, want 1", res.ToolCalls)
|
||||
}
|
||||
call := res.ToolCalls[0]
|
||||
if call.Name != "echo" {
|
||||
t.Fatalf("ToolCall.Name = %q, want echo", call.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(call.Args, map[string]any{"q": "hi"}) {
|
||||
t.Fatalf("ToolCall.Args = %v, want {q: hi}", call.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_ToolCallPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The model.Message placeholder convention: a single call with no args.
|
||||
ad := &mockAdapter{resp: model.Response{
|
||||
Model: "m",
|
||||
Content: "",
|
||||
Message: model.Message{Role: model.RoleAssistant, ToolCallName: "weather"},
|
||||
}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
||||
if err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if res.Final {
|
||||
t.Fatalf("Final = true, want false")
|
||||
}
|
||||
if len(res.ToolCalls) != 1 || res.ToolCalls[0].Name != "weather" {
|
||||
t.Fatalf("ToolCalls = %v, want single weather call", res.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_GuardrailInputBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := &mockGuard{inputVerdict: &guardrail.Verdict{Allow: false, Reason: "blocked in"}}
|
||||
pipe := guardrail.New(guardrail.WithGuard(g))
|
||||
ad := &mockAdapter{resp: model.Response{Content: "should not run"}}
|
||||
a := newTestAgent(t, ad, pipe)
|
||||
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
||||
if err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if !res.Final {
|
||||
t.Fatalf("Final = false, want true")
|
||||
}
|
||||
if res.Text != "blocked in" {
|
||||
t.Fatalf("Text = %q, want blocked in", res.Text)
|
||||
}
|
||||
if res.StopReason != StopReasonGuardrailInput {
|
||||
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonGuardrailInput)
|
||||
}
|
||||
if ad.calls != 0 {
|
||||
t.Fatalf("adapter Chat calls = %d, want 0 (blocked before the model)", ad.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_GuardrailOutputBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := &mockGuard{outputVerdict: &guardrail.Verdict{Allow: false, Reason: "blocked out"}}
|
||||
pipe := guardrail.New(guardrail.WithGuard(g))
|
||||
ad := &mockAdapter{resp: model.Response{Content: "model says this"}}
|
||||
a := newTestAgent(t, ad, pipe)
|
||||
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
||||
if err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if !res.Final {
|
||||
t.Fatalf("Final = false, want true")
|
||||
}
|
||||
if res.Text != "blocked out" {
|
||||
t.Fatalf("Text = %q, want blocked out", res.Text)
|
||||
}
|
||||
if res.StopReason != StopReasonGuardrailOutput {
|
||||
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonGuardrailOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_MultimodalRouting(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := model.NewRouter("default",
|
||||
model.WithRoleModel(model.RoleUser, "user-model"),
|
||||
model.WithVisionModel("vision-model"),
|
||||
)
|
||||
ad := &mockAdapter{resp: model.Response{Model: "vision-model", Content: "ok"}}
|
||||
a := newTestAgent(t, ad, nil, WithModel(ad, router))
|
||||
msg := model.ImageMessage(model.RoleUser, "what is this", "data:image/png;base64,abc")
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{msg}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if ad.gotReq.Model != "vision-model" {
|
||||
t.Fatalf("req.Model = %q, want vision-model (multimodal routing)", ad.gotReq.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_CallRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
var recs []model.CallRecord
|
||||
ad := &mockAdapter{resp: model.Response{Model: "m", Content: "hi", TotalTokens: 9}}
|
||||
a := newTestAgent(t, ad, nil,
|
||||
WithRecorder(func(cr model.CallRecord) { recs = append(recs, cr) }),
|
||||
WithOptions(Options{Purpose: "test"}),
|
||||
)
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("records = %d, want 1", len(recs))
|
||||
}
|
||||
rec := recs[0]
|
||||
if rec.Model != "m" || rec.Purpose != "test" || rec.Tokens != 9 || rec.TookMs < 0 {
|
||||
t.Fatalf("record = %+v, want model=m purpose=test tokens=9 took_ms>=0", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_NoAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, err := New("instr", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if _, err := a.RunStep(context.Background(), "s", nil); !errors.Is(err, ErrNoAdapter) {
|
||||
t.Fatalf("RunStep error = %v, want ErrNoAdapter", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_ChatError(t *testing.T) {
|
||||
t.Parallel()
|
||||
boom := errors.New("upstream boom")
|
||||
ad := &mockAdapter{err: boom}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); !errors.Is(err, boom) {
|
||||
t.Fatalf("RunStep error = %v, want to wrap %v", err, boom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_PrependsInstructions(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{resp: model.Response{Content: "hi"}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
msgs := ad.gotReq.Messages
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("len(messages) = %d, want 2 (instruction + user)", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != model.RoleSystem || len(msgs[0].Content) != 1 || !strings.Contains(msgs[0].Content[0].Text, "helpful") {
|
||||
t.Fatalf("first message = %+v, want leading system instruction", msgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_AdvertisesToolSchemas(t *testing.T) {
|
||||
t.Parallel()
|
||||
td := dispatcherWith(t, mockTool{name: "echo", desc: "echo the input", params: map[string]any{"type": "object"}})
|
||||
ad := &mockAdapter{resp: model.Response{Content: "hi"}}
|
||||
a, err := New("instr", td, nil, WithModel(ad, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if len(ad.gotReq.Tools) != 1 {
|
||||
t.Fatalf("req.Tools = %v, want 1 advertised schema", ad.gotReq.Tools)
|
||||
}
|
||||
if ad.gotReq.Tools[0].Name != "echo" {
|
||||
t.Fatalf("req.Tools[0].Name = %q, want echo", ad.gotReq.Tools[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_NoDispatcherNoTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{resp: model.Response{Content: "hi"}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if ad.gotReq.Tools != nil {
|
||||
t.Fatalf("req.Tools = %v, want nil", ad.gotReq.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStep_ContextCanceled(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{err: context.Canceled}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("RunStep error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_NonJSONContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := extractToolCalls(model.Response{Content: "plain text"}); ok {
|
||||
t.Fatalf("extractToolCalls(plain) ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoff_Placeholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := newTestAgent(t, &mockAdapter{resp: model.Response{Content: "hi"}}, nil, WithHandoff(NewHandoff("engineer", "needs code work")))
|
||||
h := a.Handoff()
|
||||
if h == nil || h.Target != "engineer" || h.Reason != "needs code work" {
|
||||
t.Fatalf("Handoff = %+v, want engineer/needs code work", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_Accessors(t *testing.T) {
|
||||
t.Parallel()
|
||||
td := dispatcherWith(t, mockTool{name: "echo"})
|
||||
pipe := guardrail.New(guardrail.WithGuard(&mockGuard{}))
|
||||
ad := &mockAdapter{name: "mock"}
|
||||
a, err := New("instr", td, pipe, WithModel(ad, nil), WithOptions(Options{
|
||||
Model: "gpt-4", Temperature: 0.7, MaxRounds: 4, Purpose: "chat", MaxTokens: 512,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if a.Instructions() != "instr" || a.Tools() != td || a.Guardrails() != pipe || a.Adapter() != ad {
|
||||
t.Fatalf("accessor mismatch for instructions/tools/guardrails/adapter")
|
||||
}
|
||||
if a.Model() != "gpt-4" || a.Temperature() != 0.7 || a.MaxRounds() != 4 || a.Purpose() != "chat" || a.MaxTokens() != 512 {
|
||||
t.Fatalf("accessor mismatch for config: %+v", a.Options())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperature_ZeroMapsToNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, err := New("instr", nil, nil, WithOptions(Options{Temperature: 1.2}))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if a.temperature() == nil || *a.temperature() != 1.2 {
|
||||
t.Fatalf("temperature() = %v, want 1.2", a.temperature())
|
||||
}
|
||||
a2, err := New("instr", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if a2.temperature() != nil {
|
||||
t.Fatalf("temperature() = %v, want nil for zero", a2.temperature())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package agent
|
||||
|
||||
// Handoff is a placeholder describing a transfer of control to another character
|
||||
// or subagent. It deliberately carries no orchestration: wiring the handover to
|
||||
// the agent-to-agent bus and subagent lifecycle belongs to the orchestration
|
||||
// layer (M3, DevDocs/design/04-运行时.md §2).
|
||||
type Handoff struct {
|
||||
// Target is the identity (character or subagent) receiving control.
|
||||
Target string
|
||||
// Reason explains why the agent is handing off, for the orchestrator and
|
||||
// audit logs.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// NewHandoff builds a Handoff placeholder for the given target and reason.
|
||||
func NewHandoff(target, reason string) *Handoff {
|
||||
return &Handoff{Target: target, Reason: reason}
|
||||
}
|
||||
|
||||
// Handoff returns the agent's configured handoff placeholder, or nil when none
|
||||
// was set. It is read-only: orchestration interprets it without mutating it.
|
||||
func (a *Agent) Handoff() *Handoff { return a.handoff }
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// Option bounds and defaults. These are compile-time constants so no magic
|
||||
// number appears in the runtime path.
|
||||
const (
|
||||
defaultMaxRounds = 8
|
||||
minTemperature = 0.0
|
||||
maxTemperature = 2.0
|
||||
)
|
||||
|
||||
// Options carries the config knobs for an Agent. Zero values fall back to a
|
||||
// provider default: temperature uses 0 to mean "provider default", and
|
||||
// MaxRounds/MaxTokens use 0 to mean "default"/"provider default" respectively.
|
||||
type Options struct {
|
||||
// Model is the requested model name. Empty lets the injected Router or the
|
||||
// adapter's own default decide.
|
||||
Model string
|
||||
// Temperature is the sampling temperature. A nonzero value is forwarded to
|
||||
// the model; zero means the provider default.
|
||||
Temperature float64
|
||||
// MaxRounds is the tool-loop upper bound. Zero is defaulted to 8.
|
||||
MaxRounds int
|
||||
// Purpose labels every CallRecord emitted for accounting ("chat", "agent").
|
||||
Purpose string
|
||||
// MaxTokens caps generated tokens. Zero means the provider default.
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// Option configures an Agent at construction time. Options are applied in the
|
||||
// order they are passed to New.
|
||||
type Option func(*Agent)
|
||||
|
||||
// WithModel injects the LLM backend used by RunStep: a model.Adapter plus an
|
||||
// optional per-role Router. When router is non-nil it overrides the Request's
|
||||
// Model from the request contents — which is how vision requests are routed to
|
||||
// the vision model — otherwise the agent's configured Model or the adapter
|
||||
// default applies.
|
||||
func WithModel(adapter model.Adapter, router *model.Router) Option {
|
||||
return func(a *Agent) {
|
||||
a.adapter = adapter
|
||||
a.router = router
|
||||
}
|
||||
}
|
||||
|
||||
// WithOptions applies opts to the agent's config, merging over the defaults. A
|
||||
// zero Model, MaxRounds, Purpose or MaxTokens leaves the existing value
|
||||
// unchanged; Temperature is copied verbatim.
|
||||
func WithOptions(opts Options) Option {
|
||||
return func(a *Agent) {
|
||||
if opts.Model != "" {
|
||||
a.opts.Model = opts.Model
|
||||
}
|
||||
a.opts.Temperature = opts.Temperature
|
||||
if opts.MaxRounds != 0 {
|
||||
a.opts.MaxRounds = opts.MaxRounds
|
||||
}
|
||||
if opts.Purpose != "" {
|
||||
a.opts.Purpose = opts.Purpose
|
||||
}
|
||||
if opts.MaxTokens != 0 {
|
||||
a.opts.MaxTokens = opts.MaxTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRecorder wires a CallRecord sink. After each successful model call the
|
||||
// agent emits one CallRecord to the recorder.
|
||||
func WithRecorder(r model.Recorder) Option {
|
||||
return func(a *Agent) { a.recorder = r }
|
||||
}
|
||||
|
||||
// WithHandoff sets the agent's handoff-target placeholder (M3 orchestration).
|
||||
func WithHandoff(h *Handoff) Option {
|
||||
return func(a *Agent) { a.handoff = h }
|
||||
}
|
||||
|
||||
// validateOptions rejects out-of-range option values so bad config fails at
|
||||
// construction rather than at request time.
|
||||
func validateOptions(o Options) error {
|
||||
if o.Temperature < minTemperature || o.Temperature > maxTemperature {
|
||||
return fmt.Errorf("%w: temperature %v out of range [%v, %v]", ErrBadOptions, o.Temperature, minTemperature, maxTemperature)
|
||||
}
|
||||
if o.MaxRounds < 0 {
|
||||
return fmt.Errorf("%w: max rounds %d", ErrBadOptions, o.MaxRounds)
|
||||
}
|
||||
if o.MaxTokens < 0 {
|
||||
return fmt.Errorf("%w: max tokens %d", ErrBadOptions, o.MaxTokens)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package agent
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
|
||||
// defaultPurpose labels CallRecords when Options.Purpose is empty.
|
||||
const defaultPurpose = "agent"
|
||||
|
||||
// buildRequest assembles a model.Request for one step: the agent's instructions
|
||||
// prepended as a leading system message, plus the conversation messages, the
|
||||
// configured model/temperature/max-tokens/purpose and the registered tool
|
||||
// schemas. When a router is injected it overrides the model from the request
|
||||
// contents, which is how vision requests are routed to the vision model.
|
||||
func (a *Agent) buildRequest(messages []model.Message) model.Request {
|
||||
req := model.Request{
|
||||
Model: a.opts.Model,
|
||||
Messages: a.assembleMessages(messages),
|
||||
Temperature: a.temperature(),
|
||||
MaxTokens: a.opts.MaxTokens,
|
||||
Purpose: a.purpose(),
|
||||
Tools: a.toolSchemas(),
|
||||
}
|
||||
if a.router != nil {
|
||||
if routeModel, _ := a.router.RouteRequest(req); routeModel != "" {
|
||||
req.Model = routeModel
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// assembleMessages returns messages with the agent's instructions prepended as a
|
||||
// leading system message when instructions are non-empty, otherwise the input
|
||||
// slice is returned unchanged.
|
||||
func (a *Agent) assembleMessages(messages []model.Message) []model.Message {
|
||||
if a.instructions == "" {
|
||||
return messages
|
||||
}
|
||||
out := make([]model.Message, 0, len(messages)+1)
|
||||
out = append(out, model.TextMessage(model.RoleSystem, a.instructions))
|
||||
out = append(out, messages...)
|
||||
return out
|
||||
}
|
||||
|
||||
// temperature returns the temperature pointer to place in a Request. Zero maps
|
||||
// to nil so the provider default applies.
|
||||
func (a *Agent) temperature() *float64 {
|
||||
if a.opts.Temperature == 0 {
|
||||
return nil
|
||||
}
|
||||
t := a.opts.Temperature
|
||||
return &t
|
||||
}
|
||||
|
||||
// purpose returns the CallRecord purpose label, defaulting to defaultPurpose.
|
||||
func (a *Agent) purpose() string {
|
||||
if a.opts.Purpose == "" {
|
||||
return defaultPurpose
|
||||
}
|
||||
return a.opts.Purpose
|
||||
}
|
||||
|
||||
// toolSchemas returns the registered tool schemas advertised to the model, or
|
||||
// nil when no dispatcher is configured.
|
||||
func (a *Agent) toolSchemas() []model.ToolSpec {
|
||||
if a.tools == nil {
|
||||
return nil
|
||||
}
|
||||
return a.tools.ToolSchemas()
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// Stop reasons carried by StepResult.StopReason, consumed by the loop (M2) to
|
||||
// decide how to continue after a step.
|
||||
const (
|
||||
// StopReasonStop is a normal final answer with no tool calls.
|
||||
StopReasonStop = "stop"
|
||||
// StopReasonToolCalls means the assistant requested tool calls.
|
||||
StopReasonToolCalls = "tool_calls"
|
||||
// StopReasonGuardrailInput means the input guardrail blocked the request.
|
||||
StopReasonGuardrailInput = "guardrail_input"
|
||||
// StopReasonGuardrailOutput means the output guardrail blocked the response.
|
||||
StopReasonGuardrailOutput = "guardrail_output"
|
||||
)
|
||||
|
||||
// ToolCall is a single function call the assistant requested, decoded from a
|
||||
// step. Args holds the tool arguments as a JSON object.
|
||||
type ToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
// StepResult is the outcome of a single agent step.
|
||||
type StepResult struct {
|
||||
// Final is true when the step produced a final answer (or was intercepted by
|
||||
// a guardrail) and no tool execution is required.
|
||||
Final bool
|
||||
// Text is the assistant's final text, or the guardrail's block reason when
|
||||
// Final is true.
|
||||
Text string
|
||||
// ToolCalls is non-empty when Final is false, listing the tool calls to run.
|
||||
ToolCalls []ToolCall
|
||||
// StopReason classifies how the step ended (see the StopReason* consts).
|
||||
StopReason string
|
||||
}
|
||||
|
||||
// RunStep executes one step of the agent loop. It assembles a model.Request from
|
||||
// the agent's instructions prepended as a leading system message plus the
|
||||
// conversation messages, runs input guardrails, calls the model adapter, runs
|
||||
// output guardrails and decodes any tool calls. sessionID identifies the
|
||||
// conversation for downstream tracing/telemetry and is currently carried without
|
||||
// a sink; it is reserved for trace/persist (M2/M9).
|
||||
//
|
||||
// A final text answer yields Final=true with StopReason "stop"; a tool request
|
||||
// yields Final=false with the decoded ToolCalls; a guardrail denial yields
|
||||
// Final=true carrying the block reason. A nil adapter or a failed guardrail or
|
||||
// model call returns a wrapped error.
|
||||
func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model.Message) (*StepResult, error) {
|
||||
req := a.buildRequest(messages)
|
||||
|
||||
if a.guards != nil {
|
||||
verdict, err := a.guards.CheckInput(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: check input: %w", err)
|
||||
}
|
||||
if verdict != nil && !verdict.Allow {
|
||||
return &StepResult{Final: true, Text: verdict.Reason, StopReason: StopReasonGuardrailInput}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if a.adapter == nil {
|
||||
return nil, fmt.Errorf("agent: run step: %w", ErrNoAdapter)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := a.adapter.Chat(ctx, req, model.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: chat: %w", err)
|
||||
}
|
||||
if a.recorder != nil {
|
||||
a.recorder(model.CallRecord{
|
||||
Model: resp.Model,
|
||||
Purpose: req.Purpose,
|
||||
Tokens: resp.TotalTokens,
|
||||
TookMs: time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
if a.guards != nil {
|
||||
verdict, err := a.guards.CheckOutput(ctx, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: check output: %w", err)
|
||||
}
|
||||
if verdict != nil && !verdict.Allow {
|
||||
return &StepResult{Final: true, Text: verdict.Reason, StopReason: StopReasonGuardrailOutput}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if calls, ok := extractToolCalls(resp); ok {
|
||||
return &StepResult{Final: false, ToolCalls: calls, StopReason: StopReasonToolCalls}, nil
|
||||
}
|
||||
return &StepResult{Final: true, Text: resp.Content, StopReason: StopReasonStop}, nil
|
||||
}
|
||||
|
||||
// extractToolCalls decodes tool calls from a model.Response. The current model
|
||||
// layer does not parse provider tool_calls into a structured field, so the agent
|
||||
// uses two conventions in priority order:
|
||||
//
|
||||
// 1. The normalized assistant-message placeholder: when resp.Message carries a
|
||||
// ToolCallName it is a single call with no argument payload.
|
||||
// 2. A JSON envelope in resp.Content of the form
|
||||
// {"tool_calls":[{"name":"...","args":{...}}]}.
|
||||
//
|
||||
// It reports ok=true (with a non-empty slice) when a call is decoded, and
|
||||
// ok=false otherwise.
|
||||
func extractToolCalls(resp model.Response) ([]ToolCall, bool) {
|
||||
if resp.Message.Role == model.RoleAssistant && resp.Message.ToolCallName != "" {
|
||||
return []ToolCall{{Name: resp.Message.ToolCallName, Args: map[string]any{}}}, true
|
||||
}
|
||||
var payload struct {
|
||||
ToolCalls []ToolCall `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(resp.Content), &payload); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if len(payload.ToolCalls) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return payload.ToolCalls, true
|
||||
}
|
||||
Reference in new issue
Block a user