Files
portainer-core/IMPLEMENTATION_PLAN_TOOL_SELECTION.md
T
jpmschweitzerandClaude 78c0fdf6ec fix(core-ai): fix steward agent result access and increase timeout
- Fixed: Change `result.data` to `result.output` (correct PydanticAI API)
- Increased analysis_timeout from 3s to 10s (mistral-nemo needs more time)

**Status:** Steward now initializes correctly but there's a remaining issue
with the async generator merging logic in two_stage_agent.py causing
requests to hang. This needs further investigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:14:20 +01:00

24 KiB

Implementation Plan: Two-Stage Tool Selection with Steward Analysis

Executive Summary

Implemented a two-stage LLM process where:

  1. Stage 1 (Steward Analysis): Tatlock's steward analyzes queries and recommends 0-5 optimal tools
  2. Stage 2 (Tatlock Execution): Main Tatlock agent uses recommendations as guidance

Status messages like "🤵 Consulting the steward..." and "✓ Steward consultation complete" stream separately from answer content. Tool execution (specifically web_search) shows real-time status with arguments.

Status: IMPLEMENTED - See files created below


Architecture Overview

User Query
    ↓
┌─────────────────────────────────────────────┐
│  Stage 1: Steward Analysis Agent            │
│  - Uses same model as Tatlock (mistral-nemo)│
│  - Reviews all available tools               │
│  - Analyzes query intent                     │
│  - Recommends 0-5 optimal tools              │
│  - Status: "🤵 Consulting the steward..."   │
└────────────────┬────────────────────────────┘
                 │ Tool recommendations
                 ▼
┌─────────────────────────────────────────────┐
│  Enrichment Layer                            │
│  - Silent time/date injection (if needed)    │
│  - Inject tool recommendations into context  │
│  - Create enhanced user message              │
│  Status: "✓ Steward consultation complete"  │
│  (Or "✓ No further assistance required")    │
└────────────────┬────────────────────────────┘
                 │ Enhanced query
                 ▼
┌─────────────────────────────────────────────┐
│  Stage 2: Tatlock Answer Agent               │
│  - Full PydanticAI agent with all tools      │
│  - Sees tool recommendations in context      │
│  - More likely to use recommended tools      │
│  - Streams answer to user                    │
│  - Tool events tracked for web_search        │
│  Status: "🔍 Searching the web: 'query'"   │
└─────────────────────────────────────────────┘

Key User Refinements from Original Plan

Changes from Initial Design:

  1. Terminology: "Steward" instead of "household" (more precise)
  2. Tool Range: 0-5 tools (not 2-3) - allows "no tools needed" responses
  3. Model Selection: Use mistral-nemo for both steward and Tatlock (avoids cold starts)
  4. Time/Date Handling: Silent injection when recommended (no visible status)
  5. Tool Status: Only show status for web_search tool (most interesting to users)
  6. Math Handling: ALWAYS recommend calculator for ANY mathematical operation
  7. Status Permanence: Status messages are permanent (not ephemeral) for transparency
  8. Zero-Tool Case: Special message when no tools needed: "✓ No further assistance required"

Implementation Files Created

1. Tool Event Emitter

File: /services/core-ai/src/agents/tool_events.py Created

Lightweight event system for tools to signal when they're called:

@dataclass
class ToolCallEvent:
    """Event emitted when a tool is called."""
    tool_name: str
    arguments: dict
    timestamp: float

class ToolEventEmitter:
    """Singleton event emitter for tool calls."""

    def emit(self, tool_name: str, arguments: dict):
        """Emit a tool call event (non-blocking)."""
        # Emits to asyncio.Queue for streaming consumers

    async def get_event(self, timeout: float = 0.01) -> Optional[ToolCallEvent]:
        """Get next event with timeout."""
        # Used by streaming to detect tool calls in real-time

def get_tool_emitter() -> ToolEventEmitter:
    """Get global singleton emitter."""

Purpose: Allows tools to emit events during execution without blocking


2. Steward Analysis Agent

File: /services/core-ai/src/agents/steward_agent.py Created

Analysis agent that recommends optimal tools for queries:

