feat(agent): Agent 实体(指令/工具/guardrail/handoff + 单步 RunStep,含多模态路由/调用记录)

This commit is contained in:
Aska committed 2026-09-06 05:20:32 +08:00
1 parent b798e129c1
commit be67d74822
6 files changed
+843

No files matched your search

+110
View File
@@ -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 }
+418
View File
@@ -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())
}
}
+22
View File
@@ -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 }
+96
View File
@@ -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
}
+68
View File
@@ -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()
}
+129
View File
@@ -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
}