166 lines
6.1 KiB
Go
166 lines
6.1 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/cli/run"
|
|
"github.com/smallnest/pigo/internal/provider"
|
|
"github.com/smallnest/pigo/internal/runtime"
|
|
)
|
|
|
|
// Session is a single, stateful pigo agent conversation. Create one with New,
|
|
// drive it with Prompt or Stream, and release its resources with Close. The
|
|
// conversation history accumulates across calls, so follow-up prompts see the
|
|
// earlier exchange; call Reset to start over on the same session.
|
|
//
|
|
// A Session is not safe for concurrent use. Drive it from a single goroutine, or
|
|
// give each goroutine its own Session.
|
|
type Session struct {
|
|
env run.Env
|
|
runCfg runtime.RunConfig
|
|
agentCtx *agentcore.AgentContext
|
|
model string
|
|
}
|
|
|
|
// New builds a Session from the given options. It resolves the provider and
|
|
// credentials, assembles the tool set, and validates the tool policy and
|
|
// thinking level up front, so a configuration mistake (an unknown tool name, an
|
|
// invalid thinking level, an unresolvable provider) is returned here rather than
|
|
// surfacing on the first Prompt.
|
|
//
|
|
// No network call is made by New: the provider is only contacted when you call
|
|
// Prompt or Stream. This makes New cheap and safe to use in tests.
|
|
//
|
|
// See the package documentation for the default tool, skill, and memory
|
|
// behavior — in particular, that tools are enabled and auto-executed by default.
|
|
func New(opts ...Option) (*Session, error) {
|
|
c := config{model: "openrouter/free"}
|
|
for _, o := range opts {
|
|
o(&c)
|
|
}
|
|
|
|
// Validate the reasoning-effort level through the same layered config chain
|
|
// the CLI uses, so an invalid WithThinkingLevel value fails fast here.
|
|
thinking, err := run.ResolveThinkingLevel(c.thinking)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// One ToolPolicy value carries both lists so they cannot be swapped; deny
|
|
// always wins over allow inside run.ApplyToolPolicy.
|
|
policy := run.NewToolPolicy(c.allowedTools, c.disallowedTools)
|
|
|
|
// SetupEnv resolves the provider, assembles the (policy-filtered) tool set,
|
|
// builds the system prompt, and — because skills/memory are opt-in here —
|
|
// leaves the machine's shared state untouched unless WithSkills/WithMemory
|
|
// were passed. It also validates the tool policy against the real tool set,
|
|
// so an unknown tool name is reported as an error.
|
|
env, err := run.SetupEnv(
|
|
c.model, c.baseURL, c.protocol, c.provider, c.apiKey,
|
|
c.noTools, !c.skills, c.systemPrompt, c.appendSystemPrompt, c.memory, policy,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Resolve the API key by provider name: an explicit WithAPIKey overrides the
|
|
// provider's environment variable. The key is held only in the credential
|
|
// store and never logged.
|
|
creds := provider.NewCredentialStore(nil)
|
|
if c.apiKey != "" {
|
|
creds.SetOverride(env.ProviderName, c.apiKey)
|
|
}
|
|
|
|
runCfg := run.NewConfig(
|
|
c.model, env.ProviderName, thinking, env.Provider, creds,
|
|
run.ToolRegistry(env.Tools), run.TodoReminders(env.Tools),
|
|
)
|
|
|
|
return &Session{
|
|
env: env,
|
|
runCfg: runCfg,
|
|
agentCtx: &agentcore.AgentContext{
|
|
SystemPrompt: env.SysPrompt,
|
|
Tools: env.Tools,
|
|
},
|
|
model: c.model,
|
|
}, nil
|
|
}
|
|
|
|
// Prompt sends one user message, runs the agent loop to completion (executing
|
|
// any tool calls the model makes along the way), and returns the assistant's
|
|
// final text. The exchange is appended to the session history so later prompts
|
|
// have this context.
|
|
func (s *Session) Prompt(ctx context.Context, prompt string) (string, error) {
|
|
return s.Stream(ctx, prompt, nil)
|
|
}
|
|
|
|
// Stream is Prompt with incremental output: onText, if non-nil, is called with
|
|
// each chunk of assistant text as it arrives, and the complete final text is
|
|
// also returned. Tool calls still run automatically between text chunks. A nil
|
|
// onText makes Stream behave exactly like Prompt.
|
|
func (s *Session) Stream(ctx context.Context, prompt string, onText func(string)) (string, error) {
|
|
// The loop expects the initiating user message already appended; it then
|
|
// mutates agentCtx.Messages in place (assistant + tool results), which is
|
|
// what carries the conversation forward across calls.
|
|
s.agentCtx.Messages = append(s.agentCtx.Messages, agentcore.UserMessage{
|
|
RoleField: agentcore.RoleUser,
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(prompt)},
|
|
})
|
|
|
|
stream := runtime.StartRun(ctx, s.agentCtx, s.runCfg)
|
|
final, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{OnText: onText})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if final == nil {
|
|
return "", nil
|
|
}
|
|
return agentcore.ContentToText(final.Content), nil
|
|
}
|
|
|
|
// Reset clears the conversation history, so the next Prompt starts a fresh
|
|
// exchange. The provider, tool set, and system prompt are unchanged.
|
|
func (s *Session) Reset() {
|
|
s.agentCtx.Messages = nil
|
|
}
|
|
|
|
// ToolNames returns the names of the tools available to this session, in the
|
|
// order they are advertised to the model. It reflects the applied tool policy,
|
|
// so it is a convenient way to confirm WithTools/WithDisallowedTools did what
|
|
// you intended. The result is empty for a WithoutTools session.
|
|
func (s *Session) ToolNames() []string {
|
|
names := make([]string, len(s.env.Tools))
|
|
for i, t := range s.env.Tools {
|
|
names[i] = t.Name()
|
|
}
|
|
return names
|
|
}
|
|
|
|
// Model returns the model id the session was created with.
|
|
func (s *Session) Model() string { return s.model }
|
|
|
|
// Provider returns the resolved provider name (e.g. "anthropic", "openrouter"),
|
|
// which is inferred from the model id unless WithProvider was set.
|
|
func (s *Session) Provider() string { return s.env.ProviderName }
|
|
|
|
// Close releases resources held by the session: any loaded plugin manager and
|
|
// the persistent memory store (when WithMemory was used). It is safe to call
|
|
// once, and safe to call on a session that holds neither. After Close the
|
|
// session must not be used again.
|
|
func (s *Session) Close() error {
|
|
var firstErr error
|
|
if s.env.Plugins != nil {
|
|
if err := s.env.Plugins.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
if s.env.Memory != nil {
|
|
if err := s.env.Memory.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|