class ToolRecommendation(BaseModel):
    """Structured tool recommendations from steward analysis."""
    intent: str = Field(description="Brief description of what user wants")
    recommended_tools: list[str] = Field(
        default_factory=list,
        description="List of 0-5 recommended tool names"
    )
    reasoning: str = Field(description="Why these tools are optimal")
    requires_assistance: bool = Field(
        default=False,
        description="False if 0 tools recommended"
    )

class StewardAgent:
    """The Steward - Tatlock's analytical assistant for tool selection."""

    def __init__(self, model_name: Optional[str] = None):
        # Uses same model as Tatlock (mistral-nemo) for performance
        if model_name is None:
            model_name = self.settings.agent_model  # mistral-nemo:latest

        self.model = OpenAIModel(
            model_name=model_name,
            provider=OllamaProvider(base_url=ollama_base_url_v1)
        )

        # Generate system prompt with tool catalog
        self.system_prompt = self._generate_steward_prompt()

        # Create PydanticAI agent with structured output
        self.agent = Agent(
            model=self.model,
            system_prompt=self.system_prompt,
            result_type=ToolRecommendation,  # Structured JSON output
        )

    async def analyze(self, query: str, timeout: Optional[float] = None):
        """Analyze query and recommend tools with timeout."""
        result = await asyncio.wait_for(
            self.agent.run(f"User query: {query}"),
            timeout=timeout or 3  # Default 3 seconds
        )
        return result.data  # Returns ToolRecommendation

Critical Rules in System Prompt:

CRITICAL RULES:
1. ANY mathematical calculation → MUST recommend 'calculate' tool
   - LLMs are unreliable with math - ALWAYS use calculator
   - Examples: "15 + 27", "50% of 200", "sqrt(144)" → ALL require 'calculate'

2. Time/date queries → recommend 'get_current_time' or 'get_current_date'
   - These will be injected silently into context
   - Examples: "what time is it", "today's date", "current time in Paris"

3. Real-time information → recommend 'web_search'
   - News, weather, current events, latest information
   - Any query requiring up-to-date data from the internet

4. Infrastructure operations → recommend appropriate tools
   - Container management, service control, DNS lookups, etc.

5. General knowledge → NO TOOLS NEEDED (requires_assistance: false)
   - Historical facts, definitions, explanations
   - But NEVER for math - always use calculator

Performance: Uses mistral-nemo (already loaded in VRAM) so no cold start penalty


3. Two-Stage Orchestration Wrapper

File: /services/core-ai/src/agents/two_stage_agent.py Created

Coordinates steward analysis with Tatlock execution:

class TwoStageAgent:
    """Two-stage agent orchestrator."""

    def __init__(self, tatlock_agent: Agent, enable_two_stage: bool = True):
        self.tatlock = tatlock_agent
        self.enable_two_stage = enable_two_stage

        if self.enable_two_stage:
            self.steward = get_steward_agent()

    async def chat_with_analysis(
        self,
        messages: list[dict],
        conversation_id: Optional[str] = None,
        stream: bool = True
    ) -> AsyncGenerator[Dict[str, Any], None]:
        """
        Execute two-stage chat with steward analysis and streaming.

        Yields:
            Dict with 'type':
            - type='status': Status update (message, phase, tool_name, arguments)
            - type='content': Response content chunk
            - type='done': Completion marker
        """

        # Stage 1: Steward Analysis
        yield {
            "type": "status",
            "message": "🤵 Consulting the steward...",
            "phase": "analysis"
        }

        recommendation = await self._perform_steward_analysis(user_query)

        if recommendation.recommended_tools:
            yield {
                "type": "status",
                "message": "✓ Steward consultation complete",
                "phase": "analysis_complete",
                "recommended_tools": recommendation.recommended_tools
            }
        else:
            yield {
                "type": "status",
                "message": "✓ No further assistance required - answering from general knowledge",
                "phase": "analysis_complete"
            }

        # Silent time/date injection if needed
        needs_datetime = any(
            tool in recommendation.recommended_tools
            for tool in ('get_current_time', 'get_current_date')
        )

        datetime_info = None
        if needs_datetime:
            datetime_info = await self._get_current_datetime()

        # Enrich user message with recommendations
        enriched_messages = self._enrich_user_message(
            user_query,
            recommendation,
            datetime_info
        )

        # Stage 2: Tatlock Execution with Tool Event Monitoring
        # Monitor tool events for web_search
        emitter = get_tool_emitter()
        emitter.clear()

        # Stream both tool events and Tatlock response
        async for event in emitter.get_event():
            if event.tool_name == "web_search":
                query = event.arguments.get("query", "")
                yield {
                    "type": "status",
                    "message": f"🔍 Searching the web: \"{query}\"",
                    "phase": "tool_execution",
                    "tool_name": event.tool_name,
                    "arguments": event.arguments
                }

        async for chunk in self.tatlock.chat(...):
            yield chunk

