mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-17 21:25:49 +02:00
- 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
307 lines
9.2 KiB
Go
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)
|
|
}
|