Files
osmedeus/internal/executor/agent_executor.go
T
j3ssie 33aa3d82bc feat: add TypeScript execution and CDN/WAF asset classification
- Add exec_ts() and exec_ts_file() utility functions for inline and file-based TypeScript execution via Bun
- Implement CDN/WAF detection system with is_cdn, is_cloud, is_waf boolean fields for assets
- Enhance Python execution to prefer uv package manager with fallback to python3/python
- Update roadmap with cloud integration step, clarify security warning, improve documentation
2026-02-15 11:32:55 +07:00

1225 lines
36 KiB
Go

package executor
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"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"
)
// AgentExecutor implements the agentic loop for type: agent steps.
// It queries the LLM, handles tool calls by executing them via the ToolExecutorRegistry,
// feeds results back, and repeats until the LLM responds without tool calls or
// the max_iterations limit is reached.
type AgentExecutor struct {
templateEngine template.TemplateEngine
functionRegistry *functions.Registry
config *config.Config
silent bool
currentDepth int // 0 = top-level
maxDepth int // default 3
}
// NewAgentExecutor creates a new agent executor
func NewAgentExecutor(engine template.TemplateEngine, funcRegistry *functions.Registry) *AgentExecutor {
return &AgentExecutor{
templateEngine: engine,
functionRegistry: funcRegistry,
}
}
// Name returns the executor name
func (e *AgentExecutor) Name() string {
return "agent"
}
// StepTypes returns the step types this executor handles
func (e *AgentExecutor) StepTypes() []core.StepType {
return []core.StepType{core.StepTypeAgent}
}
// SetConfig sets the application config
func (e *AgentExecutor) SetConfig(cfg *config.Config) {
e.config = cfg
}
// SetSilent enables or disables silent mode
func (e *AgentExecutor) SetSilent(s bool) {
e.silent = s
}
// SetDepthContext sets the current nesting depth and maximum allowed depth.
func (e *AgentExecutor) SetDepthContext(depth, maxDepth int) {
e.currentDepth = depth
e.maxDepth = maxDepth
}
// agentState tracks the agent's runtime state during execution
type agentState struct {
messages []ChatMessage
totalTokens int
promptTokens int
completionTokens int
iteration int
toolResults []map[string]interface{}
finalContent string
planContent string // populated by planning stage
goalResults []map[string]interface{} // results from each goal in multi-goal mode
toolRegistry *ToolExecutorRegistry // pluggable tool dispatch
tokenMu sync.Mutex // protects token fields for concurrent sub-agent merging
}
// MergeTokens safely adds child agent token counts into this state.
// Thread-safe for concurrent sub-agent spawning.
func (s *agentState) MergeTokens(total, prompt, completion int) {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
s.totalTokens += total
s.promptTokens += prompt
s.completionTokens += completion
}
// Execute runs the agent loop
func (e *AgentExecutor) Execute(ctx context.Context, step *core.Step, execCtx *core.ExecutionContext) (*core.StepResult, error) {
log := logger.Get()
result := &core.StepResult{
StepName: step.Name,
Status: core.StepStatusRunning,
StartTime: time.Now(),
Exports: make(map[string]interface{}),
}
// Validate config
if e.config == nil {
return e.fail(result, fmt.Errorf("agent executor config not set"))
}
// Validate required fields: need query OR queries (not both)
if step.Query == "" && len(step.Queries) == 0 {
return e.fail(result, fmt.Errorf("agent step '%s' requires 'query' or 'queries' field", step.Name))
}
if step.Query != "" && len(step.Queries) > 0 {
return e.fail(result, fmt.Errorf("agent step '%s' cannot have both 'query' and 'queries'", step.Name))
}
if step.MaxIterations <= 0 {
return e.fail(result, fmt.Errorf("agent step '%s' requires 'max_iterations' > 0", step.Name))
}
if len(step.AgentTools) == 0 {
return e.fail(result, fmt.Errorf("agent step '%s' requires 'agent_tools' field", step.Name))
}
// Parse output schema if specified
var outputSchema *core.LLMResponseFormat
if step.OutputSchema != "" {
var err error
outputSchema, err = core.ParseOutputSchema(step.OutputSchema)
if err != nil {
return e.fail(result, fmt.Errorf("agent step '%s': %w", step.Name, err))
}
}
// Resolve agent tools to OpenAI-compatible schemas (with spawn_agent if sub-agents present)
tools, err := core.ResolveAgentToolsWithSubAgents(step.AgentTools, step.SubAgents)
if err != nil {
return e.fail(result, fmt.Errorf("agent step '%s': %w", step.Name, err))
}
// Get merged LLM config
llmConfig := e.getMergedConfig(step)
// Compute effective max depth
effectiveMaxDepth := step.MaxAgentDepth
if effectiveMaxDepth <= 0 {
effectiveMaxDepth = core.DefaultMaxAgentDepth
}
// If spawned as child, inherit parent's max depth
if e.maxDepth > 0 {
effectiveMaxDepth = e.maxDepth
}
// Initialize state before building tool registry (needed as parentState arg)
state := &agentState{}
// Build ToolExecutorRegistry with sub-agent support if needed
var toolRegistry *ToolExecutorRegistry
if len(step.SubAgents) > 0 {
toolRegistry = BuildToolRegistryWithSubAgents(
step.AgentTools, e.functionRegistry,
e.templateEngine, e.config, e.silent,
e.currentDepth, effectiveMaxDepth,
state, step.SubAgents,
)
} else {
toolRegistry = BuildToolRegistry(step.AgentTools, e.functionRegistry)
}
state.toolRegistry = toolRegistry
// Load resumed conversation if specified
if step.Memory != nil && step.Memory.ResumePath != "" {
if err := e.loadConversation(state, step.Memory.ResumePath); err != nil {
log.Warn("Failed to load conversation for resume, starting fresh",
zap.String("path", step.Memory.ResumePath),
zap.Error(err),
)
}
}
// Determine queries to execute (Group 3: multi-goal)
queries := []string{step.Query}
if len(step.Queries) > 0 {
queries = step.Queries
}
// Run planning stage if configured (Group 2)
if step.PlanPrompt != "" {
planContent, err := e.executePlanningStage(ctx, state, step, llmConfig)
if err != nil {
log.Warn("Planning stage failed, continuing without plan",
zap.String("step", step.Name),
zap.Error(err),
)
} else {
state.planContent = planContent
}
}
// Execute each query (single or multi-goal)
for goalIdx, query := range queries {
if query == "" {
continue
}
// Build initial messages for this goal
e.initMessages(state, query, step, llmConfig, goalIdx)
log.Debug("Starting agent loop",
zap.String("step", step.Name),
zap.Int("goal", goalIdx+1),
zap.Int("total_goals", len(queries)),
zap.Int("max_iterations", step.MaxIterations),
zap.Int("tools", len(tools)),
)
// Main agent loop
for state.iteration = 1; state.iteration <= step.MaxIterations; state.iteration++ {
log.Debug("Agent iteration",
zap.String("step", step.Name),
zap.Int("iteration", state.iteration),
zap.Int("messages", len(state.messages)),
)
// Build LLM request, potentially with structured output on final iteration
var responseFormat *core.LLMResponseFormat
if outputSchema != nil && state.iteration == step.MaxIterations {
responseFormat = outputSchema
}
// Call LLM with optional model fallback (Group 5)
response, err := e.callLLMWithFallback(ctx, state, tools, llmConfig, step.Models, responseFormat)
if err != nil {
return e.fail(result, fmt.Errorf("agent step '%s' iteration %d: %w", step.Name, state.iteration, err))
}
if response == nil || len(response.Choices) == 0 {
return e.fail(result, fmt.Errorf("agent step '%s': empty response from LLM", step.Name))
}
// Track tokens
state.totalTokens += response.Usage.TotalTokens
state.promptTokens += response.Usage.PromptTokens
state.completionTokens += response.Usage.CompletionTokens
choice := response.Choices[0]
// Append assistant message to conversation
state.messages = append(state.messages, choice.Message)
// Extract content
if content, ok := choice.Message.Content.(string); ok {
state.finalContent = content
}
// Check if we're done (no tool calls)
if len(choice.Message.ToolCalls) == 0 {
// If we have OutputSchema and this isn't the max iteration,
// try to enforce structured output on the next call
if outputSchema != nil && state.iteration < step.MaxIterations {
log.Debug("Agent completed (no tool calls), structured output available",
zap.String("step", step.Name),
zap.Int("iterations", state.iteration),
)
} else {
log.Debug("Agent completed (no tool calls)",
zap.String("step", step.Name),
zap.Int("iterations", state.iteration),
)
}
break
}
// Execute tool calls with tracing hooks (Group 7)
toolMessages, err := e.executeToolCalls(ctx, choice.Message.ToolCalls, state, step, execCtx)
if err != nil {
return e.fail(result, fmt.Errorf("agent step '%s' iteration %d tool execution: %w", step.Name, state.iteration, err))
}
// Append tool results to conversation
state.messages = append(state.messages, toolMessages...)
// Evaluate stop condition if defined
if step.StopCondition != "" {
vars := execCtx.GetVariables()
vars["agent_content"] = state.finalContent
vars["iteration"] = state.iteration
shouldStop, err := e.functionRegistry.EvaluateCondition(step.StopCondition, vars)
if err != nil {
log.Warn("Stop condition evaluation failed",
zap.String("step", step.Name),
zap.Error(err),
)
} else if shouldStop {
log.Debug("Agent stopped by stop_condition",
zap.String("step", step.Name),
zap.Int("iteration", state.iteration),
)
break
}
}
// Apply sliding window if configured (with optional compression — Group 4)
if step.Memory != nil && step.Memory.MaxMessages > 0 {
if step.Memory.SummarizeOnTruncate {
e.applyMessageWindowWithSummary(ctx, state, step.Memory.MaxMessages, llmConfig)
} else {
e.applyMessageWindow(state, step.Memory.MaxMessages)
}
}
}
// Record goal result (Group 3)
goalResult := map[string]interface{}{
"query": query,
"content": state.finalContent,
}
state.goalResults = append(state.goalResults, goalResult)
}
// If OutputSchema is set and we haven't gotten structured output yet,
// make a final structured output request (Group 6)
if outputSchema != nil {
structuredContent := e.requestStructuredOutput(ctx, state, outputSchema, llmConfig)
if structuredContent != "" {
state.finalContent = structuredContent
}
}
// Print final output (skip if streaming — tokens were already printed in real-time)
if !e.silent && !llmConfig.Stream && state.finalContent != "" {
printLLMOutput(state.finalContent)
}
// Persist conversation if configured
if step.Memory != nil && step.Memory.PersistPath != "" {
if err := e.persistConversation(state, step.Memory.PersistPath); err != nil {
log.Warn("Failed to persist agent conversation",
zap.String("path", step.Memory.PersistPath),
zap.Error(err),
)
}
}
// Set exports
historyJSON, err := json.Marshal(state.messages)
if err != nil {
log.Warn("Failed to marshal agent history", zap.Error(err))
historyJSON = []byte("[]")
}
toolResultsJSON, err := json.Marshal(state.toolResults)
if err != nil {
log.Warn("Failed to marshal agent tool results", zap.Error(err))
toolResultsJSON = []byte("[]")
}
result.Exports["agent_content"] = state.finalContent
result.Exports["agent_history"] = string(historyJSON)
// Cap iteration count: the for-loop post-increments past MaxIterations on natural exit
iterations := state.iteration
if iterations > step.MaxIterations {
iterations = step.MaxIterations
}
result.Exports["agent_iterations"] = iterations
result.Exports["agent_total_tokens"] = state.totalTokens
result.Exports["agent_prompt_tokens"] = state.promptTokens
result.Exports["agent_completion_tokens"] = state.completionTokens
result.Exports["agent_tool_results"] = string(toolResultsJSON)
// Planning stage export (Group 2)
if state.planContent != "" {
result.Exports["agent_plan"] = state.planContent
}
// Multi-goal results export (Group 3)
if len(state.goalResults) > 1 {
goalResultsJSON, err := json.Marshal(state.goalResults)
if err != nil {
log.Warn("Failed to marshal goal results", zap.Error(err))
goalResultsJSON = []byte("[]")
}
result.Exports["agent_goal_results"] = string(goalResultsJSON)
}
result.Output = state.finalContent
result.Status = core.StepStatusSuccess
result.EndTime = time.Now()
result.Duration = result.EndTime.Sub(result.StartTime)
return result, nil
}
// executePlanningStage runs the planning phase before the main agent loop (Group 2)
func (e *AgentExecutor) executePlanningStage(
ctx context.Context,
state *agentState,
step *core.Step,
llmConfig *MergedLLMConfig,
) (string, error) {
log := logger.Get()
log.Debug("Executing planning stage",
zap.String("step", step.Name),
)
// Build planning messages
var planMessages []ChatMessage
// System prompt if available
systemPrompt := step.SystemPrompt
if systemPrompt == "" && llmConfig.SystemPrompt != "" {
systemPrompt = llmConfig.SystemPrompt
}
if systemPrompt != "" {
planMessages = append(planMessages, ChatMessage{
Role: string(core.LLMRoleSystem),
Content: systemPrompt,
})
}
// Plan prompt as user message
planMessages = append(planMessages, ChatMessage{
Role: string(core.LLMRoleUser),
Content: step.PlanPrompt,
})
// Build planning request (no tools — just text generation)
planConfig := *llmConfig
if step.PlanMaxTokens != nil {
planConfig.MaxTokens = *step.PlanMaxTokens
}
planState := &agentState{messages: planMessages}
response, err := e.callLLM(ctx, planState, nil, &planConfig)
if err != nil {
return "", fmt.Errorf("planning request failed: %w", err)
}
if response == nil || len(response.Choices) == 0 {
return "", fmt.Errorf("empty response from planning request")
}
// Track tokens from planning
state.totalTokens += response.Usage.TotalTokens
state.promptTokens += response.Usage.PromptTokens
state.completionTokens += response.Usage.CompletionTokens
planContent := ""
if content, ok := response.Choices[0].Message.Content.(string); ok {
planContent = content
}
log.Debug("Planning stage complete",
zap.String("step", step.Name),
zap.Int("plan_length", len(planContent)),
)
return planContent, nil
}
// initMessages builds the initial conversation messages for a goal
func (e *AgentExecutor) initMessages(state *agentState, query string, step *core.Step, llmConfig *MergedLLMConfig, goalIdx int) {
// For the first goal, build from scratch or resume
if goalIdx == 0 {
// If we loaded resumed messages, just append the new query
if len(state.messages) > 0 {
state.messages = append(state.messages, ChatMessage{
Role: string(core.LLMRoleUser),
Content: query,
})
return
}
// Start fresh conversation
systemPrompt := step.SystemPrompt
if systemPrompt == "" && llmConfig.SystemPrompt != "" {
systemPrompt = llmConfig.SystemPrompt
}
if systemPrompt != "" {
state.messages = append(state.messages, ChatMessage{
Role: string(core.LLMRoleSystem),
Content: systemPrompt,
})
}
// Prepend plan if available (Group 2)
if state.planContent != "" {
state.messages = append(state.messages, ChatMessage{
Role: string(core.LLMRoleAssistant),
Content: "Here is my plan:\n\n" + state.planContent,
})
}
// User query
state.messages = append(state.messages, ChatMessage{
Role: string(core.LLMRoleUser),
Content: query,
})
return
}
// For subsequent goals in multi-goal mode, append new user message
state.messages = append(state.messages, ChatMessage{
Role: string(core.LLMRoleUser),
Content: query,
})
}
// callLLMWithFallback calls the LLM with optional per-agent model fallback (Group 5)
func (e *AgentExecutor) callLLMWithFallback(
ctx context.Context,
state *agentState,
tools []core.LLMTool,
llmConfig *MergedLLMConfig,
models []string,
responseFormat *core.LLMResponseFormat,
) (*ChatCompletionResponse, error) {
// If step specifies preferred models, try each in order
if len(models) > 0 {
var lastErr error
for _, model := range models {
modelConfig := *llmConfig
modelConfig.Model = model
if responseFormat != nil {
modelConfig.ResponseFormat = responseFormat
}
response, err := e.callLLM(ctx, state, tools, &modelConfig)
if err == nil && (response == nil || response.Error == nil) {
return response, nil
}
lastErr = err
logger.Get().Debug("Model fallback: trying next model",
zap.String("failed_model", model),
zap.Error(err),
)
}
// Fall through to default provider rotation
logger.Get().Warn("All specified models failed, falling back to default",
zap.Error(lastErr),
)
}
// Default path: use standard provider rotation
if responseFormat != nil {
configWithFormat := *llmConfig
configWithFormat.ResponseFormat = responseFormat
return e.callLLM(ctx, state, tools, &configWithFormat)
}
return e.callLLM(ctx, state, tools, llmConfig)
}
// callLLM sends the current conversation to the LLM and returns the response
func (e *AgentExecutor) callLLM(
ctx context.Context,
state *agentState,
tools []core.LLMTool,
llmConfig *MergedLLMConfig,
) (*ChatCompletionResponse, error) {
log := logger.Get()
request := &ChatCompletionRequest{
Model: llmConfig.Model,
Messages: state.messages,
MaxTokens: llmConfig.MaxTokens,
Temperature: llmConfig.Temperature,
TopP: llmConfig.TopP,
TopK: llmConfig.TopK,
Tools: tools,
Stream: llmConfig.Stream,
ResponseFormat: llmConfig.ResponseFormat,
}
// Provider rotation with retries
var response *ChatCompletionResponse
var lastErr error
maxRetries := llmConfig.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
providerCount := e.config.LLM.GetProviderCount()
if providerCount == 0 {
return nil, fmt.Errorf("no LLM providers configured")
}
totalAttempts := maxRetries * providerCount
for attempt := 0; attempt < totalAttempts; attempt++ {
provider := e.config.LLM.GetCurrentProvider()
if provider == nil {
lastErr = fmt.Errorf("no LLM providers available")
break
}
if llmConfig.Model == "" {
request.Model = provider.Model
}
log.Debug("Agent LLM request",
zap.String("provider", provider.Provider),
zap.String("model", request.Model),
zap.Int("attempt", attempt+1),
)
response, lastErr = e.sendChatRequest(ctx, provider, request, llmConfig)
if lastErr == nil && response.Error == nil {
break
}
if isProviderError(lastErr) || isRateLimitError(response) {
e.config.LLM.RotateProvider()
}
if attempt < totalAttempts-1 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt+1) * 500 * time.Millisecond):
}
}
}
if lastErr != nil {
return nil, lastErr
}
if response != nil && response.Error != nil {
return nil, fmt.Errorf("LLM API error: %s (%s)", response.Error.Message, response.Error.Type)
}
return response, nil
}
// sendChatRequest sends an HTTP request to the LLM provider (delegates to LLMExecutor's logic)
func (e *AgentExecutor) sendChatRequest(
ctx context.Context,
provider *config.LLMProvider,
request *ChatCompletionRequest,
llmConfig *MergedLLMConfig,
) (*ChatCompletionResponse, error) {
llmExec := &LLMExecutor{config: e.config}
return llmExec.sendChatRequest(ctx, provider, request, llmConfig)
}
// executeToolCalls executes tool calls from the LLM response, with tracing hooks (Group 7)
func (e *AgentExecutor) executeToolCalls(
ctx context.Context,
toolCalls []core.LLMToolCall,
state *agentState,
step *core.Step,
execCtx *core.ExecutionContext,
) ([]ChatMessage, error) {
log := logger.Get()
parallelToolCalls := true
if step.ParallelToolCalls != nil {
parallelToolCalls = *step.ParallelToolCalls
}
if parallelToolCalls && len(toolCalls) > 1 {
return e.executeToolCallsParallel(ctx, toolCalls, state, step, execCtx, log)
}
return e.executeToolCallsSequential(ctx, toolCalls, state, step, execCtx, log)
}
// executeToolCallsSequential executes tool calls one at a time
func (e *AgentExecutor) executeToolCallsSequential(
ctx context.Context,
toolCalls []core.LLMToolCall,
state *agentState,
step *core.Step,
execCtx *core.ExecutionContext,
log *zap.Logger,
) ([]ChatMessage, error) {
messages := make([]ChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Execute on_tool_start hook (Group 7)
e.executeToolHook(step.OnToolStart, tc.Function.Name, tc.Function.Arguments, "", 0, state.iteration, nil, execCtx)
startTime := time.Now()
result, err := e.executeSingleToolCall(ctx, tc, state, execCtx, log)
duration := time.Since(startTime)
if err != nil {
result = fmt.Sprintf("Error executing tool '%s': %s", tc.Function.Name, err.Error())
}
// Execute on_tool_end hook (Group 7)
e.executeToolHook(step.OnToolEnd, tc.Function.Name, tc.Function.Arguments, result, duration.Milliseconds(), state.iteration, err, execCtx)
log.Debug("Tool call completed",
zap.String("tool", tc.Function.Name),
zap.String("call_id", tc.ID),
zap.Duration("duration", duration),
)
state.toolResults = append(state.toolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"tool_name": tc.Function.Name,
"result": result,
})
messages = append(messages, ChatMessage{
Role: string(core.LLMRoleTool),
Content: result,
ToolCallID: tc.ID,
})
}
return messages, nil
}
// executeToolCallsParallel executes tool calls concurrently
func (e *AgentExecutor) executeToolCallsParallel(
ctx context.Context,
toolCalls []core.LLMToolCall,
state *agentState,
step *core.Step,
execCtx *core.ExecutionContext,
log *zap.Logger,
) ([]ChatMessage, error) {
type toolResult struct {
index int
message ChatMessage
}
results := make(chan toolResult, len(toolCalls))
var wg sync.WaitGroup
for i, tc := range toolCalls {
wg.Add(1)
go func(idx int, call core.LLMToolCall) {
defer wg.Done()
select {
case <-ctx.Done():
return
default:
}
// Execute on_tool_start hook (Group 7)
e.executeToolHook(step.OnToolStart, call.Function.Name, call.Function.Arguments, "", 0, state.iteration, nil, execCtx)
startTime := time.Now()
result, err := e.executeSingleToolCall(ctx, call, state, execCtx, log)
duration := time.Since(startTime)
if err != nil {
result = fmt.Sprintf("Error executing tool '%s': %s", call.Function.Name, err.Error())
}
// Execute on_tool_end hook (Group 7)
e.executeToolHook(step.OnToolEnd, call.Function.Name, call.Function.Arguments, result, duration.Milliseconds(), state.iteration, err, execCtx)
log.Debug("Tool call completed",
zap.String("tool", call.Function.Name),
zap.String("call_id", call.ID),
zap.Duration("duration", duration),
)
results <- toolResult{
index: idx,
message: ChatMessage{
Role: string(core.LLMRoleTool),
Content: result,
ToolCallID: call.ID,
},
}
}(i, tc)
}
wg.Wait()
close(results)
// Collect and order results, populate toolResults
ordered := make([]ChatMessage, len(toolCalls))
for r := range results {
ordered[r.index] = r.message
}
for i, msg := range ordered {
state.toolResults = append(state.toolResults, map[string]interface{}{
"tool_call_id": toolCalls[i].ID,
"tool_name": toolCalls[i].Function.Name,
"result": msg.Content,
})
}
return ordered, nil
}
// executeSingleToolCall executes a single tool call via the ToolExecutorRegistry
func (e *AgentExecutor) executeSingleToolCall(
ctx context.Context,
tc core.LLMToolCall,
state *agentState,
execCtx *core.ExecutionContext,
log *zap.Logger,
) (string, error) {
// Use ToolExecutorRegistry if available (Group 1)
if state.toolRegistry != nil {
return executeToolCallViaRegistry(ctx, tc, state.toolRegistry, execCtx, log)
}
// Fallback to legacy dispatch (should not happen in normal flow)
return e.executeSingleToolCallLegacy(tc, execCtx, log)
}
// executeSingleToolCallLegacy is the legacy dispatch path (kept for safety)
func (e *AgentExecutor) executeSingleToolCallLegacy(
tc core.LLMToolCall,
execCtx *core.ExecutionContext,
log *zap.Logger,
) (string, error) {
funcName := tc.Function.Name
argsJSON := tc.Function.Arguments
log.Debug("Executing tool call (legacy)",
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 e.executePresetTool(funcName, args, execCtx)
}
// executePresetTool runs a preset tool by building a function call expression
func (e *AgentExecutor) executePresetTool(funcName string, args map[string]interface{}, execCtx *core.ExecutionContext) (string, error) {
expr := buildPresetCallExpr(funcName, args)
vars := execCtx.GetVariables()
result, err := e.functionRegistry.Execute(expr, vars)
if err != nil {
return "", fmt.Errorf("preset tool '%s' failed: %w", funcName, err)
}
return formatToolResult(result), nil
}
// executeToolHook evaluates a JS hook expression with tool call context (Group 7)
func (e *AgentExecutor) executeToolHook(
hook string,
toolName string,
toolArgs string,
result string,
durationMs int64,
iteration int,
toolErr error,
execCtx *core.ExecutionContext,
) {
if hook == "" || e.functionRegistry == nil {
return
}
log := logger.Get()
vars := execCtx.GetVariables()
vars["tool_name"] = toolName
vars["tool_args"] = toolArgs
vars["result"] = result
vars["duration"] = durationMs
vars["iteration"] = iteration
if toolErr != nil {
vars["error"] = toolErr.Error()
} else {
vars["error"] = ""
}
if _, err := e.functionRegistry.Execute(hook, vars); err != nil {
log.Warn("Tool hook execution failed (non-blocking)",
zap.String("hook", hook),
zap.Error(err),
)
}
}
// requestStructuredOutput makes a final LLM call to enforce structured output (Group 6)
func (e *AgentExecutor) requestStructuredOutput(
ctx context.Context,
state *agentState,
schema *core.LLMResponseFormat,
llmConfig *MergedLLMConfig,
) string {
log := logger.Get()
// Only request if we have a schema and the last response might not be structured
if schema == nil {
return ""
}
// Check if current content is already valid JSON matching the schema
var check interface{}
if json.Unmarshal([]byte(state.finalContent), &check) == nil {
// Already valid JSON, likely structured
return ""
}
// Add instruction to produce structured output
state.messages = append(state.messages, ChatMessage{
Role: string(core.LLMRoleUser),
Content: "Please provide your final answer in the structured JSON format specified.",
})
configWithSchema := *llmConfig
configWithSchema.ResponseFormat = schema
response, err := e.callLLM(ctx, state, nil, &configWithSchema)
if err != nil {
log.Warn("Structured output request failed",
zap.Error(err),
)
return ""
}
if response == nil || len(response.Choices) == 0 {
return ""
}
// Track tokens
state.totalTokens += response.Usage.TotalTokens
state.promptTokens += response.Usage.PromptTokens
state.completionTokens += response.Usage.CompletionTokens
if content, ok := response.Choices[0].Message.Content.(string); ok {
return content
}
return ""
}
// buildPresetCallExpr builds a JS function call expression from a preset tool name and arguments
func buildPresetCallExpr(funcName string, args map[string]interface{}) string {
switch funcName {
case "bash":
return fmt.Sprintf("bash(%s)", jsQuote(getStringArg(args, "command")))
case "read_file":
return fmt.Sprintf("read_file(%s)", jsQuote(getStringArg(args, "path")))
case "read_lines":
return fmt.Sprintf("read_lines(%s)", jsQuote(getStringArg(args, "path")))
case "file_exists":
return fmt.Sprintf("file_exists(%s)", jsQuote(getStringArg(args, "path")))
case "file_length":
return fmt.Sprintf("file_length(%s)", jsQuote(getStringArg(args, "path")))
case "append_file":
return fmt.Sprintf("append_file(%s, %s)", jsQuote(getStringArg(args, "dest")), jsQuote(getStringArg(args, "content")))
case "save_content":
return fmt.Sprintf("save_content(%s, %s)", jsQuote(getStringArg(args, "content")), jsQuote(getStringArg(args, "path")))
case "glob":
return fmt.Sprintf("glob(%s)", jsQuote(getStringArg(args, "pattern")))
case "grep_string":
return fmt.Sprintf("grep_string(%s, %s)", jsQuote(getStringArg(args, "source")), jsQuote(getStringArg(args, "str")))
case "grep_regex":
return fmt.Sprintf("grep_regex(%s, %s)", jsQuote(getStringArg(args, "source")), jsQuote(getStringArg(args, "pattern")))
case "http_get":
return fmt.Sprintf("http_get(%s)", jsQuote(getStringArg(args, "url")))
case "http_request":
return fmt.Sprintf("http_request(%s, %s, %s, %s)",
jsQuote(getStringArg(args, "url")),
jsQuote(getStringArg(args, "method")),
jsQuote(getStringArg(args, "headers")),
jsQuote(getStringArg(args, "body")),
)
case "jq":
return fmt.Sprintf("jq(%s, %s)", jsQuote(getStringArg(args, "json_data")), jsQuote(getStringArg(args, "expression")))
case "exec_python":
return fmt.Sprintf("exec_python(%s)", jsQuote(getStringArg(args, "code")))
case "exec_python_file":
return fmt.Sprintf("exec_python_file(%s)", jsQuote(getStringArg(args, "path")))
case "exec_ts":
return fmt.Sprintf("exec_ts(%s)", jsQuote(getStringArg(args, "code")))
case "exec_ts_file":
return fmt.Sprintf("exec_ts_file(%s)", jsQuote(getStringArg(args, "path")))
case "run_module":
return fmt.Sprintf("run_module(%s, %s, %s)", jsQuote(getStringArg(args, "module")), jsQuote(getStringArg(args, "target")), jsQuote(getStringArg(args, "params")))
case "run_flow":
return fmt.Sprintf("run_flow(%s, %s, %s)", jsQuote(getStringArg(args, "flow")), jsQuote(getStringArg(args, "target")), jsQuote(getStringArg(args, "params")))
default:
var argStrs []string
for _, v := range args {
argStrs = append(argStrs, jsQuote(fmt.Sprintf("%v", v)))
}
return fmt.Sprintf("%s(%s)", funcName, strings.Join(argStrs, ", "))
}
}
// getStringArg safely extracts a string argument from the args map
func getStringArg(args map[string]interface{}, key string) string {
if v, ok := args[key]; ok {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
return ""
}
// jsQuote returns a JavaScript string literal, escaping special characters
func jsQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
s = strings.ReplaceAll(s, "\t", `\t`)
return `"` + s + `"`
}
// formatToolResult converts a tool execution result to a string for the LLM
func formatToolResult(result interface{}) string {
if result == nil {
return ""
}
switch v := result.(type) {
case string:
return v
case bool:
if v {
return "true"
}
return "false"
case int, int64, float64:
return fmt.Sprintf("%v", v)
default:
jsonBytes, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(jsonBytes)
}
}
// applyMessageWindow trims the conversation to max_messages, keeping the system message
func (e *AgentExecutor) applyMessageWindow(state *agentState, maxMessages int) {
if maxMessages <= 0 || len(state.messages) <= maxMessages {
return
}
var systemMsg *ChatMessage
startIdx := 0
if len(state.messages) > 0 && state.messages[0].Role == string(core.LLMRoleSystem) {
systemMsg = &state.messages[0]
startIdx = 1
}
nonSystemMsgs := state.messages[startIdx:]
keepCount := maxMessages
if systemMsg != nil {
keepCount--
}
if len(nonSystemMsgs) > keepCount {
nonSystemMsgs = nonSystemMsgs[len(nonSystemMsgs)-keepCount:]
}
if systemMsg != nil {
state.messages = make([]ChatMessage, 0, keepCount+1)
state.messages = append(state.messages, *systemMsg)
state.messages = append(state.messages, nonSystemMsgs...)
} else {
state.messages = nonSystemMsgs
}
}
// applyMessageWindowWithSummary trims with LLM-based summarization of dropped messages (Group 4)
func (e *AgentExecutor) applyMessageWindowWithSummary(
ctx context.Context,
state *agentState,
maxMessages int,
llmConfig *MergedLLMConfig,
) {
if maxMessages <= 0 || len(state.messages) <= maxMessages {
return
}
log := logger.Get()
var systemMsg *ChatMessage
startIdx := 0
if len(state.messages) > 0 && state.messages[0].Role == string(core.LLMRoleSystem) {
systemMsg = &state.messages[0]
startIdx = 1
}
nonSystemMsgs := state.messages[startIdx:]
keepCount := maxMessages
if systemMsg != nil {
keepCount-- // Account for system message
keepCount-- // Account for summary message we'll insert
}
if keepCount < 1 {
keepCount = 1
}
if len(nonSystemMsgs) <= keepCount {
return
}
// Messages to be dropped
dropCount := len(nonSystemMsgs) - keepCount
droppedMsgs := nonSystemMsgs[:dropCount]
keptMsgs := nonSystemMsgs[dropCount:]
// Build summary of dropped messages
var summaryParts []string
for _, msg := range droppedMsgs {
if content, ok := msg.Content.(string); ok && content != "" {
role := msg.Role
// Truncate long messages
if len(content) > 200 {
content = content[:200] + "..."
}
summaryParts = append(summaryParts, fmt.Sprintf("[%s]: %s", role, content))
}
}
if len(summaryParts) == 0 {
// No meaningful content to summarize, just truncate
e.applyMessageWindow(state, maxMessages)
return
}
// Ask LLM to summarize the dropped context
summaryPrompt := "Summarize the following conversation context concisely, preserving key information:\n\n" + strings.Join(summaryParts, "\n")
summaryMessages := []ChatMessage{
{Role: string(core.LLMRoleUser), Content: summaryPrompt},
}
summaryState := &agentState{messages: summaryMessages}
summaryConfig := *llmConfig
summaryConfig.MaxTokens = 300
response, err := e.callLLM(ctx, summaryState, nil, &summaryConfig)
if err != nil {
log.Warn("Conversation summarization failed, falling back to simple truncation",
zap.Error(err),
)
e.applyMessageWindow(state, maxMessages)
return
}
summaryContent := ""
if response != nil && len(response.Choices) > 0 {
if content, ok := response.Choices[0].Message.Content.(string); ok {
summaryContent = content
}
// Track summarization tokens
state.totalTokens += response.Usage.TotalTokens
state.promptTokens += response.Usage.PromptTokens
state.completionTokens += response.Usage.CompletionTokens
}
if summaryContent == "" {
e.applyMessageWindow(state, maxMessages)
return
}
// Rebuild messages with summary
state.messages = make([]ChatMessage, 0, keepCount+2)
if systemMsg != nil {
state.messages = append(state.messages, *systemMsg)
}
state.messages = append(state.messages, ChatMessage{
Role: string(core.LLMRoleSystem),
Content: "[Summary of earlier conversation]\n" + summaryContent,
})
state.messages = append(state.messages, keptMsgs...)
}
// loadConversation loads a prior conversation from a JSON file
func (e *AgentExecutor) loadConversation(state *agentState, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read conversation file: %w", err)
}
var messages []ChatMessage
if err := json.Unmarshal(data, &messages); err != nil {
return fmt.Errorf("failed to parse conversation file: %w", err)
}
state.messages = messages
return nil
}
// persistConversation saves the conversation to a JSON file
func (e *AgentExecutor) persistConversation(state *agentState, path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
data, err := json.MarshalIndent(state.messages, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal conversation: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("failed to write conversation file: %w", err)
}
return nil
}
// getMergedConfig merges global and step-level LLM configuration
func (e *AgentExecutor) getMergedConfig(step *core.Step) *MergedLLMConfig {
llmExec := &LLMExecutor{config: e.config}
return llmExec.getMergedConfig(step)
}
// fail is a helper that sets the result to failed state
func (e *AgentExecutor) fail(result *core.StepResult, err error) (*core.StepResult, error) {
result.Status = core.StepStatusFailed
result.Error = err
result.EndTime = time.Now()
result.Duration = result.EndTime.Sub(result.StartTime)
return result, err
}