def create_two_stage_agent(tatlock_agent: Agent, enable_two_stage: bool = True):
    """Create two-stage agent orchestrator."""
    return TwoStageAgent(tatlock_agent, enable_two_stage=enable_two_stage)

Key Features:

  • Coordinates both stages
  • Handles 0-tool case gracefully
  • Silent time/date injection
  • Shows visible tool recommendations
  • Monitors web_search execution
  • Graceful fallback if steward fails

4. Web Search Tool Update

File: /services/core-ai/src/tools/local.py Updated

Added event emission to web_search tool:

@register_tool
async def web_search(query: str, category: str = "general", max_results: int = 5):
    """Search the web using SearXNG metasearch engine."""

    logger.info(f"Web search: query='{query}', category='{category}'")

    # Emit tool call event for status tracking
    try:
        from src.agents.tool_events import get_tool_emitter
        emitter = get_tool_emitter()
        emitter.emit("web_search", {"query": query, "category": category})
    except Exception as e:
        logger.warning(f"Failed to emit tool event: {e}")

    # ... actual search implementation ...

Result: Web searches now emit events that trigger status messages showing the search query


5. Main API Endpoint Updates

File: /services/core-ai/main.py Updated

Modified chat endpoint to support two-stage processing:

from src.agents.two_stage_agent import create_two_stage_agent

async def chat_completions(request):
    """Enhanced with two-stage tool selection."""

    # Extract parameters
    two_stage_analysis = data.get("two_stage_analysis", True)  # Enabled by default

    # Get base agent
    base_agent = get_pydantic_agent(discover_tools=enable_tools, user_id=user_id)

    # Wrap with two-stage orchestration
    agent = create_two_stage_agent(base_agent, enable_two_stage=two_stage_analysis)

    # For streaming
    if stream:
        async for chunk in agent.chat_with_analysis(messages, conversation_id, stream=True):
            chunk_type = chunk.get("type")

            if chunk_type == "status":
                # Send status as separate SSE event
                status_data = {
                    "type": "status",
                    "message": chunk.get("message"),
                    "phase": chunk.get("phase"),
                    "tool_name": chunk.get("tool_name"),
                    "arguments": chunk.get("arguments")
                }
                await response.write(f"data: {json.dumps(status_data)}\\n\\n".encode('utf-8'))

            elif chunk_type == "content":
                # Send content as standard OpenAI format
                chunk_data = {
                    "type": "content",
                    "choices": [{
                        "index": 0,
                        "delta": {"content": chunk.get("content", "")},
                        "finish_reason": chunk.get("finish_reason")
                    }],
                    "model": model
                }
                await response.write(f"data: {json.dumps(chunk_data)}\\n\\n".encode('utf-8'))

Changes:

  • Added two_stage_analysis request parameter (default: true)
  • Wraps base agent with TwoStageAgent
  • Handles status and content events separately in SSE stream
  • Backwards compatible (can disable with two_stage_analysis: false)

6. Configuration Options

File: /services/core-ai/src/config.py Updated

Added two-stage configuration settings:

