diff --git a/internal/runtime/agent/agent.go b/internal/runtime/agent/agent.go new file mode 100644 index 0000000..6748ed3 --- /dev/null +++ b/internal/runtime/agent/agent.go @@ -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 } diff --git a/internal/runtime/agent/agent_test.go b/internal/runtime/agent/agent_test.go new file mode 100644 index 0000000..d8730dc --- /dev/null +++ b/internal/runtime/agent/agent_test.go @@ -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()) + } +} diff --git a/internal/runtime/agent/handoff.go b/internal/runtime/agent/handoff.go new file mode 100644 index 0000000..9c6e871 --- /dev/null +++ b/internal/runtime/agent/handoff.go @@ -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 } diff --git a/internal/runtime/agent/options.go b/internal/runtime/agent/options.go new file mode 100644 index 0000000..f21a48b --- /dev/null +++ b/internal/runtime/agent/options.go @@ -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 +} diff --git a/internal/runtime/agent/request.go b/internal/runtime/agent/request.go new file mode 100644 index 0000000..65337aa --- /dev/null +++ b/internal/runtime/agent/request.go @@ -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() +} diff --git a/internal/runtime/agent/step.go b/internal/runtime/agent/step.go new file mode 100644 index 0000000..dd17a89 --- /dev/null +++ b/internal/runtime/agent/step.go @@ -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 +}