Files
osmedeus/internal/executor/tool_executor.go
T
j3ssie 438d8ec138 feat: implement agent executor with tool calling, sub-agents, and comprehensive test suite
- Add AgentExecutor implementing LLM-based agentic loop with tool calling, max iterations, and stop conditions
- Introduce agent preset tools (bash, file_exists, http_get, run_module, etc.) with extensible registry pattern
- Add sub-agent spawning capability via spawn_agent tool call with recursive depth limits and validation
- Implement ToolExecutor for custom tool execution with template rendering and error handling
- Add agent session persistence and memory management with sliding window configuration
- Create comprehensive E2E test suite covering 15+ agent workflow scenarios (minimal, custom tools, planning, multi-goal, structured output, tracing hooks, file tools, orchestration, Python tools, sub-agents, nested sub-agents, and validation)
- Add agent-and-llm test data directory with 17 YAML workflow fixtures
- Update integration tests to include agent workflow directories
- Add AgentTool and AgentConfig types with validation for duplicate names and unknown presets
- Implement LLM streaming test utilities
- Update documentation (CLAUDE.md, HACKING.md, README.md) with agent features and CLI examples
2026-02-10 08:44:48 +07:00

307 lines
9.2 KiB
Go

package executor
import (
"context"
"fmt"
"github.com/j3ssie/osmedeus/v5/internal/config"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/functions"
"github.com/j3ssie/osmedeus/v5/internal/json"
"github.com/j3ssie/osmedeus/v5/internal/logger"
"github.com/j3ssie/osmedeus/v5/internal/template"
"go.uber.org/zap"
)
// ToolExecutor defines the interface for executing agent tool calls.
// Each implementation handles a specific tool (preset or custom).
type ToolExecutor interface {
// Name returns the tool name (matches the function name in LLM tool calls)
Name() string
// Execute runs the tool with the given arguments and returns the result string
Execute(ctx context.Context, args map[string]interface{}, execCtx *core.ExecutionContext) (string, error)
}
// ToolExecutorRegistry manages a collection of ToolExecutor instances
type ToolExecutorRegistry struct {
executors map[string]ToolExecutor
}
// NewToolExecutorRegistry creates an empty registry
func NewToolExecutorRegistry() *ToolExecutorRegistry {
return &ToolExecutorRegistry{
executors: make(map[string]ToolExecutor),
}
}
// Register adds a ToolExecutor to the registry
func (r *ToolExecutorRegistry) Register(te ToolExecutor) {
r.executors[te.Name()] = te
}
// Get returns the ToolExecutor for the given name
func (r *ToolExecutorRegistry) Get(name string) (ToolExecutor, bool) {
te, ok := r.executors[name]
return te, ok
}
// Execute dispatches a tool call to the appropriate executor
func (r *ToolExecutorRegistry) Execute(ctx context.Context, name string, args map[string]interface{}, execCtx *core.ExecutionContext) (string, error) {
te, ok := r.executors[name]
if !ok {
return "", fmt.Errorf("unknown tool: %s", name)
}
return te.Execute(ctx, args, execCtx)
}
// PresetToolExecutor wraps a preset tool and executes it via the function registry
type PresetToolExecutor struct {
name string
registry *functions.Registry
}
// NewPresetToolExecutor creates a preset tool executor
func NewPresetToolExecutor(name string, registry *functions.Registry) *PresetToolExecutor {
return &PresetToolExecutor{name: name, registry: registry}
}
// Name returns the tool name
func (e *PresetToolExecutor) Name() string {
return e.name
}
// Execute runs the preset tool by building a function call expression
func (e *PresetToolExecutor) Execute(_ context.Context, args map[string]interface{}, execCtx *core.ExecutionContext) (string, error) {
expr := buildPresetCallExpr(e.name, args)
vars := execCtx.GetVariables()
result, err := e.registry.Execute(expr, vars)
if err != nil {
return "", fmt.Errorf("preset tool '%s' failed: %w", e.name, err)
}
return formatToolResult(result), nil
}
// CustomToolExecutor wraps a custom tool with a JS handler expression
type CustomToolExecutor struct {
name string
handler string
registry *functions.Registry
}
// NewCustomToolExecutor creates a custom tool executor
func NewCustomToolExecutor(name, handler string, registry *functions.Registry) *CustomToolExecutor {
return &CustomToolExecutor{name: name, handler: handler, registry: registry}
}
// Name returns the tool name
func (e *CustomToolExecutor) Name() string {
return e.name
}
// Execute runs the custom tool handler JS expression
func (e *CustomToolExecutor) Execute(_ context.Context, args map[string]interface{}, execCtx *core.ExecutionContext) (string, error) {
vars := execCtx.GetVariables()
vars["args"] = args
result, err := e.registry.Execute(e.handler, vars)
if err != nil {
return "", fmt.Errorf("custom handler '%s' failed: %w", e.name, err)
}
return formatToolResult(result), nil
}
// BuildToolRegistry constructs a ToolExecutorRegistry from the resolved agent tool definitions
func BuildToolRegistry(toolDefs []core.AgentToolDef, funcRegistry *functions.Registry) *ToolExecutorRegistry {
log := logger.Get()
reg := NewToolExecutorRegistry()
for _, def := range toolDefs {
if def.IsPreset() {
reg.Register(NewPresetToolExecutor(def.Preset, funcRegistry))
} else if def.Handler != "" {
reg.Register(NewCustomToolExecutor(def.Name, def.Handler, funcRegistry))
} else {
log.Debug("Tool definition has no handler, registering as preset fallback",
zap.String("tool", def.Name),
)
reg.Register(NewPresetToolExecutor(def.Name, funcRegistry))
}
}
return reg
}
// SubAgentToolExecutor handles spawn_agent tool calls by creating
// a child AgentExecutor and running the specified sub-agent.
type SubAgentToolExecutor struct {
subAgents map[string]core.SubAgentDef
templateEngine template.TemplateEngine
funcRegistry *functions.Registry
config *config.Config
silent bool
currentDepth int
maxDepth int
parentState *agentState // for token merging
}
// Name returns the tool name
func (e *SubAgentToolExecutor) Name() string {
return core.SpawnAgentToolName
}
// Execute spawns a child agent and returns its output as the tool result.
func (e *SubAgentToolExecutor) Execute(ctx context.Context, args map[string]interface{}, execCtx *core.ExecutionContext) (string, error) {
log := logger.Get()
agentName, _ := args["agent"].(string)
query, _ := args["query"].(string)
if agentName == "" {
return "", fmt.Errorf("spawn_agent requires 'agent' parameter")
}
if query == "" {
return "", fmt.Errorf("spawn_agent requires 'query' parameter")
}
// Look up sub-agent definition
saDef, ok := e.subAgents[agentName]
if !ok {
return "", fmt.Errorf("unknown sub-agent: %s", agentName)
}
// Check depth limit
childDepth := e.currentDepth + 1
if childDepth > e.maxDepth {
return "", fmt.Errorf("sub-agent depth limit exceeded (depth %d > max %d)", childDepth, e.maxDepth)
}
log.Debug("Spawning sub-agent",
zap.String("agent", agentName),
zap.Int("depth", childDepth),
zap.Int("max_depth", e.maxDepth),
)
// Build synthetic step from SubAgentDef
syntheticStep := buildSyntheticStep(&saDef, query)
// Create child AgentExecutor
childExec := NewAgentExecutor(e.templateEngine, e.funcRegistry)
childExec.SetConfig(e.config)
childExec.SetSilent(e.silent)
childExec.SetDepthContext(childDepth, e.maxDepth)
// Execute child agent
result, err := childExec.Execute(ctx, syntheticStep, execCtx)
if err != nil {
// Return error as string tool result (don't crash parent)
errMsg := fmt.Sprintf("Sub-agent '%s' failed: %s", agentName, err.Error())
log.Warn("Sub-agent execution failed",
zap.String("agent", agentName),
zap.Error(err),
)
return errMsg, nil
}
// Merge child tokens into parent
if e.parentState != nil {
childTotal, _ := result.Exports["agent_total_tokens"].(int)
childPrompt, _ := result.Exports["agent_prompt_tokens"].(int)
childCompletion, _ := result.Exports["agent_completion_tokens"].(int)
e.parentState.MergeTokens(childTotal, childPrompt, childCompletion)
}
return result.Output, nil
}
// buildSyntheticStep creates a Step from a SubAgentDef and query.
func buildSyntheticStep(sa *core.SubAgentDef, query string) *core.Step {
maxIterations := sa.MaxIterations
if maxIterations <= 0 {
maxIterations = 10
}
step := &core.Step{
Name: "sub-agent-" + sa.Name,
Type: core.StepTypeAgent,
Query: query,
SystemPrompt: sa.SystemPrompt,
AgentTools: sa.AgentTools,
MaxIterations: maxIterations,
Models: sa.Models,
LLMConfig: sa.LLMConfig,
OutputSchema: sa.OutputSchema,
Memory: sa.Memory,
StopCondition: sa.StopCondition,
SubAgents: sa.SubAgents,
OnToolStart: sa.OnToolStart,
OnToolEnd: sa.OnToolEnd,
}
return step
}
// BuildToolRegistryWithSubAgents constructs a ToolExecutorRegistry that includes
// both standard tools and the spawn_agent tool for sub-agent delegation.
func BuildToolRegistryWithSubAgents(
toolDefs []core.AgentToolDef,
funcRegistry *functions.Registry,
engine template.TemplateEngine,
cfg *config.Config,
silent bool,
currentDepth int,
maxDepth int,
parentState *agentState,
subAgents []core.SubAgentDef,
) *ToolExecutorRegistry {
reg := BuildToolRegistry(toolDefs, funcRegistry)
if len(subAgents) > 0 {
// Build name → def map
saMap := make(map[string]core.SubAgentDef, len(subAgents))
for _, sa := range subAgents {
saMap[sa.Name] = sa
}
reg.Register(&SubAgentToolExecutor{
subAgents: saMap,
templateEngine: engine,
funcRegistry: funcRegistry,
config: cfg,
silent: silent,
currentDepth: currentDepth,
maxDepth: maxDepth,
parentState: parentState,
})
}
return reg
}
// executeToolCallViaRegistry parses tool call arguments and dispatches to the registry
func executeToolCallViaRegistry(
ctx context.Context,
tc core.LLMToolCall,
toolRegistry *ToolExecutorRegistry,
execCtx *core.ExecutionContext,
log *zap.Logger,
) (string, error) {
funcName := tc.Function.Name
argsJSON := tc.Function.Arguments
log.Debug("Executing tool call via registry",
zap.String("tool", funcName),
zap.String("args", argsJSON),
)
var args map[string]interface{}
if argsJSON != "" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("failed to parse tool arguments for %s: %w", funcName, err)
}
}
if args == nil {
args = make(map[string]interface{})
}
return toolRegistry.Execute(ctx, funcName, args, execCtx)
}