class Settings(BaseSettings):
    # ... existing settings ...

    # Two-Stage Tool Selection Configuration
    two_stage_enabled: bool = True  # Enable two-stage steward analysis by default
    analysis_timeout: int = 3  # Steward analysis timeout in seconds
    max_recommended_tools: int = 5  # Maximum tools steward can recommend
    min_recommended_tools: int = 0  # Minimum tools (0 = can recommend no tools)

    # Status Message Configuration
    enable_status_messages: bool = True  # Show status messages during streaming
    show_web_search_status: bool = True  # Show status when web_search tool is called
    status_consulting: str = "🤵 Consulting the steward..."
    status_complete: str = "✓ Steward consultation complete"
    status_no_assistance: str = "✓ No further assistance required - answering from general knowledge"

Customization: All status messages and behavior can be configured via environment variables


Event Flow Example

User Query: "What's the weather in Paris right now?"

SSE Stream Output:

data: {"type":"status","message":"🤵 Consulting the steward...","phase":"analysis"}

data: {"type":"status","message":"✓ Steward consultation complete","phase":"analysis_complete","recommended_tools":["web_search"]}

data: {"type":"status","message":"🔍 Searching the web: \"Paris weather current\"","phase":"tool_execution","tool_name":"web_search"}

data: {"type":"content","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" weather"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" in"},"finish_reason":null}]}

... (more content chunks)

data: [DONE]

User Query: "What is 2+2?"

SSE Stream Output:

data: {"type":"status","message":"🤵 Consulting the steward...","phase":"analysis"}

data: {"type":"status","message":"✓ Steward consultation complete","phase":"analysis_complete","recommended_tools":["calculate"]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":"Let"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" me"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" calculate"},"finish_reason":null}]}

... (Tatlock calls calculate tool)

data: {"type":"content","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" answer"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" is"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" 4"},"finish_reason":null}]}

data: [DONE]

User Query: "Who was the first president of the United States?"

SSE Stream Output:

data: {"type":"status","message":"🤵 Consulting the steward...","phase":"analysis"}

data: {"type":"status","message":"✓ No further assistance required - answering from general knowledge","phase":"analysis_complete"}

data: {"type":"content","choices":[{"index":0,"delta":{"content":"George"},"finish_reason":null}]}

data: {"type":"content","choices":[{"index":0,"delta":{"content":" Washington"},"finish_reason":null}]}

... (more content)

data: [DONE]

Performance Considerations

Model Selection Rationale

Using mistral-nemo for both steward and Tatlock:

  • No cold start penalty (already loaded in VRAM)
  • Consistent quality between analysis and execution
  • Simplified deployment (one model to manage)
  • Slightly slower analysis than gemma2:2b (~200-500ms vs ~100-300ms)
  • Verdict: Trade-off worth it - cold start would cost 5-10+ seconds

Expected Latencies

Stage Duration Model Notes
Analysis 200-500ms mistral-nemo Same model as Tatlock
Enrichment <10ms Python Text manipulation
Answer 2-10s mistral-nemo Full inference with tools
Total Overhead ~210-510ms Minimal impact

Zero-Tool Optimization

When steward recommends 0 tools:

  • No context enrichment needed
  • Original query passed directly to Tatlock
  • Only overhead is analysis time (~200-500ms)
  • Still beneficial: Confirms no tools needed (transparent reasoning)

Testing Strategy

Manual Testing Commands

# Test with two-stage enabled (default)
curl -X POST http://localhost:8086/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role":"user","content":"What is the weather in Paris?"}],
    "model": "Tatlock",
    "stream": true
  }'

# Test with two-stage disabled
curl -X POST http://localhost:8086/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role":"user","content":"What is 2+2?"}],
    "model": "Tatlock",
    "stream": true,
    "two_stage_analysis": false
  }'

# Test zero-tool case
curl -X POST http://localhost:8086/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role":"user","content":"Who invented the telephone?"}],
    "model": "Tatlock",
    "stream": true
  }'

# Test time/date injection
curl -X POST http://localhost:8086/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role":"user","content":"What time is it?"}],
    "model": "Tatlock",
    "stream": true
  }'

Test Cases

Query Expected Steward Recommendation Expected Status
"What is 15 + 27?" ["calculate"] Shows calculator recommendation
"What's the weather in Tokyo?" ["web_search"] Shows search status with query
"What time is it in Paris?" ["get_current_time"] Silent injection, no visible status
"Who won the 2024 Olympics?" ["web_search"] Shows search status
"What is the capital of France?" [] (no tools) "No further assistance required"
"Calculate 50% of 200" ["calculate"] Shows calculator recommendation

Fallback & Error Handling

Graceful Degradation

try:
    # Attempt steward analysis
    recommendation = await self.steward.analyze(user_query, timeout=3)

except asyncio.TimeoutError:
    # Analysis took too long, fall back to single-stage
    yield {
        "type": "status",
        "message": "⚠️ Steward unavailable, proceeding without analysis",
        "phase": "fallback"
    }
    # Continue with original messages

except Exception as e:
    # Analysis failed, fall back to single-stage
    logger.error(f"Steward analysis failed: {e}")
    yield {"type": "status", "message": "⚠️ Steward unavailable..."}
    # Continue with original messages

Result: User always gets an answer, even if steward analysis fails


Success Metrics

Performance Targets

  • Analysis latency: < 500ms (p95) - using mistral-nemo
  • Total overhead: < 1s (p95) - minimal impact
  • Success rate: > 95% - analysis completes without error

Quality Metrics

  • Tool recommendation accuracy: Track recommended vs. actually used
  • User satisfaction: Transparent reasoning via status messages
  • Error rate: < 5% - graceful fallback on failures

Monitoring

Track in AI metrics dashboard:

  • Two-stage requests vs. single-stage
  • Steward analysis performance
  • Tool recommendation patterns
  • Fallback rate

Key Architectural Decisions

1. Context Enrichment vs. System Prompt Injection

Decision: Context enrichment (inject into user message) Reason: PydanticAI system prompts are immutable Trade-off: Less clean, but only practical workaround

2. Same Model vs. Lightweight Model

Decision: Use mistral-nemo for both steward and Tatlock Reason: Avoid cold start penalty (5-10+ seconds) Trade-off: Slightly slower analysis, but much better overall latency

3. Tool Event System vs. Direct Monitoring

Decision: Separate event emitter system Reason: Decouples tool execution from streaming logic Benefit: Other tools can easily emit events in future

4. Permanent vs. Ephemeral Status Messages

Decision: Permanent status messages Reason: Transparency - users can see reasoning trail Benefit: Educational, builds trust in AI decisions

5. 0-5 Tools vs. 2-3 Tools

Decision: Allow 0-5 tool recommendations Reason: More flexible, handles simple queries better Benefit: "No tools needed" case explicitly handled


Future Enhancements

Phase 1 (Immediate)

  • Core two-stage implementation
  • Steward analysis agent
  • Web search status tracking
  • Silent time/date injection
  • Frontend integration testing

Phase 2 (Near-term)

  • Extend status tracking to other interesting tools (dns_lookup, etc.)
  • Track tool recommendation accuracy metrics
  • A/B test two-stage vs. single-stage performance
  • Tune steward prompts based on usage patterns

Phase 3 (Long-term)

  • Parallel analysis + Tatlock initialization (shave 200-500ms)
  • Cache tool catalog (regenerate only on tool changes)
  • Smart timeout adjustment based on query complexity
  • User preference: verbose vs. quiet status mode

Conclusion

The two-stage steward system has been fully implemented with the following benefits:

  1. Improved Tool Selection: Steward recommends 0-5 optimal tools
  2. Better UX: Permanent status messages show reasoning
  3. Minimal Overhead: ~200-500ms using same model (no cold starts)
  4. Graceful Fallback: Works even if steward fails
  5. Full Observability: Status events for web_search execution
  6. Math Reliability: Always recommends calculator for arithmetic
  7. Silent Data Injection: Time/date added without visible status
  8. Flexible Tool Range: Handles 0-5 tools, including "no tools needed"

Files Modified:

  • src/agents/tool_events.py (new)
  • src/agents/steward_agent.py (new)
  • src/agents/two_stage_agent.py (new)
  • src/tools/local.py (updated web_search)
  • main.py (updated chat endpoint)
  • src/config.py (added settings)

Next Steps: Test the implementation end-to-end with real queries and verify status messages appear correctly in the UI.