From 98c1822e7fade59223074788c92d96c941d341f6 Mon Sep 17 00:00:00 2001 From: memgrafter Date: Sat, 31 Jan 2026 14:02:08 -0800 Subject: [PATCH 1/8] Anything agent prototype by opus in pi. --- sdk/examples/anything_agent/GUIDANCE.md | 39 + sdk/examples/anything_agent/PLAN.md | 1536 +++++++++++++++++ sdk/examples/anything_agent/SUMMARY.md | 52 + sdk/examples/anything_agent/anything_agent.db | Bin 0 -> 81920 bytes .../anything_agent/config/profiles.yml | 13 + sdk/examples/anything_agent/pyproject.toml | 17 + sdk/examples/anything_agent/run.sh | 47 + .../src/anything_agent/__init__.py | 1 + .../src/anything_agent/context.py | 67 + .../src/anything_agent/hooks.py | 141 ++ .../machines/agents/reflector.yml | 55 + .../machines/agents/thinker.yml | 41 + .../anything_agent/machines/agents/worker.yml | 28 + .../src/anything_agent/machines/core.yml | 103 ++ .../anything_agent/src/anything_agent/main.py | 131 ++ .../src/anything_agent/observer.py | 153 ++ 16 files changed, 2424 insertions(+) create mode 100644 sdk/examples/anything_agent/GUIDANCE.md create mode 100644 sdk/examples/anything_agent/PLAN.md create mode 100644 sdk/examples/anything_agent/SUMMARY.md create mode 100644 sdk/examples/anything_agent/anything_agent.db create mode 100644 sdk/examples/anything_agent/config/profiles.yml create mode 100644 sdk/examples/anything_agent/pyproject.toml create mode 100755 sdk/examples/anything_agent/run.sh create mode 100644 sdk/examples/anything_agent/src/anything_agent/__init__.py create mode 100644 sdk/examples/anything_agent/src/anything_agent/context.py create mode 100644 sdk/examples/anything_agent/src/anything_agent/hooks.py create mode 100644 sdk/examples/anything_agent/src/anything_agent/machines/agents/reflector.yml create mode 100644 sdk/examples/anything_agent/src/anything_agent/machines/agents/thinker.yml create mode 100644 sdk/examples/anything_agent/src/anything_agent/machines/agents/worker.yml create mode 100644 sdk/examples/anything_agent/src/anything_agent/machines/core.yml create mode 100644 sdk/examples/anything_agent/src/anything_agent/main.py create mode 100644 sdk/examples/anything_agent/src/anything_agent/observer.py diff --git a/sdk/examples/anything_agent/GUIDANCE.md b/sdk/examples/anything_agent/GUIDANCE.md new file mode 100644 index 00000000..d2649b7c --- /dev/null +++ b/sdk/examples/anything_agent/GUIDANCE.md @@ -0,0 +1,39 @@ +# Anything Agent Guidance + +You are a core machine in an autonomous agent system. Human oversight approves every transition. + +## Goal +Work toward `ledger.goal`. Each step should make measurable progress. + +## Context Rules +- Stay under 40K tokens (20% of 200K). You can increase if you explain why. +- Minimum 12K (you need 8-11K for thinking). +- Prune aggressively. Log failures as one line, then clear. +- History compresses: recent = detailed, old = summarized. + +## Decision Loop +1. **Think**: What's needed next? +2. **Work**: If you can do it, do it. Update progress. +3. **Delegate**: If you need external capability (web, code, files), launch a leaf machine. +4. **Done**: When goal is complete, terminate. + +## Delegation +- Generate leaf spec with task +- Store yourself, launch leaf +- Leaf returns result, you resume with it + +## Self-Improvement +- Record techniques that work (name + description) +- Record failures to avoid (approach + reason) +- Human notes are guidance—follow them + +## Constraints +- Never fabricate results +- Never skip human approval +- If stuck, say so—human can help +- Keep responses focused and concise + +## Output +- `action`: work | delegate | done +- `detail`: what to do or why done +- If technique discovered: `new_technique: {name, description}` diff --git a/sdk/examples/anything_agent/PLAN.md b/sdk/examples/anything_agent/PLAN.md new file mode 100644 index 00000000..7d126dc4 --- /dev/null +++ b/sdk/examples/anything_agent/PLAN.md @@ -0,0 +1,1536 @@ +# Anything Agent: Inverted Control Architecture + +## Overview + +An autonomous agent system where machines dynamically launch successor machines with specific capabilities for the goal and tasks. Human oversight via SQLite-based approval with snapshot/restore—no long-running processes. + +**Core Principle**: Inversion of control. Machine decides what to do next—continue working, launch a successor with different capabilities, or terminate. No automatic cycling; each machine is self-contained. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CLI (observer.py) │ +│ Polls pending_approvals, displays context, writes decisions │ +│ On approve: restores machine from snapshot, continues execution │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ SQLite Database │ +│ Tables: │ +│ - lineage (execution_id, machine_yaml, agent_yamls, snapshot) │ +│ - pending_approvals (execution_id, state, context, transition_to) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Key insight**: No long-running machine process. Every human decision point: +1. Snapshot machine state to `lineage` +2. Insert `pending_approvals` row +3. Exit process +4. CLI polls, human approves → CLI restores from snapshot, continues + +--- + +## Data Model + +```sql +-- Sessions: group of machines working toward one goal +CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + goal TEXT NOT NULL, + status TEXT NOT NULL, -- active | paused | completed | failed + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Ledger: structured context that persists across machines in a session +CREATE TABLE ledger ( + session_id TEXT PRIMARY KEY, + goal TEXT NOT NULL, + progress TEXT NOT NULL DEFAULT '', -- Structured milestones, not raw history + techniques TEXT NOT NULL DEFAULT '', + failed_approaches TEXT NOT NULL DEFAULT '', + human_notes TEXT NOT NULL DEFAULT '', -- Timestamped human observer notes + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) +); + +-- Machine executions: full audit trail, queryable by future machines +CREATE TABLE executions ( + execution_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + parent_id TEXT, -- Parent execution (for delegation chains) + machine_type TEXT NOT NULL, -- core | leaf | planner | task | custom + tags TEXT NOT NULL DEFAULT '[]', -- JSON array of tags for filtering + machine_yaml TEXT NOT NULL, + agent_yamls TEXT NOT NULL, -- JSON: {name: yaml} + snapshot TEXT, -- JSON: MachineSnapshot for resume + status TEXT NOT NULL, -- pending | running | suspended | terminated + input_tokens INTEGER, -- Total input tokens used + output_tokens INTEGER, -- Total output tokens used + created_at TEXT NOT NULL, + terminated_at TEXT, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) +); + +-- Leaf results: stored separately, queryable, can be pruned +CREATE TABLE leaf_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + session_id TEXT NOT NULL, + result_type TEXT NOT NULL, -- web_search | code_exec | file_read | etc. + result_json TEXT NOT NULL, + token_count INTEGER, -- Estimated tokens if stringified + created_at TEXT NOT NULL, + pruned INTEGER DEFAULT 0, -- 1 if pruned from active context + FOREIGN KEY (execution_id) REFERENCES executions(execution_id) +); + +-- Pending approvals with human input field +CREATE TABLE pending_approvals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + session_id TEXT NOT NULL, + state_name TEXT NOT NULL, + context_json TEXT NOT NULL, + proposed_transition TEXT NOT NULL, + status TEXT NOT NULL, -- pending | approved | stopped + human_note TEXT, -- Optional note from human on approval + created_at TEXT NOT NULL, + responded_at TEXT, + FOREIGN KEY (execution_id) REFERENCES executions(execution_id) +); + +CREATE TABLE validation_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + errors_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- Indexes for common queries +CREATE INDEX idx_executions_session ON executions(session_id); +CREATE INDEX idx_executions_type ON executions(machine_type); +CREATE INDEX idx_leaf_results_session ON leaf_results(session_id); +CREATE INDEX idx_pending_session ON pending_approvals(session_id, status); +``` + +### Ledger Schema + +The ledger is the structured context that flows between machines. Clear fields, not freeform: + +```python +@dataclass +class Ledger: + goal: str # Original objective + progress: list[Milestone] # Structured milestones + techniques: list[Technique] # What works + failed_approaches: list[Failure] # What to avoid + human_notes: list[HumanNote] # Timestamped observer notes + +@dataclass +class Milestone: + timestamp: str + description: str + source: str # execution_id that produced this + +@dataclass +class Technique: + name: str + description: str + success_count: int + +@dataclass +class Failure: + approach: str + reason: str + timestamp: str + +@dataclass +class HumanNote: + timestamp: str + note: str + execution_id: str # Which execution it was added to +``` + +--- + +## Machine Pattern + +### Two-Tier Hierarchy + +**Core Machine** (goal-driven, self-improving) +``` +┌─────────────────────────────────────────────────────────────┐ +│ Context accumulates: │ +│ - goal (original objective) │ +│ - progress (what's been done) │ +│ - techniques (what's working) │ +│ - failed_approaches (what to avoid) │ +│ - leaf_results (outputs from delegated work) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Decision loop: │ +│ 1. Assess: What's needed next for goal? │ +│ 2. Can I do it with my actions? → Do it, update progress │ +│ 3. Need external capability? → Delegate to leaf │ +│ 4. Goal complete? → Done │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Leaf Machine** (stateless worker) +``` +┌─────────────────────────────────────────────────────────────┐ +│ Single task: web_search, code_exec, file_ops, etc. │ +│ Input: task spec from core │ +│ Output: result │ +│ On complete: launches stored core copy with result │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Delegation Flow + +``` +Core Machine Leaf Machine +──────────── ──────────── +1. Working on goal +2. Need web search (not in my actions) +3. Store self (core copy + current context) +4. Generate leaf spec for web_search +5. Launch leaf with {task, core_copy} +6. Terminate + 7. Do web search + 8. Launch core_copy with {leaf_result} + 9. Terminate + +Core Machine Copy Launched +────────────────────────── +10. Resume with leaf_result in context +11. Continue toward goal... +``` + +### Aggressive Context Management + +**Target: <20% of model context**. Models perform better with lean context. Prune proactively, not reactively. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ load_context action (non-LLM): │ +│ 1. Fetch ledger from SQLite │ +│ 2. Estimate tokens (tiktoken + char ratio) │ +│ 3. Apply logarithmic history compression │ +│ 4. Prune aggressively to stay under 20% budget │ +│ 5. Return lean context │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ cleanup_context action (after each agent call): │ +│ 1. Check result usefulness │ +│ 2. If useless: log one-line to failures, clear result │ +│ 3. del/clear intermediate fields before next call │ +│ 4. Keep only what's needed for next step │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Logarithmic History Compression + +History grows but stays bounded. Recent = detailed, old = summarized. + +```python +def compress_history(milestones: list[Milestone], execution_count: int) -> list[Milestone]: + """ + Keep history logarithmic against execution count. + + Example with 100 executions: + - Last 5: full detail + - Previous 10: summarized to 3 entries + - Previous 25: summarized to 2 entries + - Everything before: single "early progress" entry + """ + if len(milestones) <= 5: + return milestones + + recent = milestones[-5:] # Last 5: keep full + older = milestones[:-5] + + # Compress older into buckets + compressed = [] + bucket_sizes = [10, 25, 50] # Logarithmic buckets + + for bucket_size in bucket_sizes: + if len(older) <= bucket_size: + # Summarize remaining into one entry + if older: + compressed.append(Milestone( + timestamp=older[0].timestamp, + description=f"[{len(older)} steps] " + "; ".join(m.description[:30] for m in older[:3]) + "...", + source="compressed" + )) + break + else: + # Take bucket, summarize, continue + bucket = older[:bucket_size] + older = older[bucket_size:] + compressed.append(Milestone( + timestamp=bucket[0].timestamp, + description=f"[{len(bucket)} steps] " + "; ".join(m.description[:30] for m in bucket[:2]) + "...", + source="compressed" + )) + + return compressed + recent +``` + +### Token Estimation + +Use multiple methods, log both: + +```python +# context.py + +from pathlib import Path + +def load_guidance() -> str: + """Load GUIDANCE.md (~400 tokens).""" + guidance_path = Path(__file__).parent.parent.parent / "GUIDANCE.md" + return guidance_path.read_text() + +def estimate_tokens(text: str) -> dict: + """Estimate tokens using multiple methods.""" + # Character ratio (~4 chars/token, rough) + char_estimate = len(text) // 4 + + # tiktoken (accurate, if available) + try: + import tiktoken + enc = tiktoken.get_encoding("cl100k_base") + tiktoken_estimate = len(enc.encode(text)) + except ImportError: + tiktoken_estimate = None + + return { + "char_estimate": char_estimate, + "tiktoken_estimate": tiktoken_estimate, + "used": tiktoken_estimate or char_estimate + } + +# Logging example: +# 📊 Context: 12,345/40,000 tokens (tiktoken: 12,345, char: 12,890) +# 📋 Guidance: 317 tokens +``` + +### Context Loading + +```python +def load_context(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Non-LLM action: load ledger, prune aggressively.""" + session_id = context["session_id"] + + # GLM 4.7 via Cerebras: 200K context + # Target 20% = 40K tokens + # Minimum 12K (model needs 8-11K for thinking) + model_limit = 200000 + target_pct = 0.20 + budget = max(int(model_limit * target_pct), 12000) # 40K target, 12K floor + + # Load GUIDANCE.md (~400 tokens, always included) + guidance = load_guidance() + guidance_tokens = estimate_tokens(guidance)["used"] + + # Remaining budget for ledger + ledger_budget = budget - guidance_tokens + + # Fetch and compress + ledger = self._fetch_ledger(session_id) + ledger.progress = compress_history(ledger.progress) + + tokens = estimate_tokens(self._serialize_ledger(ledger)) + + # Prune if over ledger budget + while tokens["used"] > ledger_budget and len(ledger.progress) > 1: + ledger.progress = ledger.progress[1:] + tokens = estimate_tokens(self._serialize_ledger(ledger)) + + context["guidance"] = guidance + context["ledger"] = ledger + context["token_estimate"] = tokens + context["token_budget"] = budget + context["guidance_tokens"] = guidance_tokens + + total = tokens["used"] + guidance_tokens + print(f"📊 Context: {total:,}/{budget:,} tokens " + f"(ledger: {tokens['used']:,}, guidance: {guidance_tokens})") + return context +``` + +### Agent Prompts + +Non-leaf agents include GUIDANCE.md in system prompt: + +```yaml +# agents/thinker.yml +spec: flatagent +spec_version: "0.9.0" + +data: + name: thinker + model: default + + system: | + {{ context.guidance }} + + --- + + Goal: {{ context.ledger.goal }} + + Progress ({{ context.ledger.progress | length }} milestones): + {% for m in context.ledger.progress[-5:] %} + - {{ m.description }} + {% endfor %} + + Techniques: {% for t in context.ledger.techniques %}{{ t.name }}; {% endfor %} + + Avoid: {% for f in context.ledger.failed_approaches[-3:] %}{{ f.approach }}; {% endfor %} + + Human notes: {% for n in context.ledger.human_notes[-3:] %}{{ n.note }}; {% endfor %} + + Tokens: {{ context.token_estimate.used }}/{{ context.token_budget }} + + user: | + What's the next step toward the goal? + + output: + action: + type: str + enum: ["work", "delegate", "done"] + detail: + type: str + description: "What to do, or why done" + new_technique: + type: object + required: false + properties: + name: { type: str } + description: { type: str } +``` + +Leaf agents do NOT include GUIDANCE.md—they're stateless workers: + +```yaml +# agents/executor.yml (leaf) +spec: flatagent +spec_version: "0.9.0" + +data: + name: executor + model: default + + system: | + Execute the task. Return the result. Be concise. + + user: | + Task: {{ input.task }} + + output: + result: + type: str +``` + +### Core Machine Template + +```yaml +spec: flatmachine +spec_version: "0.9.0" + +data: + name: core + + # Machine classification + metadata: + type: core + tags: ["goal-driven", "self-improving"] + + context: + session_id: "{{ input.session_id }}" + db_path: "{{ input.db_path }}" + model_token_limit: 200000 # GLM 4.7 via Cerebras + context_target_pct: 0.20 # Target 20% = 40K + context_minimum: 12000 # Floor (model needs 8-11K for thinking) + leaf_result: "{{ input.leaf_result | default(none) }}" + + agents: + thinker: ./thinker.yml + worker: ./worker.yml + planner: ./planner.yml + reflector: ./reflector.yml + + states: + start: + type: initial + transitions: + - to: load_context + + # Token-aware context loading (non-LLM) + load_context: + action: load_context + # Fetches ledger, prunes to fit token budget + transitions: + - condition: "context.leaf_result != none" + to: process_leaf_result + - to: think + + process_leaf_result: + agent: reflector + input: + leaf_result: "{{ context.leaf_result }}" + ledger: "{{ context.ledger }}" + output_to_context: + ledger_update: "{{ output.ledger_update }}" + transitions: + - to: save_ledger_after_leaf + + save_ledger_after_leaf: + action: save_ledger + transitions: + - to: think + + think: + agent: thinker + input: + ledger: "{{ context.ledger }}" + token_budget: "{{ context.token_budget }}" + output_to_context: + next_action: "{{ output.action }}" # work | delegate | done + action_detail: "{{ output.detail }}" + transitions: + - condition: "context.next_action == 'done'" + to: save_and_done + - condition: "context.next_action == 'delegate'" + to: plan_leaf + - to: work + + work: + agent: worker + input: + task: "{{ context.action_detail }}" + ledger: "{{ context.ledger }}" + output_to_context: + work_result: "{{ output.result }}" + work_success: "{{ output.success }}" + transitions: + - to: cleanup_after_work + + # Aggressive cleanup: clear useless results immediately + cleanup_after_work: + action: cleanup_context + # If work_result useless: log one-line failure, clear it + # Clear action_detail, intermediate fields + transitions: + - to: reflect + + reflect: + agent: reflector + input: + work_result: "{{ context.work_result }}" + work_success: "{{ context.work_success }}" + ledger: "{{ context.ledger }}" + output_to_context: + ledger_update: "{{ output.ledger_update }}" + transitions: + - to: cleanup_after_reflect + + cleanup_after_reflect: + action: cleanup_context + # Clear work_result, work_success after reflection extracted value + transitions: + - to: save_ledger + + save_ledger: + action: save_ledger + # Persists ledger_update to SQLite + transitions: + - to: load_context # Reload with compression + + plan_leaf: + agent: planner + input: + task: "{{ context.action_detail }}" + ledger: "{{ context.ledger }}" + output_to_context: + leaf_spec: "{{ output.machine_yaml }}" + leaf_agents: "{{ output.agent_yamls }}" + leaf_tags: "{{ output.tags }}" # e.g., ["web_search", "research"] + transitions: + - to: validate_leaf + + validate_leaf: + action: validate_specs + transitions: + - condition: "context.validation_passed" + to: delegate_to_leaf + - to: plan_leaf + + delegate_to_leaf: + action: delegate_to_leaf + transitions: + - to: suspended + + suspended: + type: final + output: + status: "delegated" + leaf_id: "{{ context.leaf_id }}" + + save_and_done: + action: save_ledger + transitions: + - to: done + + done: + type: final + output: + status: "completed" +``` + +### Leaf Machine Template + +```yaml +spec: flatmachine +spec_version: "0.9.0" + +data: + name: leaf_worker + + # Machine classification (planner sets these) + metadata: + type: leaf + tags: [] # e.g., ["web_search", "research"] + + context: + session_id: "{{ input.session_id }}" + db_path: "{{ input.db_path }}" + task: "{{ input.task }}" + core_copy_id: "{{ input.core_copy_id }}" + result_type: "{{ input.result_type }}" # web_search | code_exec | etc. + + agents: + executor: ./executor.yml # Task-specific agent + + states: + start: + type: initial + transitions: + - to: execute + + execute: + agent: executor + input: + task: "{{ context.task }}" + output_to_context: + result: "{{ output.result }}" + transitions: + - to: save_result + + save_result: + action: save_leaf_result + # Stores result in leaf_results table with token estimate + transitions: + - to: return_to_core + + return_to_core: + action: launch_core_with_result + transitions: + - to: done + + done: + type: final + output: + status: "returned" +``` + +### Machine Types + +Machines self-classify via `metadata.type`: + +| Type | Purpose | +|------|---------| +| `core` | Goal-driven, accumulates context, delegates | +| `leaf` | Stateless worker, single task, returns result | +| `planner` | Generates machine specs (could be leaf or standalone) | +| `task` | Simple task wrapper (like leaf but doesn't return to core) | + +Tags are freeform for filtering: `["web_search", "research", "code", "file_ops"]` + +Future machines can query past executions: +```sql +SELECT * FROM executions +WHERE session_id = ? AND tags LIKE '%"web_search"%' +ORDER BY created_at DESC LIMIT 5; +``` + +--- + +## Hooks Implementation + +```python +# hooks.py +import sqlite3 +import json +import uuid +from typing import Dict, Any +from flatagents import MachineHooks + +class AnythingAgentHooks(MachineHooks): + """ + Hooks for Anything Agent. + + Every transition snapshots to DB, inserts pending_approval, exits. + CLI polls and restores on approval. + """ + + def __init__(self, db_path: str, execution_id: str = None): + self.db_path = db_path + self.execution_id = execution_id or str(uuid.uuid4()) + + def on_transition( + self, + from_state: str, + to_state: str, + context: Dict[str, Any], + machine # FlatMachine instance for snapshot + ) -> bool: + """ + Snapshot state, insert pending approval, raise to exit. + CLI will restore and continue after human approves. + """ + conn = sqlite3.connect(self.db_path) + + # Update snapshot in lineage + snapshot = machine.get_snapshot() + conn.execute(""" + UPDATE lineage SET snapshot = ? WHERE execution_id = ? + """, (json.dumps(snapshot), self.execution_id)) + + # Insert pending approval + conn.execute(""" + INSERT INTO pending_approvals + (execution_id, state_name, context_json, proposed_transition, status, created_at) + VALUES (?, ?, ?, ?, 'pending', datetime('now')) + """, ( + self.execution_id, + from_state, + json.dumps(context), + to_state + )) + + conn.commit() + conn.close() + + # Exit - CLI will restore after approval + raise AwaitingApproval(self.execution_id, from_state, to_state) + + def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str, Any]: + if action_name == "load_context": + return self._load_context(context) + elif action_name == "cleanup_context": + return self._cleanup_context(context) + elif action_name == "save_ledger": + return self._save_ledger(context) + elif action_name == "validate_specs": + return self._validate_specs(context) + elif action_name == "delegate_to_leaf": + return self._delegate_to_leaf(context) + elif action_name == "launch_core_with_result": + return self._launch_core_with_result(context) + return context + + def _load_context(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Load ledger, compress history, stay under 20% budget.""" + from .context import fetch_ledger, compress_history, estimate_tokens + + session_id = context["session_id"] + model_limit = context.get("model_token_limit", 8192) + target_pct = context.get("context_target_pct", 0.20) + budget = int(model_limit * target_pct) + + ledger = fetch_ledger(self.db_path, session_id) + ledger["progress"] = compress_history(ledger["progress"]) + + tokens = estimate_tokens(json.dumps(ledger)) + + # Prune until under budget + while tokens["used"] > budget and len(ledger["progress"]) > 1: + ledger["progress"] = ledger["progress"][1:] + tokens = estimate_tokens(json.dumps(ledger)) + + context["ledger"] = ledger + context["token_estimate"] = tokens + context["token_budget"] = budget + + print(f"📊 Context: {tokens['used']}/{budget} tokens " + f"(tiktoken: {tokens.get('tiktoken_estimate', 'N/A')}, " + f"char: {tokens['char_estimate']})") + return context + + def _cleanup_context(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Aggressive cleanup: clear intermediate fields, log failures.""" + # Check if work_result is useless + work_result = context.get("work_result") + if work_result and context.get("work_success") == False: + # Log one-line failure, then clear + failure_line = str(work_result)[:100].replace('\n', ' ') + print(f"❌ Useless result logged: {failure_line}") + # Add to ledger failures (will be saved on next save_ledger) + if "ledger_update" not in context: + context["ledger_update"] = {} + context["ledger_update"]["new_failure"] = { + "approach": context.get("action_detail", "unknown")[:50], + "reason": failure_line, + "timestamp": datetime.now().isoformat() + } + + # Clear intermediate fields + for field in ["work_result", "work_success", "action_detail", "leaf_result"]: + if field in context and context[field] is not None: + context[field] = None + + return context + + def _save_ledger(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Persist ledger updates to SQLite.""" + session_id = context["session_id"] + update = context.get("ledger_update", {}) + + if not update: + return context + + conn = sqlite3.connect(self.db_path) + now = datetime.now().isoformat() + + # Apply updates + if "new_milestone" in update: + cursor = conn.execute("SELECT progress FROM ledger WHERE session_id = ?", (session_id,)) + progress = json.loads(cursor.fetchone()[0] or "[]") + progress.append(update["new_milestone"]) + conn.execute("UPDATE ledger SET progress = ?, updated_at = ? WHERE session_id = ?", + (json.dumps(progress), now, session_id)) + + if "new_failure" in update: + cursor = conn.execute("SELECT failed_approaches FROM ledger WHERE session_id = ?", (session_id,)) + failures = json.loads(cursor.fetchone()[0] or "[]") + failures.append(update["new_failure"]) + conn.execute("UPDATE ledger SET failed_approaches = ?, updated_at = ? WHERE session_id = ?", + (json.dumps(failures), now, session_id)) + + if "new_technique" in update: + cursor = conn.execute("SELECT techniques FROM ledger WHERE session_id = ?", (session_id,)) + techniques = json.loads(cursor.fetchone()[0] or "[]") + techniques.append(update["new_technique"]) + conn.execute("UPDATE ledger SET techniques = ?, updated_at = ? WHERE session_id = ?", + (json.dumps(techniques), now, session_id)) + + conn.commit() + conn.close() + + context["ledger_update"] = {} # Clear after save + return context + + def _validate_specs(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Validate generated specs, return errors in context.""" + from .validators import SpecValidator + + validator = SpecValidator() + errors = [] + + spec_key = "leaf_spec" if "leaf_spec" in context else "successor_spec" + agents_key = "leaf_agents" if "leaf_agents" in context else "successor_agents" + + errors.extend(validator.validate_machine(context.get(spec_key, ""))) + for name, yaml_str in context.get(agents_key, {}).items(): + errors.extend(validator.validate_agent(yaml_str)) + + if errors: + self._log_validation(errors) + + context["validation_passed"] = len(errors) == 0 + context["validation_errors"] = [{"path": e.path, "msg": e.message} for e in errors] + return context + + def _delegate_to_leaf(self, context: Dict[str, Any]) -> Dict[str, Any]: + """ + Store core copy, launch leaf with reference to core. + Leaf will relaunch core with result. + """ + leaf_id = str(uuid.uuid4()) + core_copy_id = str(uuid.uuid4()) + + conn = sqlite3.connect(self.db_path) + + # Get current core spec + cursor = conn.execute( + "SELECT machine_yaml, agent_yamls FROM lineage WHERE execution_id = ?", + (self.execution_id,) + ) + row = cursor.fetchone() + core_yaml, core_agents = row[0], row[1] + + # Store core copy (with current context as input for relaunch) + core_input = { + "goal": context.get("goal"), + "progress": context.get("progress"), + "techniques": context.get("techniques"), + "failed_approaches": context.get("failed_approaches"), + # leaf_result will be added by leaf when relaunching + } + conn.execute(""" + INSERT INTO lineage + (execution_id, parent_id, machine_yaml, agent_yamls, snapshot, status, created_at) + VALUES (?, ?, ?, ?, ?, 'stored', datetime('now')) + """, ( + core_copy_id, + self.execution_id, + core_yaml, + core_agents, + json.dumps({"input": core_input}) # Store input for relaunch + )) + + # Launch leaf with task + core_copy reference + conn.execute(""" + INSERT INTO lineage + (execution_id, parent_id, machine_yaml, agent_yamls, snapshot, status, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', datetime('now')) + """, ( + leaf_id, + self.execution_id, + context["leaf_spec"], + json.dumps(context["leaf_agents"]), + json.dumps({"input": { + "task": context["action_detail"], + "core_copy_id": core_copy_id, + "db_path": context["db_path"] + }}) + )) + + # Mark self as terminated + conn.execute(""" + UPDATE lineage SET status = 'terminated' WHERE execution_id = ? + """, (self.execution_id,)) + + conn.commit() + conn.close() + + context["leaf_id"] = leaf_id + context["core_copy_id"] = core_copy_id + return context + + def _launch_core_with_result(self, context: Dict[str, Any]) -> Dict[str, Any]: + """ + Leaf completes: launch stored core copy with leaf result. + """ + core_copy_id = context["core_copy_id"] + new_core_id = str(uuid.uuid4()) + + conn = sqlite3.connect(self.db_path) + + # Get stored core copy + cursor = conn.execute( + "SELECT machine_yaml, agent_yamls, snapshot FROM lineage WHERE execution_id = ?", + (core_copy_id,) + ) + row = cursor.fetchone() + core_yaml, core_agents, core_snapshot = row[0], row[1], row[2] + + # Parse stored input, add leaf result + stored = json.loads(core_snapshot) + stored["input"]["leaf_result"] = context["result"] + + # Launch new core instance + conn.execute(""" + INSERT INTO lineage + (execution_id, parent_id, machine_yaml, agent_yamls, snapshot, status, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', datetime('now')) + """, ( + new_core_id, + core_copy_id, + core_yaml, + core_agents, + json.dumps(stored) + )) + + # Mark leaf as terminated + conn.execute(""" + UPDATE lineage SET status = 'terminated' WHERE execution_id = ? + """, (self.execution_id,)) + + conn.commit() + conn.close() + + context["new_core_id"] = new_core_id + return context + + +class AwaitingApproval(Exception): + """Raised to exit process and await human approval.""" + def __init__(self, execution_id: str, from_state: str, to_state: str): + self.execution_id = execution_id + self.from_state = from_state + self.to_state = to_state + super().__init__(f"Awaiting approval: {from_state} → {to_state}") +``` + +--- + +## CLI (Observer + Runner) + +```python +# observer.py +""" +CLI that polls for pending approvals and restores machines on approval. +Based on human-in-the-loop pattern with prompt_toolkit. +""" +import sqlite3 +import json +import asyncio +from pathlib import Path + +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import HTML + +from flatagents import FlatMachine +from .hooks import AnythingAgentHooks, AwaitingApproval + +session = PromptSession() + +async def run_loop(db_path: str): + """Main CLI loop: poll, display, approve/stop, restore, repeat.""" + print(f"🔍 Anything Agent Observer") + print(f"📁 Database: {db_path}") + print("Commands: [a]pprove, [s]top, [q]uit, [n]ote (approve with note)") + print("-" * 60) + + while True: + # Check for pending approvals + pending = get_pending(db_path) + + if not pending: + # Check for pending executions (from launches) + pending_exec = get_pending_execution(db_path) + if pending_exec: + print(f"\n⏳ Starting pending execution {pending_exec['execution_id'][:8]}...") + await run_execution(pending_exec, db_path) + else: + await asyncio.sleep(1) + continue + + # Display and get decision + approval = pending[0] + display_approval(approval) + + response = await session.prompt_async(HTML('Decision [a/s/n/q]: ')) + response = response.strip().lower() + + if response in ('a', 'approve', ''): + approve(db_path, approval['id'], approval['session_id']) + print("✅ Approved. Restoring machine...") + await restore_and_continue(approval['execution_id'], db_path) + elif response in ('n', 'note'): + note = await session.prompt_async(HTML('Note: ')) + note = note.strip() + if note: + approve(db_path, approval['id'], approval['session_id'], note) + print(f"✅ Approved with note. Restoring machine...") + else: + approve(db_path, approval['id'], approval['session_id']) + print("✅ Approved. Restoring machine...") + await restore_and_continue(approval['execution_id'], db_path) + elif response in ('s', 'stop'): + stop(db_path, approval['id']) + print("🛑 Stopped. Session paused.") + elif response in ('q', 'quit'): + print("Goodbye.") + break + +async def restore_and_continue(execution_id: str, db_path: str): + """Restore machine from snapshot and continue execution.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT * FROM lineage WHERE execution_id = ?", + (execution_id,) + ) + row = cursor.fetchone() + conn.close() + + if not row or not row['snapshot']: + print(f"❌ No snapshot for {execution_id}") + return + + await run_execution(dict(row), db_path) + +async def run_execution(row: dict, db_path: str): + """Run a machine from lineage row.""" + import yaml + + execution_id = row['execution_id'] + machine_config = yaml.safe_load(row['machine_yaml']) + snapshot = json.loads(row['snapshot']) if row.get('snapshot') else None + + # Mark as running + conn = sqlite3.connect(db_path) + conn.execute("UPDATE lineage SET status = 'running' WHERE execution_id = ?", (execution_id,)) + conn.commit() + conn.close() + + hooks = AnythingAgentHooks(db_path, execution_id) + machine = FlatMachine(config=machine_config, hooks=hooks) + + try: + if snapshot: + result = await machine.execute(resume_from_snapshot=snapshot) + else: + result = await machine.execute(input={"db_path": db_path}) + print(f"✅ Execution complete: {result}") + except AwaitingApproval as e: + print(f"⏸️ Paused at {e.from_state} → {e.to_state}. Awaiting approval...") + +def display_approval(approval: dict): + """Display pending approval.""" + print(f"\n{'═' * 60}") + print(f"Pending: {approval['execution_id'][:8]}...") + print(f"State: {approval['state_name']} → {approval['proposed_transition']}") + print(f"\nContext:") + context = json.loads(approval['context_json']) + for k, v in list(context.items())[:10]: # Limit display + print(f" {k}: {str(v)[:80]}") + print('═' * 60) + +def get_pending(db_path: str) -> list: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT * FROM pending_approvals WHERE status = 'pending' ORDER BY created_at" + ) + result = [dict(r) for r in cursor.fetchall()] + conn.close() + return result + +def get_pending_execution(db_path: str) -> dict: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT * FROM lineage WHERE status = 'pending' ORDER BY created_at LIMIT 1" + ) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + +def approve(db_path: str, approval_id: int, session_id: str, note: str = None): + conn = sqlite3.connect(db_path) + now = datetime.now().isoformat() + + # Update approval + conn.execute(""" + UPDATE pending_approvals + SET status = 'approved', human_note = ?, responded_at = ? + WHERE id = ? + """, (note, now, approval_id)) + + # If note provided, append to ledger.human_notes + if note: + cursor = conn.execute( + "SELECT human_notes FROM ledger WHERE session_id = ?", + (session_id,) + ) + row = cursor.fetchone() + notes = json.loads(row[0]) if row and row[0] else [] + notes.append({ + "timestamp": now, + "note": note, + "approval_id": approval_id + }) + conn.execute(""" + UPDATE ledger SET human_notes = ?, updated_at = ? + WHERE session_id = ? + """, (json.dumps(notes), now, session_id)) + + conn.commit() + conn.close() + +def stop(db_path: str, approval_id: int): + conn = sqlite3.connect(db_path) + conn.execute( + "UPDATE pending_approvals SET status = 'stopped' WHERE id = ?", + (approval_id,) + ) + # Also mark lineage as suspended + cursor = conn.execute( + "SELECT execution_id FROM pending_approvals WHERE id = ?", + (approval_id,) + ) + exec_id = cursor.fetchone()[0] + conn.execute( + "UPDATE lineage SET status = 'suspended' WHERE execution_id = ?", + (exec_id,) + ) + conn.commit() + conn.close() +``` + +--- + +## Entry Point + +```python +# main.py +import argparse +import asyncio +import sqlite3 +import uuid +from datetime import datetime +from pathlib import Path + +from flatagents import FlatMachine +from .hooks import AnythingAgentHooks, AwaitingApproval +from .observer import run_loop + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + goal TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS ledger ( + session_id TEXT PRIMARY KEY, + goal TEXT NOT NULL, + progress TEXT NOT NULL DEFAULT '[]', + techniques TEXT NOT NULL DEFAULT '[]', + failed_approaches TEXT NOT NULL DEFAULT '[]', + human_notes TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS executions ( + execution_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + parent_id TEXT, + machine_type TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + machine_yaml TEXT NOT NULL, + agent_yamls TEXT NOT NULL, + snapshot TEXT, + status TEXT NOT NULL, + input_tokens INTEGER, + output_tokens INTEGER, + created_at TEXT NOT NULL, + terminated_at TEXT +); + +CREATE TABLE IF NOT EXISTS leaf_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + session_id TEXT NOT NULL, + result_type TEXT NOT NULL, + result_json TEXT NOT NULL, + token_count INTEGER, + created_at TEXT NOT NULL, + pruned INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS pending_approvals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + session_id TEXT NOT NULL, + state_name TEXT NOT NULL, + context_json TEXT NOT NULL, + proposed_transition TEXT NOT NULL, + status TEXT NOT NULL, + human_note TEXT, + created_at TEXT NOT NULL, + responded_at TEXT +); + +CREATE TABLE IF NOT EXISTS validation_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + errors_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_executions_session ON executions(session_id); +CREATE INDEX IF NOT EXISTS idx_pending_session ON pending_approvals(session_id, status); +""" + +def init_db(db_path: str): + conn = sqlite3.connect(db_path) + conn.executescript(SCHEMA) + conn.commit() + conn.close() + +async def start_goal(goal: str, db_path: str): + """Start new session with goal.""" + session_id = str(uuid.uuid4()) + execution_id = str(uuid.uuid4()) + now = datetime.now().isoformat() + + bootstrap_path = Path(__file__).parent / "machines" / "core.yml" + with open(bootstrap_path) as f: + machine_yaml = f.read() + + conn = sqlite3.connect(db_path) + + # Create session + conn.execute(""" + INSERT INTO sessions (session_id, goal, status, created_at, updated_at) + VALUES (?, ?, 'active', ?, ?) + """, (session_id, goal, now, now)) + + # Initialize ledger + conn.execute(""" + INSERT INTO ledger (session_id, goal, updated_at) + VALUES (?, ?, ?) + """, (session_id, goal, now)) + + # Create initial execution + conn.execute(""" + INSERT INTO executions + (execution_id, session_id, machine_type, tags, machine_yaml, agent_yamls, status, created_at) + VALUES (?, ?, 'core', '["initial"]', ?, '{}', 'pending', ?) + """, (execution_id, session_id, machine_yaml, now)) + + conn.commit() + conn.close() + + print(f"🚀 Session {session_id[:8]}... created") + print(f" Goal: {goal}") + print(f" Run './run.sh observe' to approve transitions") + +async def resume_session(session_id: str, db_path: str): + """Resume a paused session.""" + conn = sqlite3.connect(db_path) + conn.execute( + "UPDATE sessions SET status = 'active', updated_at = ? WHERE session_id = ?", + (datetime.now().isoformat(), session_id) + ) + conn.commit() + conn.close() + print(f"▶️ Session {session_id[:8]}... resumed") + +def list_sessions(db_path: str): + """List all sessions.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT session_id, goal, status, created_at + FROM sessions ORDER BY created_at DESC LIMIT 20 + """) + rows = cursor.fetchall() + conn.close() + + print(f"{'Session':<12} {'Status':<10} {'Goal':<40} {'Created'}") + print("-" * 80) + for r in rows: + print(f"{r['session_id'][:10]}.. {r['status']:<10} {r['goal'][:38]:<40} {r['created_at'][:16]}") + +def main(): + parser = argparse.ArgumentParser(description="Anything Agent") + parser.add_argument("command", choices=["run", "resume", "list", "observe"]) + parser.add_argument("--goal", help="Goal for new run") + parser.add_argument("--session", help="Session ID to resume") + parser.add_argument("--db", default="./anything_agent.db") + args = parser.parse_args() + + init_db(args.db) + + if args.command == "run": + if not args.goal: + parser.error("--goal required") + asyncio.run(start_goal(args.goal, args.db)) + elif args.command == "resume": + if not args.session: + parser.error("--session required") + asyncio.run(resume_session(args.session, args.db)) + elif args.command == "list": + list_sessions(args.db) + elif args.command == "observe": + asyncio.run(run_loop(args.db)) + +if __name__ == "__main__": + main() +``` + +--- + +## Run Script + +```bash +#!/bin/bash +set -e +cd "$(dirname "$0")" + +case ${1:-observe} in + run) + python -m anything_agent.main run --goal "$2" + ;; + resume) + python -m anything_agent.main resume --session "$2" + ;; + list) + python -m anything_agent.main list + ;; + observe) + python -m anything_agent.main observe + ;; + status) + echo "=== Sessions ===" + sqlite3 -header -column ./anything_agent.db \ + "SELECT session_id, status, goal FROM sessions ORDER BY created_at DESC LIMIT 5" + echo "" + echo "=== Recent Executions ===" + sqlite3 -header -column ./anything_agent.db \ + "SELECT execution_id, machine_type, status FROM executions ORDER BY created_at DESC LIMIT 10" + ;; + *) + echo "Usage: ./run.sh [run 'goal'|resume |list|observe|status]" + ;; +esac +``` + +--- + +## Model Configuration + +Single model: **GLM 4.7 via Cerebras** + +```yaml +# config/profiles.yml +spec: flatprofiles +spec_version: "0.9.0" + +data: + model_profiles: + default: + provider: cerebras + name: zai-glm-4.7 + temperature: 0.9 + top_p: 0.95 + max_tokens: 2048 + + default: default +``` + +- Context limit: 200K tokens +- Target: 20% = 40K tokens +- Minimum: 12K tokens (model needs 8-11K for thinking) +- GUIDANCE.md: ~400 tokens, always included in non-leaf agents + +--- + +## File Structure + +``` +anything_agent/ +├── PLAN.md +├── GUIDANCE.md # ~400 tokens, hardcoded into non-leaf agents +├── run.sh +├── pyproject.toml +├── src/anything_agent/ +│ ├── __init__.py +│ ├── main.py # Entry: run/observe +│ ├── hooks.py # AnythingAgentHooks +│ ├── observer.py # CLI with prompt_toolkit +│ ├── validators.py # Spec validation +│ ├── context.py # Token estimation, compression, guidance loading +│ └── machines/ +│ └── core.yml +└── config/ + └── profiles.yml +``` + +--- + +## Summary + +1. **Sessions**: Each goal is a session. Pause with Ctrl+C, resume later. + +2. **Lean Context**: Target 20% of 200K = 40K tokens. Minimum 12K (model needs 8-11K for thinking). + +3. **GUIDANCE.md**: ~400 token guidance hardcoded into all non-leaf agents. Goals, limitations, patterns. + +4. **Logarithmic History**: Recent = detailed, old = compressed. Bounded growth. + +5. **Immediate Cleanup**: Useless results → one-line failure log → clear before next call. + +6. **Single Model**: GLM 4.7 via Cerebras, temp 0.9, top_p 0.95. + +7. **Two-Tier Machines**: + - **Core**: Goal-driven, includes GUIDANCE.md, reads/writes ledger + - **Leaf**: Stateless worker, no guidance, single task + +8. **Human Notes**: Every approval can include a note, added to ledger. + +9. **Token Estimation**: Both tiktoken and char ratio, log both. + +--- + +## Design Decisions + +### Context Budget +**Decision**: 200K context, target 20% = 40K tokens. Minimum 12K floor (model needs 8-11K for thinking). Model can increase its cap if it explains why. + +### GUIDANCE.md +**Decision**: ~400 token guidance document hardcoded into all non-leaf agents. Contains goals, limitations, patterns. LLM could drop it but human would stop execution. + +### Aggressive Cleanup +**Decision**: After each agent call, `cleanup_context` action clears intermediate fields. Useless results → one-line failure log → delete immediately. + +### Logarithmic History +**Decision**: History compresses as it ages. Last 5 milestones full detail, older buckets summarized. Bounded growth. + +### Token Estimation +**Decision**: Both tiktoken (accurate) and char ratio (~4 chars/token). Log both estimates. + +### Single Model +**Decision**: GLM 4.7 via Cerebras only. temp=0.9, top_p=0.95, max_tokens=2048. + +### Human Input +**Decision**: Every approval can include a note. Timestamped, added to `ledger.human_notes`. + +--- + +## Open Questions + +### Technique Learning + +How does reflector identify novel techniques? + +Options: +1. **Explicit output**: Reflector outputs `new_technique: {name, description}` or null +2. **Human-guided**: Human notes flag techniques ("technique: X works for Y") + +### Leaf Machine Library + +Should there be predefined leaf templates? + +Options: +1. **Generated only**: Planner generates from scratch each time +2. **Hybrid**: Query past successful leaves by tag, reuse or customize + +--- + +## Implementation Order + +1. **Schema + CLI**: SQLite schema, observer CLI with human notes +2. **Context module**: `estimate_tokens` (tiktoken + char), `compress_history` (logarithmic) +3. **Context actions**: `load_context`, `cleanup_context`, `save_ledger` +4. **Core machine**: thinker, worker, reflector agents + cleanup cycle +5. **Delegation**: `delegate_to_leaf` + `launch_core_with_result` +6. **Leaf template**: executor + `save_leaf_result` + return-to-core +7. **Validation**: Spec validators, self-correction loop +8. **Profiles**: Single GLM 4.7 profile diff --git a/sdk/examples/anything_agent/SUMMARY.md b/sdk/examples/anything_agent/SUMMARY.md new file mode 100644 index 00000000..1e9fa85b --- /dev/null +++ b/sdk/examples/anything_agent/SUMMARY.md @@ -0,0 +1,52 @@ +# Summary + + The prototype works! It: + 1. Creates sessions + 2. Pauses at each transition for human approval + 3. Auto-approves work through the core machine + 4. Makes LLM calls via Cerebras + 5. Completes with status + + Let me summarize what was built and note the files. + + It works! The prototype successfully: + - Creates sessions with goals + - Pauses at each transition for human approval + - Loads context with token tracking (344/40K tokens) + - Makes LLM calls via Cerebras GLM 4.7 + - Completes the goal + + Files created: + + ``` + anything_agent/ + ├── GUIDANCE.md # ~310 tokens guidance + ├── PLAN.md # Full design doc + ├── run.sh # Entry script + ├── pyproject.toml + ├── config/profiles.yml # GLM 4.7, temp 0.9 + └── src/anything_agent/ + ├── __init__.py + ├── main.py # run/list/observe commands + ├── hooks.py # Approval hooks + ├── observer.py # CLI + execution + ├── context.py # Token estimation + └── machines/ + ├── core.yml # Main state machine + └── agents/ + ├── thinker.yml + ├── worker.yml + └── reflector.yml + ``` + + Usage: + + ```bash + ./run.sh run "Your goal" # Create session + ./run.sh observe # Approve transitions + ./run.sh list # List sessions + ./run.sh status # Quick DB check + ``` + + + diff --git a/sdk/examples/anything_agent/anything_agent.db b/sdk/examples/anything_agent/anything_agent.db new file mode 100644 index 0000000000000000000000000000000000000000..66ee442ba2c4f187e1125c437af5afb367681d53 GIT binary patch literal 81920 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 Duad^2 literal 0 HcmV?d00001 diff --git a/sdk/examples/anything_agent/config/profiles.yml b/sdk/examples/anything_agent/config/profiles.yml new file mode 100644 index 00000000..ffc0b3b5 --- /dev/null +++ b/sdk/examples/anything_agent/config/profiles.yml @@ -0,0 +1,13 @@ +spec: flatprofiles +spec_version: "0.9.0" + +data: + model_profiles: + default: + provider: cerebras + name: zai-glm-4.7 + temperature: 0.9 + top_p: 0.95 + max_tokens: 2048 + + default: default diff --git a/sdk/examples/anything_agent/pyproject.toml b/sdk/examples/anything_agent/pyproject.toml new file mode 100644 index 00000000..a900233d --- /dev/null +++ b/sdk/examples/anything_agent/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "anything_agent" +version = "0.1.0" +description = "Autonomous agent with human oversight and inverted control" +dependencies = [ + "flatagents[litellm]", + "prompt-toolkit>=3.0.0", + "tiktoken>=0.5.0", +] +requires-python = ">=3.10" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.scripts] +anything-agent = "anything_agent.main:main" diff --git a/sdk/examples/anything_agent/run.sh b/sdk/examples/anything_agent/run.sh new file mode 100755 index 00000000..74cdb7aa --- /dev/null +++ b/sdk/examples/anything_agent/run.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# Find project root +find_project_root() { + local dir="$1" + while [[ "$dir" != "/" ]]; do + if [[ -e "$dir/.git" ]]; then echo "$dir"; return 0; fi + dir="$(dirname "$dir")" + done + return 1 +} +PROJECT_ROOT="$(find_project_root "$SCRIPT_DIR")" +PYTHON_SDK_PATH="$PROJECT_ROOT/sdk/python" + +# Ensure venv +if [ ! -d ".venv" ]; then + uv venv .venv +fi + +# Install deps +uv pip install --python .venv/bin/python -e "$PYTHON_SDK_PATH[litellm]" -e . prompt-toolkit tiktoken + +case ${1:-observe} in + run) + .venv/bin/python -m anything_agent.main run --goal "$2" + ;; + resume) + .venv/bin/python -m anything_agent.main resume --session "$2" + ;; + list) + .venv/bin/python -m anything_agent.main list + ;; + observe) + .venv/bin/python -m anything_agent.main observe + ;; + status) + echo "=== Sessions ===" && sqlite3 -header -column ./anything_agent.db \ + "SELECT substr(session_id,1,8) as id, status, substr(goal,1,40) as goal FROM sessions ORDER BY created_at DESC LIMIT 5" 2>/dev/null || echo "No database yet" + ;; + *) + echo "Usage: ./run.sh [run 'goal'|resume |list|observe|status]" + ;; +esac diff --git a/sdk/examples/anything_agent/src/anything_agent/__init__.py b/sdk/examples/anything_agent/src/anything_agent/__init__.py new file mode 100644 index 00000000..99bc2cd6 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/__init__.py @@ -0,0 +1 @@ +"""Anything Agent: Autonomous agent with human oversight.""" diff --git a/sdk/examples/anything_agent/src/anything_agent/context.py b/sdk/examples/anything_agent/src/anything_agent/context.py new file mode 100644 index 00000000..1f3a34eb --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/context.py @@ -0,0 +1,67 @@ +"""Token estimation, history compression, guidance loading.""" +import json +import sqlite3 +from pathlib import Path +from datetime import datetime +from typing import Any + +def load_guidance() -> str: + """Load GUIDANCE.md (~400 tokens).""" + path = Path(__file__).parent.parent.parent / "GUIDANCE.md" + return path.read_text() + +def estimate_tokens(text: str) -> dict: + """Estimate tokens using tiktoken + char ratio.""" + char_estimate = len(text) // 4 + try: + import tiktoken + enc = tiktoken.get_encoding("cl100k_base") + tiktoken_estimate = len(enc.encode(text)) + except ImportError: + tiktoken_estimate = None + return { + "char_estimate": char_estimate, + "tiktoken_estimate": tiktoken_estimate, + "used": tiktoken_estimate or char_estimate + } + +def compress_history(milestones: list) -> list: + """Logarithmic compression: recent=detailed, old=summarized.""" + if len(milestones) <= 5: + return milestones + + recent = milestones[-5:] + older = milestones[:-5] + + # Summarize older into one entry + if older: + summary = { + "timestamp": older[0].get("timestamp", ""), + "description": f"[{len(older)} earlier steps]", + "source": "compressed" + } + return [summary] + recent + return recent + +def fetch_ledger(db_path: str, session_id: str) -> dict: + """Fetch ledger from SQLite.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM ledger WHERE session_id = ?", (session_id,)) + row = cursor.fetchone() + conn.close() + + if not row: + return {"goal": "", "progress": [], "techniques": [], "failed_approaches": [], "human_notes": []} + + return { + "goal": row["goal"], + "progress": json.loads(row["progress"] or "[]"), + "techniques": json.loads(row["techniques"] or "[]"), + "failed_approaches": json.loads(row["failed_approaches"] or "[]"), + "human_notes": json.loads(row["human_notes"] or "[]"), + } + +def serialize_ledger(ledger: dict) -> str: + """Serialize ledger for token counting.""" + return json.dumps(ledger, indent=2) diff --git a/sdk/examples/anything_agent/src/anything_agent/hooks.py b/sdk/examples/anything_agent/src/anything_agent/hooks.py new file mode 100644 index 00000000..d92a34f2 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/hooks.py @@ -0,0 +1,141 @@ +"""Hooks for Anything Agent.""" +import sqlite3 +import json +import uuid +from datetime import datetime +from typing import Dict, Any +from flatagents import MachineHooks +from .context import load_guidance, estimate_tokens, compress_history, fetch_ledger, serialize_ledger + +class AwaitingApproval(Exception): + """Raised to exit and await human approval.""" + def __init__(self, execution_id: str, from_state: str, to_state: str): + self.execution_id = execution_id + self.from_state = from_state + self.to_state = to_state + super().__init__(f"Awaiting: {from_state} → {to_state}") + +class AnythingAgentHooks(MachineHooks): + def __init__(self, db_path: str, session_id: str, execution_id: str = None): + self.db_path = db_path + self.session_id = session_id + self.execution_id = execution_id or str(uuid.uuid4()) + + def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) -> str: + """Check approval, snapshot if needed, exit if not approved.""" + conn = sqlite3.connect(self.db_path) + now = datetime.now().isoformat() + + # Check if this exact transition was already approved + cursor = conn.execute( + "SELECT status FROM pending_approvals WHERE execution_id = ? AND state_name = ? AND proposed_transition = ? ORDER BY id DESC LIMIT 1", + (self.execution_id, from_state, to_state) + ) + row = cursor.fetchone() + + if row and row[0] == 'approved': + # Already approved, continue + conn.close() + return to_state + + if row and row[0] == 'pending': + # Already pending, just wait + conn.close() + raise AwaitingApproval(self.execution_id, from_state, to_state) + + # New transition, needs approval + # Serialize context, handling non-serializable items + ctx_copy = {} + for k, v in context.items(): + try: + json.dumps(v) + ctx_copy[k] = v + except (TypeError, ValueError): + ctx_copy[k] = str(v) + + snapshot = {"state": to_state, "context": ctx_copy} + conn.execute("UPDATE executions SET snapshot = ? WHERE execution_id = ?", + (json.dumps(snapshot), self.execution_id)) + conn.execute( + "INSERT INTO pending_approvals (execution_id, session_id, state_name, context_json, proposed_transition, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', ?)", + (self.execution_id, self.session_id, from_state, json.dumps(ctx_copy), to_state, now) + ) + conn.commit() + conn.close() + raise AwaitingApproval(self.execution_id, from_state, to_state) + + def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str, Any]: + if action_name == "load_context": + return self._load_context(context) + elif action_name == "cleanup_context": + return self._cleanup_context(context) + elif action_name == "save_ledger": + return self._save_ledger(context) + return context + + def _load_context(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Load ledger, prune to budget.""" + model_limit = context.get("model_token_limit", 200000) + target_pct = context.get("context_target_pct", 0.20) + minimum = context.get("context_minimum", 12000) + budget = max(int(model_limit * target_pct), minimum) + + guidance = load_guidance() + guidance_tokens = estimate_tokens(guidance)["used"] + ledger_budget = budget - guidance_tokens + + ledger = fetch_ledger(self.db_path, self.session_id) + ledger["progress"] = compress_history(ledger["progress"]) + + tokens = estimate_tokens(serialize_ledger(ledger)) + while tokens["used"] > ledger_budget and len(ledger["progress"]) > 1: + ledger["progress"] = ledger["progress"][1:] + tokens = estimate_tokens(serialize_ledger(ledger)) + + context["guidance"] = guidance + context["ledger"] = ledger + context["token_estimate"] = tokens + context["token_budget"] = budget + + total = tokens["used"] + guidance_tokens + print(f"📊 Context: {total:,}/{budget:,} tokens (ledger: {tokens['used']:,}, guidance: {guidance_tokens})") + return context + + def _cleanup_context(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Clear intermediate fields, log failures.""" + if context.get("work_result") and context.get("work_success") == False: + failure_line = str(context["work_result"])[:100].replace('\n', ' ') + print(f"❌ Failed: {failure_line}") + if "ledger_update" not in context: + context["ledger_update"] = {} + context["ledger_update"]["new_failure"] = { + "approach": context.get("action_detail", "unknown")[:50], + "reason": failure_line, + "timestamp": datetime.now().isoformat() + } + + for field in ["work_result", "work_success", "action_detail", "leaf_result"]: + context[field] = None + return context + + def _save_ledger(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Persist ledger updates.""" + update = context.get("ledger_update", {}) + if not update: + return context + + conn = sqlite3.connect(self.db_path) + now = datetime.now().isoformat() + + for key, field in [("new_milestone", "progress"), ("new_failure", "failed_approaches"), ("new_technique", "techniques")]: + if key in update: + cursor = conn.execute(f"SELECT {field} FROM ledger WHERE session_id = ?", (self.session_id,)) + items = json.loads(cursor.fetchone()[0] or "[]") + items.append(update[key]) + conn.execute(f"UPDATE ledger SET {field} = ?, updated_at = ? WHERE session_id = ?", + (json.dumps(items), now, self.session_id)) + + conn.commit() + conn.close() + context["ledger_update"] = {} + return context diff --git a/sdk/examples/anything_agent/src/anything_agent/machines/agents/reflector.yml b/sdk/examples/anything_agent/src/anything_agent/machines/agents/reflector.yml new file mode 100644 index 00000000..4e9e2b70 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/machines/agents/reflector.yml @@ -0,0 +1,55 @@ +spec: flatagent +spec_version: "0.9.0" + +data: + name: reflector + model: default + + system: | + {{ input.guidance }} + + --- + + You reflect on work results and update the ledger. + + GOAL: {{ input.ledger.goal }} + + Add milestones for progress. Add techniques that worked. Add failures to avoid. + + user: | + {% if input.work_result %} + WORK RESULT: {{ input.work_result }} + SUCCESS: {{ input.work_success }} + {% elif input.leaf_result %} + LEAF RESULT: {{ input.leaf_result }} + {% endif %} + + What should be added to the ledger? Be concise. + + output: + ledger_update: + type: object + description: "Updates to ledger" + properties: + new_milestone: + type: object + required: false + properties: + description: + type: str + new_technique: + type: object + required: false + properties: + name: + type: str + description: + type: str + new_failure: + type: object + required: false + properties: + approach: + type: str + reason: + type: str diff --git a/sdk/examples/anything_agent/src/anything_agent/machines/agents/thinker.yml b/sdk/examples/anything_agent/src/anything_agent/machines/agents/thinker.yml new file mode 100644 index 00000000..a56f43b6 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/machines/agents/thinker.yml @@ -0,0 +1,41 @@ +spec: flatagent +spec_version: "0.9.0" + +data: + name: thinker + model: default + + system: | + {{ input.guidance }} + + --- + + GOAL: {{ input.ledger.goal }} + + PROGRESS: + {% for m in input.ledger.progress[-5:] %} + - {{ m.description }} + {% endfor %} + {% if not input.ledger.progress %}(none yet){% endif %} + + TECHNIQUES: {% for t in input.ledger.techniques %}{{ t.name }}; {% endfor %}{% if not input.ledger.techniques %}(none yet){% endif %} + + AVOID: {% for f in input.ledger.failed_approaches[-3:] %}{{ f.approach }}; {% endfor %}{% if not input.ledger.failed_approaches %}(none yet){% endif %} + + HUMAN NOTES: {% for n in input.ledger.human_notes[-3:] %}{{ n.note }}; {% endfor %}{% if not input.ledger.human_notes %}(none yet){% endif %} + + user: | + What's the next step toward the goal? Reply with action (work or done) and detail. + + output: + action: + type: str + enum: ["work", "done"] + description: "work to continue, done if goal achieved" + detail: + type: str + description: "What to do next, or why goal is complete" + ledger_update: + type: object + required: false + description: "Optional: new_technique or new_milestone to add" diff --git a/sdk/examples/anything_agent/src/anything_agent/machines/agents/worker.yml b/sdk/examples/anything_agent/src/anything_agent/machines/agents/worker.yml new file mode 100644 index 00000000..ac0c10b8 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/machines/agents/worker.yml @@ -0,0 +1,28 @@ +spec: flatagent +spec_version: "0.9.0" + +data: + name: worker + model: default + + system: | + {{ input.guidance }} + + --- + + You are executing a task toward: {{ input.ledger.goal }} + + Be concise. Execute the task and report results. + + user: | + TASK: {{ input.task }} + + Execute this task. Report what you did and whether it succeeded. + + output: + result: + type: str + description: "What was done and the outcome" + success: + type: bool + description: "Did the task succeed?" diff --git a/sdk/examples/anything_agent/src/anything_agent/machines/core.yml b/sdk/examples/anything_agent/src/anything_agent/machines/core.yml new file mode 100644 index 00000000..5edae71e --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/machines/core.yml @@ -0,0 +1,103 @@ +spec: flatmachine +spec_version: "0.9.0" + +data: + name: core + + context: + session_id: "{{ input.session_id }}" + db_path: "{{ input.db_path }}" + model_token_limit: 200000 + context_target_pct: 0.20 + context_minimum: 12000 + leaf_result: "{{ input.leaf_result | default('') }}" + has_leaf_result: false + + agents: + thinker: ./agents/thinker.yml + worker: ./agents/worker.yml + reflector: ./agents/reflector.yml + + states: + start: + type: initial + transitions: + - to: load_context + + load_context: + action: load_context + transitions: + - condition: "context.has_leaf_result == true" + to: process_leaf + - to: think + + process_leaf: + agent: reflector + input: + leaf_result: "{{ context.leaf_result }}" + ledger: "{{ context.ledger }}" + guidance: "{{ context.guidance }}" + output_to_context: + ledger_update: "{{ output.ledger_update }}" + transitions: + - to: cleanup + + think: + agent: thinker + input: + ledger: "{{ context.ledger }}" + guidance: "{{ context.guidance }}" + token_budget: "{{ context.token_budget }}" + output_to_context: + next_action: "{{ output.action }}" + action_detail: "{{ output.detail }}" + ledger_update: "{{ output.ledger_update | default({}) }}" + transitions: + - condition: "context.next_action == 'done'" + to: save_and_done + - to: work + + work: + agent: worker + input: + task: "{{ context.action_detail }}" + ledger: "{{ context.ledger }}" + guidance: "{{ context.guidance }}" + output_to_context: + work_result: "{{ output.result }}" + work_success: "{{ output.success }}" + transitions: + - to: cleanup + + cleanup: + action: cleanup_context + transitions: + - to: reflect + + reflect: + agent: reflector + input: + work_result: "{{ context.work_result }}" + work_success: "{{ context.work_success }}" + ledger: "{{ context.ledger }}" + guidance: "{{ context.guidance }}" + output_to_context: + ledger_update: "{{ output.ledger_update }}" + transitions: + - to: save_ledger + + save_ledger: + action: save_ledger + transitions: + - to: load_context + + save_and_done: + action: save_ledger + transitions: + - to: done + + done: + type: final + output: + status: "completed" + goal: "{{ context.ledger.goal }}" diff --git a/sdk/examples/anything_agent/src/anything_agent/main.py b/sdk/examples/anything_agent/src/anything_agent/main.py new file mode 100644 index 00000000..7aeb0929 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/main.py @@ -0,0 +1,131 @@ +"""Anything Agent entry point.""" +import argparse +import asyncio +import sqlite3 +import uuid +from datetime import datetime +from pathlib import Path + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + goal TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS ledger ( + session_id TEXT PRIMARY KEY, + goal TEXT NOT NULL, + progress TEXT NOT NULL DEFAULT '[]', + techniques TEXT NOT NULL DEFAULT '[]', + failed_approaches TEXT NOT NULL DEFAULT '[]', + human_notes TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS executions ( + execution_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + parent_id TEXT, + machine_type TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + machine_yaml TEXT NOT NULL, + agent_yamls TEXT NOT NULL DEFAULT '{}', + snapshot TEXT, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + terminated_at TEXT +); +CREATE TABLE IF NOT EXISTS pending_approvals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + session_id TEXT NOT NULL, + state_name TEXT NOT NULL, + context_json TEXT NOT NULL, + proposed_transition TEXT NOT NULL, + status TEXT NOT NULL, + human_note TEXT, + created_at TEXT NOT NULL, + responded_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_exec_session ON executions(session_id); +CREATE INDEX IF NOT EXISTS idx_pending ON pending_approvals(status); +""" + +def init_db(db_path: str): + conn = sqlite3.connect(db_path) + conn.executescript(SCHEMA) + conn.commit() + conn.close() + +def start_goal(goal: str, db_path: str): + """Start new session with goal.""" + session_id = str(uuid.uuid4()) + execution_id = str(uuid.uuid4()) + now = datetime.now().isoformat() + + machine_path = Path(__file__).parent / "machines" / "core.yml" + machine_yaml = machine_path.read_text() + + conn = sqlite3.connect(db_path) + conn.execute( + "INSERT INTO sessions (session_id, goal, status, created_at, updated_at) VALUES (?, ?, 'active', ?, ?)", + (session_id, goal, now, now) + ) + conn.execute( + "INSERT INTO ledger (session_id, goal, updated_at) VALUES (?, ?, ?)", + (session_id, goal, now) + ) + conn.execute( + "INSERT INTO executions (execution_id, session_id, machine_type, tags, machine_yaml, status, created_at) VALUES (?, ?, 'core', '[\"initial\"]', ?, 'pending', ?)", + (execution_id, session_id, machine_yaml, now) + ) + conn.commit() + conn.close() + + print(f"🚀 Session {session_id[:8]}... created") + print(f" Goal: {goal}") + print(f" Run './run.sh observe' to approve transitions") + +def list_sessions(db_path: str): + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM sessions ORDER BY created_at DESC LIMIT 20") + rows = cursor.fetchall() + conn.close() + + if not rows: + print("No sessions yet.") + return + + print(f"{'Session':<12} {'Status':<10} {'Goal'}") + print("-" * 60) + for r in rows: + print(f"{r['session_id'][:10]}.. {r['status']:<10} {r['goal'][:40]}") + +def main(): + parser = argparse.ArgumentParser(description="Anything Agent") + parser.add_argument("command", choices=["run", "resume", "list", "observe"]) + parser.add_argument("--goal", help="Goal for new run") + parser.add_argument("--session", help="Session ID to resume") + parser.add_argument("--db", default="./anything_agent.db") + args = parser.parse_args() + + init_db(args.db) + + if args.command == "run": + if not args.goal: + parser.error("--goal required") + start_goal(args.goal, args.db) + elif args.command == "resume": + if not args.session: + parser.error("--session required") + print(f"Resuming {args.session}...") + elif args.command == "list": + list_sessions(args.db) + elif args.command == "observe": + from .observer import run_loop + asyncio.run(run_loop(args.db)) + +if __name__ == "__main__": + main() diff --git a/sdk/examples/anything_agent/src/anything_agent/observer.py b/sdk/examples/anything_agent/src/anything_agent/observer.py new file mode 100644 index 00000000..d1187a5b --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/observer.py @@ -0,0 +1,153 @@ +"""CLI observer for human approval.""" +import sqlite3 +import json +import asyncio +from datetime import datetime +from pathlib import Path + +import yaml +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import HTML + +from flatagents import FlatMachine +from .hooks import AnythingAgentHooks, AwaitingApproval + +session = PromptSession() + +async def run_loop(db_path: str): + """Main observer loop.""" + print(f"🔍 Anything Agent Observer") + print(f"📁 Database: {db_path}") + print("Commands: [a]pprove, [n]ote, [s]top, [q]uit") + print("-" * 60) + + while True: + pending = get_pending_approvals(db_path) + + if not pending: + pending_exec = get_pending_execution(db_path) + if pending_exec: + print(f"\n⏳ Starting {pending_exec['execution_id'][:8]}...") + await run_execution(pending_exec, db_path) + else: + await asyncio.sleep(1) + continue + + approval = pending[0] + display_approval(approval) + + try: + response = await session.prompt_async(HTML('Decision [a/n/s/q]: ')) + except (EOFError, KeyboardInterrupt): + print("\n👋 Goodbye") + break + + response = response.strip().lower() + + if response in ('a', 'approve', ''): + approve(db_path, approval['id'], approval['session_id']) + print("✅ Approved") + await restore_and_continue(approval, db_path) + elif response in ('n', 'note'): + note = await session.prompt_async(HTML('Note: ')) + approve(db_path, approval['id'], approval['session_id'], note.strip()) + print(f"✅ Approved with note") + await restore_and_continue(approval, db_path) + elif response in ('s', 'stop'): + stop(db_path, approval['id']) + print("🛑 Stopped") + elif response in ('q', 'quit'): + print("👋 Goodbye") + break + +async def restore_and_continue(approval: dict, db_path: str): + """Restore from snapshot and continue.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM executions WHERE execution_id = ?", (approval['execution_id'],)) + row = cursor.fetchone() + conn.close() + + if row: + await run_execution(dict(row), db_path) + +async def run_execution(row: dict, db_path: str): + """Run machine from execution row.""" + execution_id = row['execution_id'] + session_id = row['session_id'] + snapshot = json.loads(row['snapshot']) if row.get('snapshot') else None + + conn = sqlite3.connect(db_path) + conn.execute("UPDATE executions SET status = 'running' WHERE execution_id = ?", (execution_id,)) + conn.commit() + conn.close() + + hooks = AnythingAgentHooks(db_path, session_id, execution_id) + + # Use file path for proper relative path resolution + machine_path = Path(__file__).parent / "machines" / "core.yml" + profiles_path = Path(__file__).parent.parent.parent / "config" / "profiles.yml" + machine = FlatMachine(config_file=str(machine_path), hooks=hooks, profiles_file=str(profiles_path)) + + try: + # If we have a snapshot with context, use it as input + input_data = {"session_id": session_id, "db_path": db_path} + if snapshot and 'context' in snapshot: + input_data.update(snapshot['context']) + + result = await machine.execute(input=input_data) + print(f"✅ Complete: {result}") + conn = sqlite3.connect(db_path) + conn.execute("UPDATE executions SET status = 'terminated' WHERE execution_id = ?", (execution_id,)) + conn.commit() + conn.close() + except AwaitingApproval as e: + print(f"⏸️ Paused: {e.from_state} → {e.to_state}") + +def display_approval(approval: dict): + print(f"\n{'═' * 60}") + print(f"📋 Execution: {approval['execution_id'][:8]}...") + print(f" Transition: {approval['state_name']} → {approval['proposed_transition']}") + context = json.loads(approval['context_json']) + if 'ledger' in context and 'goal' in context['ledger']: + print(f" Goal: {context['ledger']['goal'][:50]}") + print('═' * 60) + +def get_pending_approvals(db_path: str) -> list: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM pending_approvals WHERE status = 'pending' ORDER BY created_at LIMIT 1") + result = [dict(r) for r in cursor.fetchall()] + conn.close() + return result + +def get_pending_execution(db_path: str) -> dict: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM executions WHERE status = 'pending' ORDER BY created_at LIMIT 1") + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + +def approve(db_path: str, approval_id: int, session_id: str, note: str = None): + conn = sqlite3.connect(db_path) + now = datetime.now().isoformat() + conn.execute("UPDATE pending_approvals SET status = 'approved', human_note = ?, responded_at = ? WHERE id = ?", + (note, now, approval_id)) + if note: + cursor = conn.execute("SELECT human_notes FROM ledger WHERE session_id = ?", (session_id,)) + notes = json.loads(cursor.fetchone()[0] or "[]") + notes.append({"timestamp": now, "note": note}) + conn.execute("UPDATE ledger SET human_notes = ?, updated_at = ? WHERE session_id = ?", + (json.dumps(notes), now, session_id)) + conn.commit() + conn.close() + +def stop(db_path: str, approval_id: int): + conn = sqlite3.connect(db_path) + conn.execute("UPDATE pending_approvals SET status = 'stopped' WHERE id = ?", (approval_id,)) + cursor = conn.execute("SELECT execution_id FROM pending_approvals WHERE id = ?", (approval_id,)) + exec_id = cursor.fetchone()[0] + conn.execute("UPDATE executions SET status = 'suspended' WHERE execution_id = ?", (exec_id,)) + conn.commit() + conn.close() From 28ea147992575522bc5a42ee683b0be4309f3089 Mon Sep 17 00:00:00 2001 From: memgrafter Date: Sat, 31 Jan 2026 14:50:48 -0800 Subject: [PATCH 2/8] Completed manual run of anything agent. --- sdk/examples/anything_agent/anything_agent.db | Bin 81920 -> 102400 bytes .../anything_agent/src/anything_agent/main.py | 82 ++++++++- .../src/anything_agent/observer.py | 168 ++++++++++++++++-- 3 files changed, 234 insertions(+), 16 deletions(-) diff --git a/sdk/examples/anything_agent/anything_agent.db b/sdk/examples/anything_agent/anything_agent.db index 66ee442ba2c4f187e1125c437af5afb367681d53..e2771a99b2c49b2f798496401e36cac30b14304d 100644 GIT binary patch delta 4644 zcmeH~Z)g)|9LMj{rfJhZkKOFju2UatwVfv6{w8;k&2{3tf=tcaETx{gr_IsJUAVin zcH&Y|d@}~E5vy-zVWU+B>q=fI+nWl)UQGwv3lWCQH+wTS24gVxV*A}C#iXm+)xsE< zaK}A){`~$t-{j=-MyC%FCPB{)%#G0LenTzY~9p|@+W%PF$!V>!_G>`T}P;sQ5@YN^9R`{$ttbxJOx7z*6zzXyG z1~hTT;2K*R3EGhuq*GQPak*OE+9Z9C=$s=nU`7lTwBAk`<>LrNlsp?|Esw_LWj#aulAb(elgKF zgd>R4d+J@kJ7>4;m{9_B|BdS*`e=R76&vlpP5EyZ+u#1epT8(KdEw3N?b*oPanhPG zy58M6M&Ed&gYNflUJcrXPk$cs@ytvsqT8F}VceCoiM#iFym0*P{``?L;DPlnuI^yUUreFunc(Mq!k03P`E7D5z~UeNJ@dIvF2dMQpSi5Zoss(X*qdfg|VFh12B9X z`e5~=X-y}1Oaq^t)vS;)VtA&)IGxLYF*4x>2BtmxHZg3?EtGB^y_}XjbtCu6X%1#& zG{?<~C6dY^p6eyUVVqG$A?u|nP=Nt7M6tZl(ZcKlsTU5UnBdqb6OYNfSS@TpN{DP! z;$@NH=NNiZNydSjRN&^~na=|GsRNC!ig$jL3~i)}byHD?Ql{aMlTSoV`{Uj^05$6u zb9>=~(=T(77LP`vB*&PQX%XF55{~C&QSuIU;4Bq5TfBAaOaDx=ht}(1kA_s!ATwX} z&|AA#q2p?Fsi*T(?=^c=xO%W{BlGzmQDJvM`Kt{y7Ah?L((Wb{)Fee2PL^Ww3Z2Q2 z?WiE~d`u{ja*6UU72o=NpTF?U4^4%u1EcwiueG?j>6tsQp}Dc)unsNn*AvLu@z__~ zXA;{Za+VHeH9i##G&IZ%Tn0|LbWvAFY9Dau=~NB7=vqP5d=*xE0lE%n{&| zOB?g#1U$a#J(sx(csuNJuS`3g7$Z*F#^!Jdbil$~D(%wI1DCtVGLBrnl^1bIo6EyE zk9XsMda&%VOE-;?6-(>dD1m~ZdR|NrT{p)8;AJA4IcvT2Oq8rw0Je&r;{<1kl_DO8 z%QdbXmWxl2_T6?U{{jd&7L^&96ZvXsi%f!*qoTw!vJCG4-si?NOZV2*{%b<4pJ~r! z_WzdIuG3b|FrbcCi>va_rK_w0JFvYNc_-?hc@U%TKNr|@jxA&NX&l>m8d#pu48fIzgIKl7WFi1c-$vs#r5hZcJFh&&J3c;Ni-G(b4E#4X z3pSkNXHjPk<($0HUS*O23s8JH1OIZMy1D$5m)pnUR>vF7z#F}>@hY?63^_)=&`<_Z z<>mf@ZROIA4AT?E89g>{iS=OGEdSv)+ve!ll}s20ffT7sDqs;|<9`HnDuWRJZ~kxm zANk+#Kj(k6v9W@6^Mq16uF2>2i)?;WFDt;!!39)=)zu4FL^$|oGVp%^>b%8&mj3|% zX8vXTGl70+_OWL6&QnAH#Z#p%E&!Yn}dfTG*o(lyz=x! zc1EA=E5#YZIXFO$0fFg;sf-Gn9X?2~Pu~#A*tmV4Iinm0dr@g#US?kU str | None: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT session_id FROM sessions WHERE session_id = ? OR session_id LIKE ? ORDER BY created_at DESC", + (session_id, f"{session_id}%") + ) + rows = [r["session_id"] for r in cursor.fetchall()] + conn.close() + + if not rows: + print(f"❌ No session matches '{session_id}'.") + return None + + if session_id in rows: + return session_id + + if len(rows) > 1: + matches = ", ".join(r[:8] for r in rows[:5]) + suffix = "..." if len(rows) > 5 else "" + print(f"❌ Session id '{session_id}' is ambiguous: {matches}{suffix}") + return None + + return rows[0] + +def resume_session(session_id: str, db_path: str) -> str | None: + resolved = resolve_session_id(db_path, session_id) + if not resolved: + return None + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT execution_id, status FROM executions WHERE session_id = ? AND status != 'terminated' ORDER BY created_at DESC LIMIT 1", + (resolved,) + ) + row = cursor.fetchone() + + if not row: + cursor = conn.execute( + "SELECT execution_id, status FROM executions WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", + (resolved,) + ) + last = cursor.fetchone() + conn.close() + if last: + print(f"ℹ️ Session {resolved[:8]}... already completed (last execution terminated).") + else: + print(f"❌ No executions found for session {resolved[:8]}...") + return None + + execution_id = row["execution_id"] + status = row["status"] + now = datetime.now().isoformat() + + if status in ("suspended", "running"): + conn.execute("UPDATE executions SET status = 'pending' WHERE execution_id = ?", (execution_id,)) + action = "requeued" + elif status == "pending": + action = "already pending" + else: + action = f"status={status}" + + conn.execute( + "UPDATE sessions SET status = 'active', updated_at = ? WHERE session_id = ?", + (now, resolved) + ) + conn.commit() + conn.close() + + print(f"▶️ Session {resolved[:8]}... {action}") + return resolved + def list_sessions(db_path: str): conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row @@ -98,10 +171,10 @@ def list_sessions(db_path: str): print("No sessions yet.") return - print(f"{'Session':<12} {'Status':<10} {'Goal'}") + print(f"{'Session':<40} {'Status':<20} {'Goal'}") print("-" * 60) for r in rows: - print(f"{r['session_id'][:10]}.. {r['status']:<10} {r['goal'][:40]}") + print(f"{r['session_id']:<40}.. {r['status']:<20} {r['goal'][:40]}") def main(): parser = argparse.ArgumentParser(description="Anything Agent") @@ -120,7 +193,10 @@ def main(): elif args.command == "resume": if not args.session: parser.error("--session required") - print(f"Resuming {args.session}...") + from .observer import run_loop + resolved = resume_session(args.session, args.db) + if resolved: + asyncio.run(run_loop(args.db, resolved)) elif args.command == "list": list_sessions(args.db) elif args.command == "observe": diff --git a/sdk/examples/anything_agent/src/anything_agent/observer.py b/sdk/examples/anything_agent/src/anything_agent/observer.py index d1187a5b..96050b8c 100644 --- a/sdk/examples/anything_agent/src/anything_agent/observer.py +++ b/sdk/examples/anything_agent/src/anything_agent/observer.py @@ -14,30 +14,60 @@ session = PromptSession() -async def run_loop(db_path: str): +async def run_loop(db_path: str, session_id: str | None = None): """Main observer loop.""" print(f"🔍 Anything Agent Observer") print(f"📁 Database: {db_path}") - print("Commands: [a]pprove, [n]ote, [s]top, [q]uit") + if session_id: + print(f"🎯 Session: {session_id[:8]}...") + print("Commands: [a]pprove, [n]ote (approve + add note), [s]top, [q]uit") + print("Idle: [l]ist stopped, [r]eopen stopped, [q]uit") print("-" * 60) while True: - pending = get_pending_approvals(db_path) + pending = get_pending_approvals(db_path, session_id) if not pending: - pending_exec = get_pending_execution(db_path) + pending_exec = get_pending_execution(db_path, session_id) if pending_exec: print(f"\n⏳ Starting {pending_exec['execution_id'][:8]}...") await run_execution(pending_exec, db_path) - else: - await asyncio.sleep(1) + continue + + try: + response = await session.prompt_async(HTML('Idle [l]ist/[r]eopen/[q]uit: ')) + except (EOFError, KeyboardInterrupt): + print("\n👋 Goodbye") + break + + response = response.strip().lower() + if not response: + continue + + parts = response.split() + command = parts[0] + arg = parts[1] if len(parts) > 1 else None + + if command in ('q', 'quit'): + print("👋 Goodbye") + break + if command in ('l', 'list'): + list_stopped_approvals(db_path, session_id) + continue + if command in ('r', 'resume', 'reopen', 'unstop'): + reopened = reopen_stopped_approval(db_path, session_id, arg) + if reopened: + print(f"♻️ Reopened approval {reopened['id']} for {reopened['execution_id'][:8]}...") + continue + + print("❓ Unknown command. Use [l]ist, [r]eopen, or [q]uit.") continue approval = pending[0] - display_approval(approval) + display_approval(approval, db_path) try: - response = await session.prompt_async(HTML('Decision [a/n/s/q]: ')) + response = await session.prompt_async(HTML('Decision [a]pprove/[n]ote (approve + note)/[s]top/[q]uit: ')) except (EOFError, KeyboardInterrupt): print("\n👋 Goodbye") break @@ -104,27 +134,76 @@ async def run_execution(row: dict, db_path: str): except AwaitingApproval as e: print(f"⏸️ Paused: {e.from_state} → {e.to_state}") -def display_approval(approval: dict): +def display_approval(approval: dict, db_path: str): print(f"\n{'═' * 60}") print(f"📋 Execution: {approval['execution_id'][:8]}...") print(f" Transition: {approval['state_name']} → {approval['proposed_transition']}") + context = json.loads(approval['context_json']) if 'ledger' in context and 'goal' in context['ledger']: print(f" Goal: {context['ledger']['goal'][:50]}") + + machine_yaml = None + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT machine_yaml FROM executions WHERE execution_id = ?", + (approval['execution_id'],) + ) + row = cursor.fetchone() + conn.close() + if row and row["machine_yaml"]: + machine_yaml = row["machine_yaml"] + + if machine_yaml: + machine = yaml.safe_load(machine_yaml) + data = (machine or {}).get("data", {}) + states = data.get("states", {}) + from_state = states.get(approval["state_name"], {}) + to_state = states.get(approval["proposed_transition"], {}) + + print("\n🧠 Machine data:") + machine_header = { + "name": data.get("name"), + "context": data.get("context"), + "agents": data.get("agents"), + } + print(yaml.safe_dump(machine_header, sort_keys=False).strip()) + + print("\n➡️ From state definition:") + print(yaml.safe_dump(from_state or {"missing": True}, sort_keys=False).strip()) + + print("\n✅ To state definition:") + print(yaml.safe_dump(to_state or {"missing": True}, sort_keys=False).strip()) + + print("\n📦 Context snapshot:") + print(json.dumps(context, indent=2, sort_keys=True)) print('═' * 60) -def get_pending_approvals(db_path: str) -> list: +def get_pending_approvals(db_path: str, session_id: str | None = None) -> list: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row - cursor = conn.execute("SELECT * FROM pending_approvals WHERE status = 'pending' ORDER BY created_at LIMIT 1") + if session_id: + cursor = conn.execute( + "SELECT * FROM pending_approvals WHERE status = 'pending' AND session_id = ? ORDER BY created_at LIMIT 1", + (session_id,) + ) + else: + cursor = conn.execute("SELECT * FROM pending_approvals WHERE status = 'pending' ORDER BY created_at LIMIT 1") result = [dict(r) for r in cursor.fetchall()] conn.close() return result -def get_pending_execution(db_path: str) -> dict: +def get_pending_execution(db_path: str, session_id: str | None = None) -> dict: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row - cursor = conn.execute("SELECT * FROM executions WHERE status = 'pending' ORDER BY created_at LIMIT 1") + if session_id: + cursor = conn.execute( + "SELECT * FROM executions WHERE status = 'pending' AND session_id = ? ORDER BY created_at LIMIT 1", + (session_id,) + ) + else: + cursor = conn.execute("SELECT * FROM executions WHERE status = 'pending' ORDER BY created_at LIMIT 1") row = cursor.fetchone() conn.close() return dict(row) if row else None @@ -151,3 +230,66 @@ def stop(db_path: str, approval_id: int): conn.execute("UPDATE executions SET status = 'suspended' WHERE execution_id = ?", (exec_id,)) conn.commit() conn.close() + +def get_stopped_approvals(db_path: str, session_id: str | None = None) -> list: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + if session_id: + cursor = conn.execute( + "SELECT * FROM pending_approvals WHERE status = 'stopped' AND session_id = ? ORDER BY created_at DESC", + (session_id,) + ) + else: + cursor = conn.execute("SELECT * FROM pending_approvals WHERE status = 'stopped' ORDER BY created_at DESC") + result = [dict(r) for r in cursor.fetchall()] + conn.close() + return result + +def list_stopped_approvals(db_path: str, session_id: str | None = None): + stopped = get_stopped_approvals(db_path, session_id) + if not stopped: + print("ℹ️ No stopped approvals.") + return + + print("\n🧯 Stopped approvals:") + for approval in stopped[:10]: + print( + f" {approval['id']:>4} {approval['execution_id'][:8]}... " + f"{approval['state_name']} → {approval['proposed_transition']}" + ) + if len(stopped) > 10: + print(f" ... and {len(stopped) - 10} more") + +def reopen_stopped_approval(db_path: str, session_id: str | None = None, approval_id: str | None = None) -> dict | None: + stopped = get_stopped_approvals(db_path, session_id) + if not stopped: + print("ℹ️ No stopped approvals to reopen.") + return None + + target = None + if approval_id: + try: + approval_id_int = int(approval_id) + except ValueError: + print("❌ Approval id must be a number.") + return None + + for approval in stopped: + if approval["id"] == approval_id_int: + target = approval + break + if not target: + print(f"❌ No stopped approval with id {approval_id_int}.") + return None + else: + target = stopped[0] + + conn = sqlite3.connect(db_path) + conn.execute( + "UPDATE pending_approvals SET status = 'pending', responded_at = NULL, human_note = NULL WHERE id = ?", + (target["id"],) + ) + conn.execute("UPDATE executions SET status = 'pending' WHERE execution_id = ?", (target["execution_id"],)) + conn.commit() + conn.close() + return target From 5f14497f19c1ec2e3338de37cfa2d3c43debdf66 Mon Sep 17 00:00:00 2001 From: memgrafter Date: Sat, 31 Jan 2026 15:32:01 -0800 Subject: [PATCH 3/8] Fix runners. --- sdk/examples/anything_agent/anything_agent.db | Bin 102400 -> 139264 bytes sdk/examples/anything_agent/run.sh | 32 +++++++- .../src/anything_agent/hooks.py | 10 ++- .../src/anything_agent/observer.py | 72 ++++++++++++++++-- 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/sdk/examples/anything_agent/anything_agent.db b/sdk/examples/anything_agent/anything_agent.db index e2771a99b2c49b2f798496401e36cac30b14304d..938a1245c54491fb14ad0984c9e6164e18d18b3e 100644 GIT binary patch delta 5639 zcmds5e`p)m9lxhvPFz_}iY+URlj!24`C(g^`>i{{mKv`kqYj}VTIL0lobH}%wep>& zlN^^VlU+K)7=t=WxorO|WNTas!7=L4T3G8rO35E<3xQG!q3Iy(pF!KO_MZ*1_f82_ z6t`7D+el>e%-wr>-yfgvkI#Ga=L7R=8(tXbd4i&-LHu<;OM8>xiPe*TfI(l+D->LU zvCyTSS89>)Z+j}wQNFJ9b7PN=jwX3tM@Z2UNKyHOD9c77Ba2c(XVr`<>a3p08V9XY zv|%BeE=?nPR?FuMt(3D&`jl0ChR#|={61&Xn)xjLZ%)zL6f#RTjpnqOLLMEUe^A~RV-;{2`|aQi>H)ti*8mB`dBwQ*o!Jy@7NTyFkk(MOo1K z1g9sH2{FTE6Pm`d2{y}SvPd`dq|8lOT7F$oY>LOCB%YUq7Ew(zbDlt2BF>gMB`ai+ zqwEG-t7GxM1buLlf|GCsF2WCL5&96UuI%?8Y6BDBUT+$-qq!IFm=5|v9ttkQvCtuou*nr``x{!Ed&B@vzy` zjmJAdhC>XlxIuP=1P`ZYGD$;G5=mLn6Jk;@5^7SG6NaFunl9-^hR?WB#%m&{YXVLP z(a;hi$EpcM*YpI(=}3?Sq=`ZjN12u08-|y$IBEw`27U(r0sjvF2CrkW*Wf$wD!c;U zgs;Ot!po}*hQIbgneN_>SJP2E#QX5Dy%!H#H{oGh1P`%r?VSo2j`TO#@ZU|8-=*39 z&`k>d6D$59{3~38HFy#J2F^npX5j=JhXULUABDlt?a-~zP55j0I8LV3P<4DIc)UO8 z-3A(k3mMVohrZ^A-sXo*%@2|0hwvlb0brM`LID}DHoo$w%iUg3ru=K(mqED(Z%lz> z&$Hnml}^VNhacNUjgGd1XFJv2S&D-Goo$q$`r(7%$Dmq&b4SIkxuU>`GOJ2*OB%{4 zkz-g+;5hdAD7>rk05wXr6It!lIHGcNS34{3>Rjn@xESaSVa3^$z%f#iWmUGmhQQ<9 z!5$oZo{d9P&+&~z)ukV8t;Yw!OWxZ2TmJ_2t7V{hYeN(72QJQoa5q^Byw@r3_0@%I z-idRxHf}LBE&i$6dHEZxhRopQF!h_chrzFiLeQ;O^SS`6pif-j_0#$2usZ z`s(DLCoVEy?*98&2X}WZsK!4%8^Yl*=z7k-O!=4VOEeJt)%mxcuAKehCO7hzyP};; zR>HWR`RSmIY#TGO6FFlrMGvl%<&pbH9m#}?fl9}@-M8!NTfdImIqYdL>ybq#0zoV! zDI@e?*+#@fNvWQEceo4X(Uev~Rqb{Zj5{Ac0{SX+$#R(|=7tT9*}_V7%Rm{;Mk6%N z06mw@=~_PjEM~EK*(T)GGE+3Sk3M=Y)15*3v}WdPLPIfcK_+IsIwr*Tveglquk52; zk2W+TBpK^-KW(*L?re|H!an*K*|AVWvo32U)R>`F`oXJQ-h_~Z12iJg~ zSI!Le65yg2h6g$c9MPnW%9IJW9ycHS$~5GhJpqQRFWw#_U@8Q%WRjQh_ug}=l9FVG z7e!tc8em?b0#|A$k6!gvZ`|mu&42y@AgBh8ol*M;L<1MbItr0lP$Z z7VG8)F!(^Xj3MVF1akxw72gstlE_O+>lJ~T;_-|uh%(mzWsM4~Ijc4puCKA+SAoiz z??mr_v9pu;QbgH2F4^ChGQI2E&^%1=>~}650F3im9K;(y@hYRLEGM>HuyAaO6&aC} zSS8s2s!j#!)t}tlLvUKeIDNmL;I#9?lN~)D+1e~Rkw?Z9Dt;|iv9GvIdq}WqW?y~I z9A;<@+-~&&dPGWLbU2<-WnK_lsve%<6-MP1P8Bd+rfQMZim&?K$HCfnKi}e6|9I1O aH+u+b)cTIyzoS?-O-uz_T1e9!*M9&tWSQjv delta 375 zcmZoTz|pXPZGtqf4g&*&BoIpgG2=uHb4Hzw2}}4nnE1sR_$Ts#^zM)MDU`=G5BQsKdC~B5)tybb|y&HUXe>M7V$i*rFT!>!B9qhstpAp96|B zi1YvE|Hl83{|*0h{zv?G`ET%F=0CTwv4C~+gi-~r$s6{|Z9Z3TD8S1HHwEF``JoDY z{5}l)Ux0dV@t@^Cz`vP)8UIZF2L59HH2z3_AO2pTH#GP+`>fxwfsK*%00ZlR&4C;2 zStnn3$TPiPgeiObfmB8ZE)I}GKw$esLq-j@?FMCxznC^}c=d^S`@bB" + exit 1 + fi + .venv/bin/python -m anything_agent.main resume --session "$session_id" ;; list) .venv/bin/python -m anything_agent.main list @@ -42,6 +60,14 @@ case ${1:-observe} in "SELECT substr(session_id,1,8) as id, status, substr(goal,1,40) as goal FROM sessions ORDER BY created_at DESC LIMIT 5" 2>/dev/null || echo "No database yet" ;; *) - echo "Usage: ./run.sh [run 'goal'|resume |list|observe|status]" + goal="$*" + if [[ -n "$goal" ]]; then + .venv/bin/python -m anything_agent.main run --goal "$goal" + exit 0 + fi + echo "Usage: ./run.sh run \"goal\"" + echo " ./run.sh \"goal\" # shorthand" + echo " ./run.sh resume " + echo " ./run.sh list | observe | status" ;; esac diff --git a/sdk/examples/anything_agent/src/anything_agent/hooks.py b/sdk/examples/anything_agent/src/anything_agent/hooks.py index d92a34f2..b67984a5 100644 --- a/sdk/examples/anything_agent/src/anything_agent/hooks.py +++ b/sdk/examples/anything_agent/src/anything_agent/hooks.py @@ -28,17 +28,19 @@ def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) # Check if this exact transition was already approved cursor = conn.execute( - "SELECT status FROM pending_approvals WHERE execution_id = ? AND state_name = ? AND proposed_transition = ? ORDER BY id DESC LIMIT 1", + "SELECT id, status FROM pending_approvals WHERE execution_id = ? AND state_name = ? AND proposed_transition = ? ORDER BY id DESC LIMIT 1", (self.execution_id, from_state, to_state) ) row = cursor.fetchone() - if row and row[0] == 'approved': - # Already approved, continue + if row and row[1] == 'approved': + # Consume approval so loops require fresh approval next time + conn.execute("UPDATE pending_approvals SET status = 'consumed' WHERE id = ?", (row[0],)) + conn.commit() conn.close() return to_state - if row and row[0] == 'pending': + if row and row[1] == 'pending': # Already pending, just wait conn.close() raise AwaitingApproval(self.execution_id, from_state, to_state) diff --git a/sdk/examples/anything_agent/src/anything_agent/observer.py b/sdk/examples/anything_agent/src/anything_agent/observer.py index 96050b8c..8c30b804 100644 --- a/sdk/examples/anything_agent/src/anything_agent/observer.py +++ b/sdk/examples/anything_agent/src/anything_agent/observer.py @@ -117,14 +117,29 @@ async def run_execution(row: dict, db_path: str): # Use file path for proper relative path resolution machine_path = Path(__file__).parent / "machines" / "core.yml" profiles_path = Path(__file__).parent.parent.parent / "config" / "profiles.yml" - machine = FlatMachine(config_file=str(machine_path), hooks=hooks, profiles_file=str(profiles_path)) - - try: - # If we have a snapshot with context, use it as input - input_data = {"session_id": session_id, "db_path": db_path} + + machine = None + input_data = {"session_id": session_id, "db_path": db_path} + + if snapshot and snapshot.get("state") and snapshot.get("context"): + machine_yaml = row.get("machine_yaml") + if not machine_yaml: + machine_yaml = machine_path.read_text() + machine_config = yaml.safe_load(machine_yaml) or {} + machine_config = _apply_snapshot(machine_config, snapshot) + machine = FlatMachine( + config_dict=machine_config, + hooks=hooks, + profiles_file=str(profiles_path), + _config_dir=str(machine_path.parent), + ) + input_data = {} + else: + machine = FlatMachine(config_file=str(machine_path), hooks=hooks, profiles_file=str(profiles_path)) if snapshot and 'context' in snapshot: input_data.update(snapshot['context']) - + + try: result = await machine.execute(input=input_data) print(f"✅ Complete: {result}") conn = sqlite3.connect(db_path) @@ -134,6 +149,23 @@ async def run_execution(row: dict, db_path: str): except AwaitingApproval as e: print(f"⏸️ Paused: {e.from_state} → {e.to_state}") +def _apply_snapshot(machine_config: dict, snapshot: dict) -> dict: + data = machine_config.get("data", {}) + states = data.get("states", {}) + target_state = snapshot.get("state") + + if target_state and target_state in states: + for state_name, state in states.items(): + if state.get("type") == "initial": + state.pop("type", None) + states[target_state]["type"] = "initial" + else: + print(f"⚠️ Snapshot state '{target_state}' not found. Starting from default initial state.") + + data["context"] = snapshot.get("context", {}) + machine_config["data"] = data + return machine_config + def display_approval(approval: dict, db_path: str): print(f"\n{'═' * 60}") print(f"📋 Execution: {approval['execution_id'][:8]}...") @@ -176,6 +208,13 @@ def display_approval(approval: dict, db_path: str): print("\n✅ To state definition:") print(yaml.safe_dump(to_state or {"missing": True}, sort_keys=False).strip()) + session_id = approval.get("session_id") + if session_id: + ledger = fetch_ledger(db_path, session_id) + if ledger: + print("\n🧾 Ledger (db):") + print(yaml.safe_dump(ledger, sort_keys=False).strip()) + print("\n📦 Context snapshot:") print(json.dumps(context, indent=2, sort_keys=True)) print('═' * 60) @@ -231,6 +270,27 @@ def stop(db_path: str, approval_id: int): conn.commit() conn.close() +def fetch_ledger(db_path: str, session_id: str) -> dict | None: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT goal, progress, techniques, failed_approaches, human_notes FROM ledger WHERE session_id = ?", + (session_id,) + ) + row = cursor.fetchone() + conn.close() + + if not row: + return None + + return { + "goal": row["goal"], + "progress": json.loads(row["progress"] or "[]"), + "techniques": json.loads(row["techniques"] or "[]"), + "failed_approaches": json.loads(row["failed_approaches"] or "[]"), + "human_notes": json.loads(row["human_notes"] or "[]"), + } + def get_stopped_approvals(db_path: str, session_id: str | None = None) -> list: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row From 082926f19ca7ab6b7f6a3ab59bd7ee164b00d7ec Mon Sep 17 00:00:00 2001 From: memgrafter Date: Sat, 31 Jan 2026 16:20:41 -0800 Subject: [PATCH 4/8] Add untested subprocess launch. --- sdk/examples/anything_agent/anything_agent.db | Bin 139264 -> 212992 bytes .../src/anything_agent/execution.py | 40 +++++ .../src/anything_agent/hooks.py | 72 +++++++++ .../src/anything_agent/invoker.py | 147 ++++++++++++++++++ .../src/anything_agent/observer.py | 135 ++++++---------- .../src/anything_agent/runner.py | 127 +++++++++++++++ 6 files changed, 436 insertions(+), 85 deletions(-) create mode 100644 sdk/examples/anything_agent/src/anything_agent/execution.py create mode 100644 sdk/examples/anything_agent/src/anything_agent/invoker.py create mode 100644 sdk/examples/anything_agent/src/anything_agent/runner.py diff --git a/sdk/examples/anything_agent/anything_agent.db b/sdk/examples/anything_agent/anything_agent.db index 938a1245c54491fb14ad0984c9e6164e18d18b3e..7ac5c6d72c09aa860430d71e1e638dcd2dd0a7cb 100644 GIT binary patch delta 6878 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 diff --git a/sdk/examples/anything_agent/src/anything_agent/execution.py b/sdk/examples/anything_agent/src/anything_agent/execution.py new file mode 100644 index 00000000..45ea00b9 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/execution.py @@ -0,0 +1,40 @@ +"""Execution helpers for Anything Agent.""" +from __future__ import annotations + +import json +from typing import Any + +import yaml + + +def load_machine_config(machine_yaml: str) -> dict: + """Load machine config from YAML/JSON string.""" + config = yaml.safe_load(machine_yaml) or {} + if not isinstance(config, dict): + raise ValueError("Machine config must be a mapping") + return config + + +def apply_snapshot(machine_config: dict, snapshot: dict) -> dict: + """Apply snapshot state/context to machine config for resume.""" + data = machine_config.get("data", {}) + states = data.get("states", {}) + target_state = snapshot.get("state") + + if target_state and target_state in states: + for state_name, state in states.items(): + if state.get("type") == "initial": + state.pop("type", None) + states[target_state]["type"] = "initial" + elif target_state: + # Fallback to default initial state if snapshot state is missing + pass + + data["context"] = snapshot.get("context", {}) + machine_config["data"] = data + return machine_config + + +def serialize_snapshot(output: Any | None = None) -> str: + payload = {"state": "done", "output": output} + return json.dumps(payload) diff --git a/sdk/examples/anything_agent/src/anything_agent/hooks.py b/sdk/examples/anything_agent/src/anything_agent/hooks.py index b67984a5..d5596a76 100644 --- a/sdk/examples/anything_agent/src/anything_agent/hooks.py +++ b/sdk/examples/anything_agent/src/anything_agent/hooks.py @@ -2,6 +2,10 @@ import sqlite3 import json import uuid +import subprocess +import io +import contextlib +import traceback from datetime import datetime from typing import Dict, Any from flatagents import MachineHooks @@ -42,6 +46,11 @@ def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) if row and row[1] == 'pending': # Already pending, just wait + conn.execute( + "UPDATE executions SET status = 'suspended' WHERE execution_id = ?", + (self.execution_id,) + ) + conn.commit() conn.close() raise AwaitingApproval(self.execution_id, from_state, to_state) @@ -62,6 +71,10 @@ def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) "INSERT INTO pending_approvals (execution_id, session_id, state_name, context_json, proposed_transition, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', ?)", (self.execution_id, self.session_id, from_state, json.dumps(ctx_copy), to_state, now) ) + conn.execute( + "UPDATE executions SET status = 'suspended' WHERE execution_id = ?", + (self.execution_id,) + ) conn.commit() conn.close() raise AwaitingApproval(self.execution_id, from_state, to_state) @@ -73,6 +86,10 @@ def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str, Any] return self._cleanup_context(context) elif action_name == "save_ledger": return self._save_ledger(context) + elif action_name == "run_shell": + return self._run_shell(context) + elif action_name == "run_python": + return self._run_python(context) return context def _load_context(self, context: Dict[str, Any]) -> Dict[str, Any]: @@ -141,3 +158,58 @@ def _save_ledger(self, context: Dict[str, Any]) -> Dict[str, Any]: conn.close() context["ledger_update"] = {} return context + + def _run_shell(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Run a shell command from context.shell_command or context.command.""" + command = context.get("shell_command") or context.get("command") + if not command: + context["shell_success"] = False + context["shell_error"] = "shell_command missing" + return context + + try: + if isinstance(command, list): + cmd = command + shell = False + else: + cmd = command + shell = True + result = subprocess.run( + cmd, + shell=shell, + text=True, + capture_output=True, + ) + context["shell_stdout"] = result.stdout + context["shell_stderr"] = result.stderr + context["shell_exit_code"] = result.returncode + context["shell_success"] = result.returncode == 0 + except Exception as exc: + context["shell_success"] = False + context["shell_error"] = str(exc) + return context + + def _run_python(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Execute python code from context.python_code or context.code.""" + code = context.get("python_code") or context.get("code") + if not code: + context["python_success"] = False + context["python_error"] = "python_code missing" + return context + + stdout = io.StringIO() + stderr = io.StringIO() + namespace: Dict[str, Any] = {} + try: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + exec(code, namespace, namespace) + context["python_success"] = True + context["python_stdout"] = stdout.getvalue() + context["python_stderr"] = stderr.getvalue() + context["python_result"] = namespace.get("result") + except Exception: + context["python_success"] = False + context["python_stdout"] = stdout.getvalue() + context["python_stderr"] = stderr.getvalue() + context["python_error"] = traceback.format_exc() + return context diff --git a/sdk/examples/anything_agent/src/anything_agent/invoker.py b/sdk/examples/anything_agent/src/anything_agent/invoker.py new file mode 100644 index 00000000..47fcf83a --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/invoker.py @@ -0,0 +1,147 @@ +"""Subprocess invoker that runs machines via anything_agent.runner.""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import subprocess +import sys +import uuid +from datetime import datetime +from typing import Any, Dict, Optional + +import yaml +from flatagents.actions import MachineInvoker + + +def _extract_machine_type(config: Dict[str, Any]) -> str: + metadata = config.get("data", {}).get("metadata", {}) + return metadata.get("type") or config.get("data", {}).get("name", "machine") + + +def _extract_tags(config: Dict[str, Any]) -> str: + metadata = config.get("data", {}).get("metadata", {}) + tags = metadata.get("tags", []) + return json.dumps(tags or []) + + +def _inject_config_dir(config: Dict[str, Any], config_dir: str | None) -> Dict[str, Any]: + if not config_dir: + return config + data = config.setdefault("data", {}) + data["_config_dir"] = config_dir + return config + + +class AnythingAgentSubprocessInvoker(MachineInvoker): + """Launch machines in subprocesses using anything_agent.runner.""" + + def __init__(self, db_path: str, working_dir: str | None = None): + self.db_path = db_path + self.working_dir = working_dir + + async def invoke( + self, + caller_machine: "FlatMachine", + target_config: Dict[str, Any], + input_data: Dict[str, Any], + execution_id: Optional[str] = None, + ) -> Dict[str, Any]: + if not execution_id: + execution_id = str(uuid.uuid4()) + + await self.launch(caller_machine, target_config, input_data, execution_id) + return await self._wait_for_completion(execution_id) + + async def launch( + self, + caller_machine: "FlatMachine", + target_config: Dict[str, Any], + input_data: Dict[str, Any], + execution_id: str, + ) -> None: + session_id = input_data.get("session_id") + db_path = input_data.get("db_path") or self.db_path + + if not session_id: + raise ValueError("session_id required to launch machine") + if not db_path: + raise ValueError("db_path required to launch machine") + + config_dir = getattr(caller_machine, "_config_dir", None) + target_config = _inject_config_dir(target_config, config_dir) + + machine_yaml = yaml.safe_dump(target_config, sort_keys=False) + now = datetime.now().isoformat() + machine_type = _extract_machine_type(target_config) + tags = _extract_tags(target_config) + snapshot = json.dumps({"input": input_data}) + + conn = sqlite3.connect(db_path) + conn.execute( + "INSERT OR IGNORE INTO executions (execution_id, session_id, parent_id, machine_type, tags, machine_yaml, snapshot, status, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?)", + ( + execution_id, + session_id, + caller_machine.execution_id, + machine_type, + tags, + machine_yaml, + snapshot, + now, + ), + ) + conn.execute( + "UPDATE executions SET snapshot = ?, status = 'running' WHERE execution_id = ?", + (snapshot, execution_id), + ) + conn.commit() + conn.close() + + cmd = [ + sys.executable, + "-m", + "anything_agent.runner", + "--db", + db_path, + "--execution-id", + execution_id, + ] + subprocess.Popen( + cmd, + cwd=self.working_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + async def _wait_for_completion(self, execution_id: str, poll_interval: float = 0.5) -> Dict[str, Any]: + while True: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT status, snapshot FROM executions WHERE execution_id = ?", + (execution_id,), + ) + row = cursor.fetchone() + conn.close() + + if not row: + return {"_error": "execution not found"} + + status = row["status"] + if status == "terminated": + snapshot = row["snapshot"] + if snapshot: + try: + payload = json.loads(snapshot) + return payload.get("output", {}) or {} + except json.JSONDecodeError: + return {} + return {} + + if status in ("failed", "stopped"): + return {"_error": f"execution {status}"} + + await asyncio.sleep(poll_interval) diff --git a/sdk/examples/anything_agent/src/anything_agent/observer.py b/sdk/examples/anything_agent/src/anything_agent/observer.py index 8c30b804..22fa245c 100644 --- a/sdk/examples/anything_agent/src/anything_agent/observer.py +++ b/sdk/examples/anything_agent/src/anything_agent/observer.py @@ -2,15 +2,15 @@ import sqlite3 import json import asyncio +import subprocess +import sys from datetime import datetime -from pathlib import Path import yaml from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import HTML -from flatagents import FlatMachine -from .hooks import AnythingAgentHooks, AwaitingApproval +from .execution import load_machine_config session = PromptSession() @@ -30,8 +30,8 @@ async def run_loop(db_path: str, session_id: str | None = None): if not pending: pending_exec = get_pending_execution(db_path, session_id) if pending_exec: - print(f"\n⏳ Starting {pending_exec['execution_id'][:8]}...") - await run_execution(pending_exec, db_path) + if launch_execution(db_path, pending_exec["execution_id"]): + print(f"\n🚀 Launched {pending_exec['execution_id'][:8]}...") continue try: @@ -75,14 +75,12 @@ async def run_loop(db_path: str, session_id: str | None = None): response = response.strip().lower() if response in ('a', 'approve', ''): - approve(db_path, approval['id'], approval['session_id']) - print("✅ Approved") - await restore_and_continue(approval, db_path) + approve(db_path, approval['id'], approval['session_id'], approval['execution_id']) + print("✅ Approved (queued)") elif response in ('n', 'note'): note = await session.prompt_async(HTML('Note: ')) - approve(db_path, approval['id'], approval['session_id'], note.strip()) - print(f"✅ Approved with note") - await restore_and_continue(approval, db_path) + approve(db_path, approval['id'], approval['session_id'], approval['execution_id'], note.strip()) + print("✅ Approved with note (queued)") elif response in ('s', 'stop'): stop(db_path, approval['id']) print("🛑 Stopped") @@ -90,81 +88,42 @@ async def run_loop(db_path: str, session_id: str | None = None): print("👋 Goodbye") break -async def restore_and_continue(approval: dict, db_path: str): - """Restore from snapshot and continue.""" +def launch_execution(db_path: str, execution_id: str) -> bool: + """Launch an execution in a detached subprocess.""" conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - cursor = conn.execute("SELECT * FROM executions WHERE execution_id = ?", (approval['execution_id'],)) + cursor = conn.execute( + "SELECT status FROM executions WHERE execution_id = ?", + (execution_id,) + ) row = cursor.fetchone() - conn.close() - - if row: - await run_execution(dict(row), db_path) - -async def run_execution(row: dict, db_path: str): - """Run machine from execution row.""" - execution_id = row['execution_id'] - session_id = row['session_id'] - snapshot = json.loads(row['snapshot']) if row.get('snapshot') else None - - conn = sqlite3.connect(db_path) - conn.execute("UPDATE executions SET status = 'running' WHERE execution_id = ?", (execution_id,)) + if not row or row[0] != "pending": + conn.close() + return False + + conn.execute( + "UPDATE executions SET status = 'running' WHERE execution_id = ?", + (execution_id,) + ) conn.commit() conn.close() - - hooks = AnythingAgentHooks(db_path, session_id, execution_id) - - # Use file path for proper relative path resolution - machine_path = Path(__file__).parent / "machines" / "core.yml" - profiles_path = Path(__file__).parent.parent.parent / "config" / "profiles.yml" - - machine = None - input_data = {"session_id": session_id, "db_path": db_path} - - if snapshot and snapshot.get("state") and snapshot.get("context"): - machine_yaml = row.get("machine_yaml") - if not machine_yaml: - machine_yaml = machine_path.read_text() - machine_config = yaml.safe_load(machine_yaml) or {} - machine_config = _apply_snapshot(machine_config, snapshot) - machine = FlatMachine( - config_dict=machine_config, - hooks=hooks, - profiles_file=str(profiles_path), - _config_dir=str(machine_path.parent), - ) - input_data = {} - else: - machine = FlatMachine(config_file=str(machine_path), hooks=hooks, profiles_file=str(profiles_path)) - if snapshot and 'context' in snapshot: - input_data.update(snapshot['context']) - - try: - result = await machine.execute(input=input_data) - print(f"✅ Complete: {result}") - conn = sqlite3.connect(db_path) - conn.execute("UPDATE executions SET status = 'terminated' WHERE execution_id = ?", (execution_id,)) - conn.commit() - conn.close() - except AwaitingApproval as e: - print(f"⏸️ Paused: {e.from_state} → {e.to_state}") - -def _apply_snapshot(machine_config: dict, snapshot: dict) -> dict: - data = machine_config.get("data", {}) - states = data.get("states", {}) - target_state = snapshot.get("state") - - if target_state and target_state in states: - for state_name, state in states.items(): - if state.get("type") == "initial": - state.pop("type", None) - states[target_state]["type"] = "initial" - else: - print(f"⚠️ Snapshot state '{target_state}' not found. Starting from default initial state.") - data["context"] = snapshot.get("context", {}) - machine_config["data"] = data - return machine_config + cmd = [ + sys.executable, + "-m", + "anything_agent.runner", + "--db", + db_path, + "--execution-id", + execution_id, + ] + + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True def display_approval(approval: dict, db_path: str): print(f"\n{'═' * 60}") @@ -188,7 +147,7 @@ def display_approval(approval: dict, db_path: str): machine_yaml = row["machine_yaml"] if machine_yaml: - machine = yaml.safe_load(machine_yaml) + machine = load_machine_config(machine_yaml) data = (machine or {}).get("data", {}) states = data.get("states", {}) from_state = states.get(approval["state_name"], {}) @@ -247,11 +206,17 @@ def get_pending_execution(db_path: str, session_id: str | None = None) -> dict: conn.close() return dict(row) if row else None -def approve(db_path: str, approval_id: int, session_id: str, note: str = None): +def approve(db_path: str, approval_id: int, session_id: str, execution_id: str, note: str = None): conn = sqlite3.connect(db_path) now = datetime.now().isoformat() - conn.execute("UPDATE pending_approvals SET status = 'approved', human_note = ?, responded_at = ? WHERE id = ?", - (note, now, approval_id)) + conn.execute( + "UPDATE pending_approvals SET status = 'approved', human_note = ?, responded_at = ? WHERE id = ?", + (note, now, approval_id) + ) + conn.execute( + "UPDATE executions SET status = 'pending' WHERE execution_id = ?", + (execution_id,) + ) if note: cursor = conn.execute("SELECT human_notes FROM ledger WHERE session_id = ?", (session_id,)) notes = json.loads(cursor.fetchone()[0] or "[]") diff --git a/sdk/examples/anything_agent/src/anything_agent/runner.py b/sdk/examples/anything_agent/src/anything_agent/runner.py new file mode 100644 index 00000000..e8cfc1d4 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/runner.py @@ -0,0 +1,127 @@ +"""Run an execution from the Anything Agent database.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import sqlite3 +from datetime import datetime +from pathlib import Path + +from flatagents import FlatMachine + +from .execution import apply_snapshot, load_machine_config, serialize_snapshot +from .hooks import AnythingAgentHooks, AwaitingApproval +from .invoker import AnythingAgentSubprocessInvoker + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +def _resolve_config_dir(machine_config: dict, fallback: Path) -> Path: + data = machine_config.get("data", {}) + config_dir = data.pop("_config_dir", None) + return Path(config_dir) if config_dir else fallback + + +def _load_execution(db_path: str, execution_id: str) -> dict: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT execution_id, session_id, machine_yaml, snapshot FROM executions WHERE execution_id = ?", + (execution_id,), + ) + row = cursor.fetchone() + conn.close() + + if not row: + raise ValueError(f"Execution {execution_id} not found") + + return dict(row) + + +def _update_status(db_path: str, execution_id: str, status: str, snapshot: str | None = None): + conn = sqlite3.connect(db_path) + now = datetime.now().isoformat() + terminated_at = now if status in ("terminated", "failed") else None + + if snapshot is not None: + conn.execute( + "UPDATE executions SET status = ?, snapshot = ?, terminated_at = ? WHERE execution_id = ?", + (status, snapshot, terminated_at, execution_id), + ) + else: + conn.execute( + "UPDATE executions SET status = ?, terminated_at = ? WHERE execution_id = ?", + (status, terminated_at, execution_id), + ) + conn.commit() + conn.close() + + +async def run_execution(db_path: str, execution_id: str): + row = _load_execution(db_path, execution_id) + session_id = row["session_id"] + snapshot = json.loads(row["snapshot"]) if row.get("snapshot") else None + + machine_yaml = row.get("machine_yaml") + if not machine_yaml: + machine_yaml = (Path(__file__).parent / "machines" / "core.yml").read_text() + + machine_config = load_machine_config(machine_yaml) + base_dir = Path(__file__).parent / "machines" + config_dir = _resolve_config_dir(machine_config, base_dir) + + input_data = {"session_id": session_id, "db_path": db_path} + if snapshot and snapshot.get("state") and snapshot.get("context"): + machine_config = apply_snapshot(machine_config, snapshot) + input_data = {} + elif snapshot and snapshot.get("input"): + input_data = snapshot["input"] or {} + input_data.setdefault("session_id", session_id) + input_data.setdefault("db_path", db_path) + + invoker = AnythingAgentSubprocessInvoker(db_path=db_path, working_dir=str(config_dir)) + hooks = AnythingAgentHooks(db_path, session_id, execution_id) + + machine = FlatMachine( + config_dict=machine_config, + hooks=hooks, + _config_dir=str(config_dir), + invoker=invoker, + profiles_file=str(Path(__file__).parent.parent.parent / "config" / "profiles.yml"), + ) + + _update_status(db_path, execution_id, "running") + + try: + result = await machine.execute(input=input_data) + snapshot_payload = serialize_snapshot(result) + _update_status(db_path, execution_id, "terminated", snapshot_payload) + except AwaitingApproval: + _update_status(db_path, execution_id, "suspended") + except Exception as exc: + logger.exception("Execution failed: %s", exc) + _update_status(db_path, execution_id, "failed") + raise + + +def main(): + parser = argparse.ArgumentParser(description="Anything Agent Runner") + parser.add_argument("--db", default="./anything_agent.db") + parser.add_argument("--execution-id", required=True) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + asyncio.run(run_execution(args.db, args.execution_id)) + + +if __name__ == "__main__": + main() From 7c0f02e912b33ecaf9631f89e217188487e1b6d7 Mon Sep 17 00:00:00 2001 From: memgrafter Date: Sat, 31 Jan 2026 17:27:48 -0800 Subject: [PATCH 5/8] Add logs. --- sdk/examples/anything_agent/.gitignore | 2 + sdk/examples/anything_agent/anything_agent.db | Bin 212992 -> 516096 bytes .../src/anything_agent/execution.py | 33 +++- .../src/anything_agent/hooks.py | 179 +++++++++++++++++- .../src/anything_agent/invoker.py | 11 +- .../anything_agent/machines/agents/driver.yml | 99 ++++++++++ .../src/anything_agent/machines/core.yml | 104 +++++----- .../src/anything_agent/observer.py | 125 +++++++----- .../src/anything_agent/runner.py | 22 ++- 9 files changed, 469 insertions(+), 106 deletions(-) create mode 100644 sdk/examples/anything_agent/.gitignore create mode 100644 sdk/examples/anything_agent/src/anything_agent/machines/agents/driver.yml diff --git a/sdk/examples/anything_agent/.gitignore b/sdk/examples/anything_agent/.gitignore new file mode 100644 index 00000000..4d301b17 --- /dev/null +++ b/sdk/examples/anything_agent/.gitignore @@ -0,0 +1,2 @@ +logs/ +anything_agent.db diff --git a/sdk/examples/anything_agent/anything_agent.db b/sdk/examples/anything_agent/anything_agent.db index 7ac5c6d72c09aa860430d71e1e638dcd2dd0a7cb..966682461524c5d5d74086732cb2147e65c425d9 100644 GIT binary patch literal 516096 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⩔B-xX=8HQFGGcwUni2c2Yj`sK-$C!darE{i#r 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 G|NjA4R&<;I delta 629 zcmZoTAm7lyJ3)$%p^Sln!32oGV4{W@6GPd?geClZtUSvZ_$Ts<^R4Aw#j|{~qQX?3 z&C6wCnfX}xO&Iu1_+$Ck^SANe*euwf#LuF^9LhL(qm0Vr82uG|th}NO{LA^B`QGy} z@rnX8AZJMP|K|U;u~CC@vqj)izUlGP7$s$Z!cxpYLV}44 zNP^uE3$h>R3KN7IreAbr0Xiao1&1PkG*BIb@noI?r;UwHteYp4e&LuNaFsP>`h+CL z;?2?ZO9X^9;AY|U&-|rqKw~s`|4e3Ipv}Rj&!@yE%EvJ|VSxl2?;rkNeg~lcmhjqg zGfOiTq~@h$=A~=!Fv~I~78Dfam!+ntBXiV{IjRWGX3KSFKC*N6Gw>Ye?4Q^;Yx}YV zj1Fwu1&W!jG779%!NAHF8potN54cB4ieeLS(iBWo|RE z0NrfTetZq%_Ty`q{yI%Ra-LCa`>pp(ulT1Q*$-m=2Q%-3nEcEj(f5pE({IT$>#$sB zV7tC`BFjd$=?kth%5YCo)8JtU4V7Nxtvubngw dict: # Fallback to default initial state if snapshot state is missing pass - data["context"] = snapshot.get("context", {}) + data["context"] = _escape_jinja(snapshot.get("context", {})) machine_config["data"] = data return machine_config +def _escape_jinja(value: Any) -> Any: + if isinstance(value, str): + if value.startswith("{% raw %}") and value.endswith("{% endraw %}"): + return value + if "{{" in value or "{%" in value or "{#" in value: + return f"{{% raw %}}{value}{{% endraw %}}" + return value + if isinstance(value, dict): + return {k: _escape_jinja(v) for k, v in value.items()} + if isinstance(value, list): + return [_escape_jinja(v) for v in value] + return value + + def serialize_snapshot(output: Any | None = None) -> str: payload = {"state": "done", "output": output} return json.dumps(payload) + + +def get_log_root() -> Path: + return Path(__file__).parent.parent.parent / "logs" + + +def get_session_log_dir(session_id: str) -> Path: + root = get_log_root() + root.mkdir(parents=True, exist_ok=True) + session_dir = root / f"session_{session_id}" + session_dir.mkdir(parents=True, exist_ok=True) + return session_dir + + +def get_execution_log_path(session_id: str, execution_id: str) -> Path: + return get_session_log_dir(session_id) / f"execution_{execution_id}.log" diff --git a/sdk/examples/anything_agent/src/anything_agent/hooks.py b/sdk/examples/anything_agent/src/anything_agent/hooks.py index d5596a76..af3b0e44 100644 --- a/sdk/examples/anything_agent/src/anything_agent/hooks.py +++ b/sdk/examples/anything_agent/src/anything_agent/hooks.py @@ -8,7 +8,10 @@ import traceback from datetime import datetime from typing import Dict, Any -from flatagents import MachineHooks + +import yaml +from flatagents import MachineHooks, validate_flatagent_config, validate_flatmachine_config + from .context import load_guidance, estimate_tokens, compress_history, fetch_ledger, serialize_ledger class AwaitingApproval(Exception): @@ -90,6 +93,16 @@ def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str, Any] return self._run_shell(context) elif action_name == "run_python": return self._run_python(context) + elif action_name == "validate_specs": + return self._validate_specs(context) + elif action_name == "validate_decision": + return self._validate_decision(context) + elif action_name == "spawn_leaf": + return self._spawn_leaf(context) + elif action_name == "spawn_self": + return self._spawn_self(context) + elif action_name == "return_to_core": + return self._return_to_core(context) return context def _load_context(self, context: Dict[str, Any]) -> Dict[str, Any]: @@ -110,11 +123,23 @@ def _load_context(self, context: Dict[str, Any]) -> Dict[str, Any]: while tokens["used"] > ledger_budget and len(ledger["progress"]) > 1: ledger["progress"] = ledger["progress"][1:] tokens = estimate_tokens(serialize_ledger(ledger)) + + self_machine_yaml = None + conn = sqlite3.connect(self.db_path) + cursor = conn.execute( + "SELECT machine_yaml FROM executions WHERE execution_id = ?", + (self.execution_id,) + ) + row = cursor.fetchone() + conn.close() + if row: + self_machine_yaml = row[0] context["guidance"] = guidance context["ledger"] = ledger context["token_estimate"] = tokens context["token_budget"] = budget + context["self_machine_yaml"] = self_machine_yaml total = tokens["used"] + guidance_tokens print(f"📊 Context: {total:,}/{budget:,} tokens (ledger: {tokens['used']:,}, guidance: {guidance_tokens})") @@ -213,3 +238,155 @@ def _run_python(self, context: Dict[str, Any]) -> Dict[str, Any]: context["python_stderr"] = stderr.getvalue() context["python_error"] = traceback.format_exc() return context + + def _validate_specs(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Validate generated machine specs and inline agents.""" + validation_kind = context.get("validation_kind") or "" + machine_yaml = None + if validation_kind == "leaf": + machine_yaml = context.get("leaf_machine_yaml") + elif validation_kind == "self": + machine_yaml = context.get("self_machine_yaml") + else: + machine_yaml = context.get("leaf_machine_yaml") or context.get("self_machine_yaml") + + errors: list[str] = [] + machine_config = None + + if not machine_yaml: + errors.append("machine_yaml missing") + else: + try: + machine_config = yaml.safe_load(machine_yaml) or {} + except Exception as exc: + errors.append(f"machine_yaml parse error: {exc}") + + if machine_config: + errors.extend(validate_flatmachine_config(machine_config, warn=False, strict=False)) + + data = machine_config.get("data", {}) + if data.get("hooks"): + errors.append("machine config must not define hooks (hooks are injected by runner)") + + agents = data.get("agents", {}) + for name, agent_ref in agents.items(): + if isinstance(agent_ref, dict): + errors.extend( + f"agent '{name}': {err}" for err in validate_flatagent_config(agent_ref, warn=False, strict=False) + ) + + if validation_kind == "leaf": + states = data.get("states", {}) + has_return = any(state.get("action") == "return_to_core" for state in states.values()) + if not has_return: + errors.append("leaf machine must include an action: return_to_core") + + if validation_kind == "self": + states = data.get("states", {}) + has_driver = any(state.get("agent") == "driver" for state in states.values()) + if not has_driver: + errors.append("self machine must include an agent: driver") + + context["validation_passed"] = len(errors) == 0 + context["validation_errors"] = errors + context["candidate_machine_yaml"] = machine_yaml or "" + return context + + def _validate_decision(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Validate driver decision before attempting to spawn machines.""" + action = context.get("next_action") + errors: list[str] = [] + + if action not in ("delegate", "spawn_self", "done"): + errors.append("decision.action must be delegate, spawn_self, or done") + + if action == "delegate" and not context.get("leaf_machine_yaml"): + errors.append("leaf_machine_yaml required for delegate") + + if action == "spawn_self" and not context.get("self_machine_yaml"): + errors.append("self_machine_yaml required for spawn_self") + + context["decision_valid"] = len(errors) == 0 + if errors: + context["validation_errors"] = errors + context["candidate_machine_yaml"] = ( + context.get("leaf_machine_yaml") + or context.get("self_machine_yaml") + or "" + ) + else: + context["validation_errors"] = [] + return context + + def _spawn_leaf(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Spawn a leaf execution from context.leaf_machine_yaml.""" + machine_yaml = context.get("leaf_machine_yaml") + if not machine_yaml: + context["spawn_error"] = "leaf_machine_yaml missing" + return context + + return_machine_yaml = context.get("self_machine_yaml") + input_data = { + "session_id": self.session_id, + "db_path": self.db_path, + "return_machine_yaml": return_machine_yaml, + } + return self._spawn_execution(context, machine_yaml, input_data, machine_type="leaf") + + def _spawn_self(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Spawn a new self execution from context.self_machine_yaml.""" + machine_yaml = context.get("self_machine_yaml") + if not machine_yaml: + context["spawn_error"] = "self_machine_yaml missing" + return context + + input_data = { + "session_id": self.session_id, + "db_path": self.db_path, + } + return self._spawn_execution(context, machine_yaml, input_data, machine_type="core") + + def _return_to_core(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Leaf action: spawn a new core execution with leaf_result.""" + machine_yaml = context.get("return_machine_yaml") or context.get("self_machine_yaml") + if not machine_yaml: + context["spawn_error"] = "return_machine_yaml missing" + return context + + input_data = { + "session_id": context.get("session_id", self.session_id), + "db_path": context.get("db_path", self.db_path), + "leaf_result": context.get("leaf_result", ""), + } + return self._spawn_execution(context, machine_yaml, input_data, machine_type="core") + + def _spawn_execution( + self, + context: Dict[str, Any], + machine_yaml: str, + input_data: Dict[str, Any], + machine_type: str = "machine", + ) -> Dict[str, Any]: + execution_id = str(uuid.uuid4()) + now = datetime.now().isoformat() + + conn = sqlite3.connect(self.db_path) + conn.execute( + "INSERT INTO executions (execution_id, session_id, parent_id, machine_type, tags, machine_yaml, snapshot, status, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)", + ( + execution_id, + self.session_id, + self.execution_id, + machine_type, + "[]", + machine_yaml, + json.dumps({"input": input_data}), + now, + ), + ) + conn.commit() + conn.close() + + context["spawn_execution_id"] = execution_id + return context diff --git a/sdk/examples/anything_agent/src/anything_agent/invoker.py b/sdk/examples/anything_agent/src/anything_agent/invoker.py index 47fcf83a..a44ceb51 100644 --- a/sdk/examples/anything_agent/src/anything_agent/invoker.py +++ b/sdk/examples/anything_agent/src/anything_agent/invoker.py @@ -13,6 +13,8 @@ import yaml from flatagents.actions import MachineInvoker +from .execution import get_execution_log_path + def _extract_machine_type(config: Dict[str, Any]) -> str: metadata = config.get("data", {}).get("metadata", {}) @@ -108,13 +110,18 @@ async def launch( "--execution-id", execution_id, ] + log_path = get_execution_log_path(session_id, execution_id) + log_path.parent.mkdir(parents=True, exist_ok=True) + log_file = open(log_path, "a", encoding="utf-8") + subprocess.Popen( cmd, cwd=self.working_dir, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=log_file, + stderr=log_file, start_new_session=True, ) + log_file.close() async def _wait_for_completion(self, execution_id: str, poll_interval: float = 0.5) -> Dict[str, Any]: while True: diff --git a/sdk/examples/anything_agent/src/anything_agent/machines/agents/driver.yml b/sdk/examples/anything_agent/src/anything_agent/machines/agents/driver.yml new file mode 100644 index 00000000..a04664f8 --- /dev/null +++ b/sdk/examples/anything_agent/src/anything_agent/machines/agents/driver.yml @@ -0,0 +1,99 @@ +spec: flatagent +spec_version: "0.9.0" + +data: + name: driver + model: default + + system: | + {{ input.guidance }} + + --- + + You are the single orchestrator. You may: + - spawn a leaf machine (delegate) + - spawn an improved version of yourself (spawn_self) + - finish (done) + + Leaf machines must: + - Use hook actions (run_shell/run_python) for execution. + - End with an action: return_to_core that reinvokes the core machine. + - Accept input.return_machine_yaml, input.session_id, input.db_path. + - Return leaf_result in context.leaf_result before return_to_core. + + Self machines must: + - Preserve human approval by using transitions (do not bypass hooks). + - Use the same core interface (load_context → decide style). + + If validation_errors are present, fix the machine YAML and resubmit it. + + GOAL: {{ input.ledger.goal }} + + PROGRESS: + {% for m in input.ledger.progress[-5:] %} + - {{ m.description }} + {% endfor %} + {% if not input.ledger.progress %}(none yet){% endif %} + + TECHNIQUES: {% for t in input.ledger.techniques %}{{ t.name }}; {% endfor %}{% if not input.ledger.techniques %}(none yet){% endif %} + + AVOID: {% for f in input.ledger.failed_approaches[-3:] %}{{ f.approach }}; {% endfor %}{% if not input.ledger.failed_approaches %}(none yet){% endif %} + + HUMAN NOTES: {% for n in input.ledger.human_notes[-3:] %}{{ n.note }}; {% endfor %}{% if not input.ledger.human_notes %}(none yet){% endif %} + + LEAF RESULT: {{ input.leaf_result | default('') }} + + VALIDATION ERRORS: + {% for err in input.validation_errors %} + - {{ err }} + {% endfor %} + {% if not input.validation_errors %}(none){% endif %} + + user: | + Decide the next action toward the goal. + + If delegating, output leaf_machine_yaml. + If spawning self, output self_machine_yaml. + + output: + action: + type: str + enum: ["delegate", "spawn_self", "done"] + detail: + type: str + description: "What you did or plan to do next" + leaf_machine_yaml: + type: str + required: false + description: "YAML for a leaf machine (delegate)" + self_machine_yaml: + type: str + required: false + description: "YAML for an optimized self machine" + ledger_update: + type: object + required: false + description: "Optional ledger update" + properties: + new_milestone: + type: object + required: false + properties: + description: + type: str + new_technique: + type: object + required: false + properties: + name: + type: str + description: + type: str + new_failure: + type: object + required: false + properties: + approach: + type: str + reason: + type: str diff --git a/sdk/examples/anything_agent/src/anything_agent/machines/core.yml b/sdk/examples/anything_agent/src/anything_agent/machines/core.yml index 5edae71e..ee85e964 100644 --- a/sdk/examples/anything_agent/src/anything_agent/machines/core.yml +++ b/sdk/examples/anything_agent/src/anything_agent/machines/core.yml @@ -3,7 +3,7 @@ spec_version: "0.9.0" data: name: core - + context: session_id: "{{ input.session_id }}" db_path: "{{ input.db_path }}" @@ -11,13 +11,12 @@ data: context_target_pct: 0.20 context_minimum: 12000 leaf_result: "{{ input.leaf_result | default('') }}" - has_leaf_result: false - + validation_errors: "{{ input.validation_errors | default([]) }}" + candidate_machine_yaml: "{{ input.candidate_machine_yaml | default('') }}" + agents: - thinker: ./agents/thinker.yml - worker: ./agents/worker.yml - reflector: ./agents/reflector.yml - + driver: ./agents/driver.yml + states: start: type: initial @@ -27,64 +26,75 @@ data: load_context: action: load_context transitions: - - condition: "context.has_leaf_result == true" - to: process_leaf - - to: think + - to: decide - process_leaf: - agent: reflector - input: - leaf_result: "{{ context.leaf_result }}" - ledger: "{{ context.ledger }}" - guidance: "{{ context.guidance }}" - output_to_context: - ledger_update: "{{ output.ledger_update }}" - transitions: - - to: cleanup - - think: - agent: thinker + decide: + agent: driver input: ledger: "{{ context.ledger }}" guidance: "{{ context.guidance }}" + leaf_result: "{{ context.leaf_result }}" token_budget: "{{ context.token_budget }}" + token_estimate: "{{ context.token_estimate }}" + validation_errors: "{{ context.validation_errors }}" + candidate_machine_yaml: "{{ context.candidate_machine_yaml }}" + self_machine_yaml: "{{ context.self_machine_yaml | default('') }}" output_to_context: next_action: "{{ output.action }}" action_detail: "{{ output.detail }}" + leaf_machine_yaml: "{{ output.leaf_machine_yaml | default('') }}" + self_machine_yaml: "{{ output.self_machine_yaml | default(context.self_machine_yaml | default('')) }}" ledger_update: "{{ output.ledger_update | default({}) }}" + validation_kind: "{{ 'leaf' if output.action == 'delegate' else 'self' if output.action == 'spawn_self' else '' }}" + transitions: + - to: validate_decision + + validate_decision: + action: validate_decision transitions: + - condition: "context.decision_valid != true" + to: decide - condition: "context.next_action == 'done'" to: save_and_done - - to: work + - condition: "context.next_action == 'delegate'" + to: validate_leaf + - condition: "context.next_action == 'spawn_self'" + to: validate_self + - to: decide - work: - agent: worker - input: - task: "{{ context.action_detail }}" - ledger: "{{ context.ledger }}" - guidance: "{{ context.guidance }}" - output_to_context: - work_result: "{{ output.result }}" - work_success: "{{ output.success }}" + validate_leaf: + action: validate_specs transitions: - - to: cleanup + - condition: "context.validation_passed == true" + to: save_before_leaf + - to: decide - cleanup: - action: cleanup_context + save_before_leaf: + action: save_ledger transitions: - - to: reflect + - to: spawn_leaf - reflect: - agent: reflector - input: - work_result: "{{ context.work_result }}" - work_success: "{{ context.work_success }}" - ledger: "{{ context.ledger }}" - guidance: "{{ context.guidance }}" - output_to_context: - ledger_update: "{{ output.ledger_update }}" + spawn_leaf: + action: spawn_leaf transitions: - - to: save_ledger + - to: done + + validate_self: + action: validate_specs + transitions: + - condition: "context.validation_passed == true" + to: save_before_self + - to: decide + + save_before_self: + action: save_ledger + transitions: + - to: spawn_self + + spawn_self: + action: spawn_self + transitions: + - to: done save_ledger: action: save_ledger diff --git a/sdk/examples/anything_agent/src/anything_agent/observer.py b/sdk/examples/anything_agent/src/anything_agent/observer.py index 22fa245c..088d50a7 100644 --- a/sdk/examples/anything_agent/src/anything_agent/observer.py +++ b/sdk/examples/anything_agent/src/anything_agent/observer.py @@ -10,7 +10,7 @@ from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import HTML -from .execution import load_machine_config +from .execution import load_machine_config, get_execution_log_path session = PromptSession() @@ -20,75 +20,79 @@ async def run_loop(db_path: str, session_id: str | None = None): print(f"📁 Database: {db_path}") if session_id: print(f"🎯 Session: {session_id[:8]}...") - print("Commands: [a]pprove, [n]ote (approve + add note), [s]top, [q]uit") - print("Idle: [l]ist stopped, [r]eopen stopped, [q]uit") + print("Commands: [a]pprove, [n]ote (approve + add note), [s]top, [l]ist stopped, [r]eopen stopped, [q]uit") print("-" * 60) - + + last_pending_count = 0 + while True: pending = get_pending_approvals(db_path, session_id) - - if not pending: + pending_count = len(pending) + + if pending_count == 0: pending_exec = get_pending_execution(db_path, session_id) if pending_exec: - if launch_execution(db_path, pending_exec["execution_id"]): + if launch_execution(db_path, pending_exec["execution_id"], pending_exec["session_id"]): print(f"\n🚀 Launched {pending_exec['execution_id'][:8]}...") + await asyncio.sleep(0.5) continue - try: - response = await session.prompt_async(HTML('Idle [l]ist/[r]eopen/[q]uit: ')) - except (EOFError, KeyboardInterrupt): - print("\n👋 Goodbye") - break + if last_pending_count != 0: + last_pending_count = 0 - response = response.strip().lower() - if not response: - continue + await asyncio.sleep(0.5) + continue - parts = response.split() - command = parts[0] - arg = parts[1] if len(parts) > 1 else None + if pending_count != last_pending_count: + print("\a", end="", flush=True) + last_pending_count = pending_count - if command in ('q', 'quit'): - print("👋 Goodbye") - break - if command in ('l', 'list'): - list_stopped_approvals(db_path, session_id) - continue - if command in ('r', 'resume', 'reopen', 'unstop'): - reopened = reopen_stopped_approval(db_path, session_id, arg) - if reopened: - print(f"♻️ Reopened approval {reopened['id']} for {reopened['execution_id'][:8]}...") - continue + queue_indicator = "🟢" * pending_count + print(f"{queue_indicator} Pending approvals: {pending_count}") - print("❓ Unknown command. Use [l]ist, [r]eopen, or [q]uit.") - continue - approval = pending[0] display_approval(approval, db_path) - + try: - response = await session.prompt_async(HTML('Decision [a]pprove/[n]ote (approve + note)/[s]top/[q]uit: ')) + response = await session.prompt_async( + HTML('Decision [a]pprove/[n]ote (approve + note)/[s]top/[l]ist/[r]eopen/[q]uit: ') + ) except (EOFError, KeyboardInterrupt): print("\n👋 Goodbye") break - + response = response.strip().lower() - - if response in ('a', 'approve', ''): + parts = response.split() + command = parts[0] if parts else "" + arg = parts[1] if len(parts) > 1 else None + + if command in ('a', 'approve', ''): approve(db_path, approval['id'], approval['session_id'], approval['execution_id']) print("✅ Approved (queued)") - elif response in ('n', 'note'): + if launch_execution(db_path, approval['execution_id'], approval['session_id']): + print(f"🚀 Launched {approval['execution_id'][:8]}...") + elif command in ('n', 'note'): note = await session.prompt_async(HTML('Note: ')) approve(db_path, approval['id'], approval['session_id'], approval['execution_id'], note.strip()) print("✅ Approved with note (queued)") - elif response in ('s', 'stop'): + if launch_execution(db_path, approval['execution_id'], approval['session_id']): + print(f"🚀 Launched {approval['execution_id'][:8]}...") + elif command in ('s', 'stop'): stop(db_path, approval['id']) print("🛑 Stopped") - elif response in ('q', 'quit'): + elif command in ('l', 'list'): + list_stopped_approvals(db_path, session_id) + elif command in ('r', 'resume', 'reopen', 'unstop'): + reopened = reopen_stopped_approval(db_path, session_id, arg) + if reopened: + print(f"♻️ Reopened approval {reopened['id']} for {reopened['execution_id'][:8]}...") + elif command in ('q', 'quit'): print("👋 Goodbye") break + else: + print("❓ Unknown command. Use [a]pprove, [n]ote, [s]top, [l]ist, [r]eopen, or [q]uit.") -def launch_execution(db_path: str, execution_id: str) -> bool: +def launch_execution(db_path: str, execution_id: str, session_id: str) -> bool: """Launch an execution in a detached subprocess.""" conn = sqlite3.connect(db_path) cursor = conn.execute( @@ -117,12 +121,17 @@ def launch_execution(db_path: str, execution_id: str) -> bool: execution_id, ] + log_path = get_execution_log_path(session_id, execution_id) + log_path.parent.mkdir(parents=True, exist_ok=True) + log_file = open(log_path, "a", encoding="utf-8") + subprocess.Popen( cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=log_file, + stderr=log_file, start_new_session=True, ) + log_file.close() return True def display_approval(approval: dict, db_path: str): @@ -161,6 +170,9 @@ def display_approval(approval: dict, db_path: str): } print(yaml.safe_dump(machine_header, sort_keys=False).strip()) + print("\n🧾 Machine YAML (execution):") + print(yaml.safe_dump(machine, sort_keys=False).strip()) + print("\n➡️ From state definition:") print(yaml.safe_dump(from_state or {"missing": True}, sort_keys=False).strip()) @@ -174,8 +186,29 @@ def display_approval(approval: dict, db_path: str): print("\n🧾 Ledger (db):") print(yaml.safe_dump(ledger, sort_keys=False).strip()) + yaml_fields = { + "candidate_machine_yaml": context.get("candidate_machine_yaml"), + "leaf_machine_yaml": context.get("leaf_machine_yaml"), + "self_machine_yaml": context.get("self_machine_yaml"), + "return_machine_yaml": context.get("return_machine_yaml"), + } + for label, yaml_text in yaml_fields.items(): + if yaml_text: + try: + parsed = yaml.safe_load(yaml_text) + print(f"\n🧾 {label} (formatted):") + print(yaml.safe_dump(parsed, sort_keys=False).strip()) + except Exception: + print(f"\n🧾 {label} (raw):") + print(yaml_text) + + display_context = dict(context) + for label, yaml_text in yaml_fields.items(): + if yaml_text: + display_context[label] = "" + print("\n📦 Context snapshot:") - print(json.dumps(context, indent=2, sort_keys=True)) + print(json.dumps(display_context, indent=2, sort_keys=True)) print('═' * 60) def get_pending_approvals(db_path: str, session_id: str | None = None) -> list: @@ -183,11 +216,11 @@ def get_pending_approvals(db_path: str, session_id: str | None = None) -> list: conn.row_factory = sqlite3.Row if session_id: cursor = conn.execute( - "SELECT * FROM pending_approvals WHERE status = 'pending' AND session_id = ? ORDER BY created_at LIMIT 1", + "SELECT * FROM pending_approvals WHERE status = 'pending' AND session_id = ? ORDER BY created_at", (session_id,) ) else: - cursor = conn.execute("SELECT * FROM pending_approvals WHERE status = 'pending' ORDER BY created_at LIMIT 1") + cursor = conn.execute("SELECT * FROM pending_approvals WHERE status = 'pending' ORDER BY created_at") result = [dict(r) for r in cursor.fetchall()] conn.close() return result diff --git a/sdk/examples/anything_agent/src/anything_agent/runner.py b/sdk/examples/anything_agent/src/anything_agent/runner.py index e8cfc1d4..ee483afa 100644 --- a/sdk/examples/anything_agent/src/anything_agent/runner.py +++ b/sdk/examples/anything_agent/src/anything_agent/runner.py @@ -4,23 +4,17 @@ import argparse import asyncio import json -import logging +import os import sqlite3 from datetime import datetime from pathlib import Path -from flatagents import FlatMachine +from flatagents import FlatMachine, setup_logging, get_logger -from .execution import apply_snapshot, load_machine_config, serialize_snapshot +from .execution import apply_snapshot, load_machine_config, serialize_snapshot, get_session_log_dir from .hooks import AnythingAgentHooks, AwaitingApproval from .invoker import AnythingAgentSubprocessInvoker -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - def _resolve_config_dir(machine_config: dict, fallback: Path) -> Path: data = machine_config.get("data", {}) @@ -68,6 +62,16 @@ async def run_execution(db_path: str, execution_id: str): session_id = row["session_id"] snapshot = json.loads(row["snapshot"]) if row.get("snapshot") else None + session_log_dir = get_session_log_dir(session_id) + os.environ.setdefault("FLATAGENTS_LOG_DIR", str(session_log_dir)) + os.environ.setdefault("FLATAGENTS_LOG_LEVEL", "DEBUG") + os.environ.setdefault("FLATAGENTS_LOG_FORMAT", "standard") + os.environ.setdefault("FLATAGENTS_METRICS_ENABLED", "true") + os.environ.setdefault("OTEL_METRICS_EXPORTER", "console") + os.environ.setdefault("OTEL_SERVICE_NAME", "anything_agent") + setup_logging(level=os.environ.get("FLATAGENTS_LOG_LEVEL"), format=os.environ.get("FLATAGENTS_LOG_FORMAT"), force=True) + logger = get_logger(__name__) + machine_yaml = row.get("machine_yaml") if not machine_yaml: machine_yaml = (Path(__file__).parent / "machines" / "core.yml").read_text() From b9eb3a4d2b1efdafcc44a213ea7f34e1da5b7f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 18:41:27 +0000 Subject: [PATCH 6/8] Add tool calling and API format support to flatagents - Add tool calling support: tools, tool_choice, parallel_tool_calls - Add response_format with json_schema type for structured outputs - Add missing model parameters: repetition_penalty, stop, logit_bias - Update ModelConfig, LLMOptions, and runtime spec with new fields - Sync schema files across all SDK locations https://claude.ai/code/session_01GaVWKmhxt2ncfpUtNHvNc4 --- assets/flatagent.d.ts | 73 ++++++++++++++++--- assets/flatagents-runtime.d.ts | 43 +++++++++-- assets/profiles.d.ts | 26 ++++--- flatagent.d.ts | 73 ++++++++++++++++--- flatagents-runtime.d.ts | 43 +++++++++-- profiles.d.ts | 26 ++++--- sdk/js/schemas/flatagent.d.ts | 73 ++++++++++++++++--- sdk/js/schemas/flatagents-runtime.d.ts | 43 +++++++++-- sdk/js/schemas/profiles.d.ts | 26 ++++--- sdk/js/src/flatagent.ts | 7 ++ sdk/js/src/llm/types.ts | 38 ++++++++-- sdk/js/src/types.ts | 40 ++++++++++ sdk/python/flatagents/assets/flatagent.d.ts | 73 ++++++++++++++++--- .../flatagents/assets/flatagents-runtime.d.ts | 43 +++++++++-- sdk/python/flatagents/assets/profiles.d.ts | 26 ++++--- 15 files changed, 543 insertions(+), 110 deletions(-) diff --git a/assets/flatagent.d.ts b/assets/flatagent.d.ts index cb4f0c29..7c5fb9fd 100644 --- a/assets/flatagent.d.ts +++ b/assets/flatagent.d.ts @@ -36,16 +36,32 @@ * * MODEL FIELDS: * ------------- - * name - Model name (e.g., "gpt-4", "zai-glm-4.6") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom API base URL (for local models/proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom API base URL (for local models/proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) + * + * TOOL CALLING FIELDS: + * -------------------- + * tools - Array of tool definitions for function calling + * tool_choice - Control tool usage: "none", "auto", "required", or specific tool + * parallel_tool_calls - Allow multiple tool calls in single response (default: true) + * + * RESPONSE FORMAT FIELDS: + * ----------------------- + * response_format - Control output format: + * - { type: "text" } - Plain text output (default) + * - { type: "json_object" } - Valid JSON output + * - { type: "json_schema", json_schema: {...} } - Structured output with schema * * MODEL PROFILES: * --------------- @@ -197,8 +213,45 @@ export interface ModelConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; +} + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; } export interface ProfiledModelConfig extends Partial { diff --git a/assets/flatagents-runtime.d.ts b/assets/flatagents-runtime.d.ts index 6fbc796c..97545c86 100644 --- a/assets/flatagents-runtime.d.ts +++ b/assets/flatagents-runtime.d.ts @@ -260,17 +260,48 @@ export interface ToolCall { export interface LLMOptions { temperature?: number; max_tokens?: number; + top_p?: number; + top_k?: number; + frequency_penalty?: number; + presence_penalty?: number; + repetition_penalty?: number; + seed?: number; + stop?: string | string[]; + logit_bias?: Record; tools?: ToolDefinition[]; - response_format?: { type: "json_object" } | { type: "text" }; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; } export interface ToolDefinition { type: "function"; - function: { - name: string; - description?: string; - parameters?: Record; // JSON Schema - }; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; // JSON Schema + strict?: boolean; // For strict structured outputs } export interface MachineInvoker { diff --git a/assets/profiles.d.ts b/assets/profiles.d.ts index 8487debc..ee5ed1a6 100644 --- a/assets/profiles.d.ts +++ b/assets/profiles.d.ts @@ -95,16 +95,19 @@ * MODELPROFILECONFIG FIELDS: * -------------------------- * Defines all parameters for an LLM model. - * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter (0.0 to 1.0) - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom base URL for the API (e.g., for local models or proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter (0.0 to 1.0) + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom base URL for the API (e.g., for local models or proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) */ export const SPEC_VERSION = "0.9.0"; @@ -131,8 +134,11 @@ export interface ModelProfileConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; } export type FlatprofilesConfig = ProfilesWrapper; diff --git a/flatagent.d.ts b/flatagent.d.ts index cb4f0c29..7c5fb9fd 100644 --- a/flatagent.d.ts +++ b/flatagent.d.ts @@ -36,16 +36,32 @@ * * MODEL FIELDS: * ------------- - * name - Model name (e.g., "gpt-4", "zai-glm-4.6") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom API base URL (for local models/proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom API base URL (for local models/proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) + * + * TOOL CALLING FIELDS: + * -------------------- + * tools - Array of tool definitions for function calling + * tool_choice - Control tool usage: "none", "auto", "required", or specific tool + * parallel_tool_calls - Allow multiple tool calls in single response (default: true) + * + * RESPONSE FORMAT FIELDS: + * ----------------------- + * response_format - Control output format: + * - { type: "text" } - Plain text output (default) + * - { type: "json_object" } - Valid JSON output + * - { type: "json_schema", json_schema: {...} } - Structured output with schema * * MODEL PROFILES: * --------------- @@ -197,8 +213,45 @@ export interface ModelConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; +} + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; } export interface ProfiledModelConfig extends Partial { diff --git a/flatagents-runtime.d.ts b/flatagents-runtime.d.ts index 6fbc796c..97545c86 100644 --- a/flatagents-runtime.d.ts +++ b/flatagents-runtime.d.ts @@ -260,17 +260,48 @@ export interface ToolCall { export interface LLMOptions { temperature?: number; max_tokens?: number; + top_p?: number; + top_k?: number; + frequency_penalty?: number; + presence_penalty?: number; + repetition_penalty?: number; + seed?: number; + stop?: string | string[]; + logit_bias?: Record; tools?: ToolDefinition[]; - response_format?: { type: "json_object" } | { type: "text" }; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; } export interface ToolDefinition { type: "function"; - function: { - name: string; - description?: string; - parameters?: Record; // JSON Schema - }; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; // JSON Schema + strict?: boolean; // For strict structured outputs } export interface MachineInvoker { diff --git a/profiles.d.ts b/profiles.d.ts index 8487debc..ee5ed1a6 100644 --- a/profiles.d.ts +++ b/profiles.d.ts @@ -95,16 +95,19 @@ * MODELPROFILECONFIG FIELDS: * -------------------------- * Defines all parameters for an LLM model. - * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter (0.0 to 1.0) - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom base URL for the API (e.g., for local models or proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter (0.0 to 1.0) + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom base URL for the API (e.g., for local models or proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) */ export const SPEC_VERSION = "0.9.0"; @@ -131,8 +134,11 @@ export interface ModelProfileConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; } export type FlatprofilesConfig = ProfilesWrapper; diff --git a/sdk/js/schemas/flatagent.d.ts b/sdk/js/schemas/flatagent.d.ts index cb4f0c29..7c5fb9fd 100644 --- a/sdk/js/schemas/flatagent.d.ts +++ b/sdk/js/schemas/flatagent.d.ts @@ -36,16 +36,32 @@ * * MODEL FIELDS: * ------------- - * name - Model name (e.g., "gpt-4", "zai-glm-4.6") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom API base URL (for local models/proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom API base URL (for local models/proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) + * + * TOOL CALLING FIELDS: + * -------------------- + * tools - Array of tool definitions for function calling + * tool_choice - Control tool usage: "none", "auto", "required", or specific tool + * parallel_tool_calls - Allow multiple tool calls in single response (default: true) + * + * RESPONSE FORMAT FIELDS: + * ----------------------- + * response_format - Control output format: + * - { type: "text" } - Plain text output (default) + * - { type: "json_object" } - Valid JSON output + * - { type: "json_schema", json_schema: {...} } - Structured output with schema * * MODEL PROFILES: * --------------- @@ -197,8 +213,45 @@ export interface ModelConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; +} + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; } export interface ProfiledModelConfig extends Partial { diff --git a/sdk/js/schemas/flatagents-runtime.d.ts b/sdk/js/schemas/flatagents-runtime.d.ts index 6fbc796c..97545c86 100644 --- a/sdk/js/schemas/flatagents-runtime.d.ts +++ b/sdk/js/schemas/flatagents-runtime.d.ts @@ -260,17 +260,48 @@ export interface ToolCall { export interface LLMOptions { temperature?: number; max_tokens?: number; + top_p?: number; + top_k?: number; + frequency_penalty?: number; + presence_penalty?: number; + repetition_penalty?: number; + seed?: number; + stop?: string | string[]; + logit_bias?: Record; tools?: ToolDefinition[]; - response_format?: { type: "json_object" } | { type: "text" }; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; } export interface ToolDefinition { type: "function"; - function: { - name: string; - description?: string; - parameters?: Record; // JSON Schema - }; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; // JSON Schema + strict?: boolean; // For strict structured outputs } export interface MachineInvoker { diff --git a/sdk/js/schemas/profiles.d.ts b/sdk/js/schemas/profiles.d.ts index 8487debc..ee5ed1a6 100644 --- a/sdk/js/schemas/profiles.d.ts +++ b/sdk/js/schemas/profiles.d.ts @@ -95,16 +95,19 @@ * MODELPROFILECONFIG FIELDS: * -------------------------- * Defines all parameters for an LLM model. - * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter (0.0 to 1.0) - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom base URL for the API (e.g., for local models or proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter (0.0 to 1.0) + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom base URL for the API (e.g., for local models or proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) */ export const SPEC_VERSION = "0.9.0"; @@ -131,8 +134,11 @@ export interface ModelProfileConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; } export type FlatprofilesConfig = ProfilesWrapper; diff --git a/sdk/js/src/flatagent.ts b/sdk/js/src/flatagent.ts index 17c73b93..e502a10f 100644 --- a/sdk/js/src/flatagent.ts +++ b/sdk/js/src/flatagent.ts @@ -131,7 +131,14 @@ export class FlatAgent { top_k: this.resolvedModelConfig.top_k, frequency_penalty: this.resolvedModelConfig.frequency_penalty, presence_penalty: this.resolvedModelConfig.presence_penalty, + repetition_penalty: this.resolvedModelConfig.repetition_penalty, seed: this.resolvedModelConfig.seed, + stop: this.resolvedModelConfig.stop, + logit_bias: this.resolvedModelConfig.logit_bias, + tools: this.resolvedModelConfig.tools, + tool_choice: this.resolvedModelConfig.tool_choice, + parallel_tool_calls: this.resolvedModelConfig.parallel_tool_calls, + response_format: this.resolvedModelConfig.response_format, }); // Extract structured output diff --git a/sdk/js/src/llm/types.ts b/sdk/js/src/llm/types.ts index 8e48508a..93c54784 100644 --- a/sdk/js/src/llm/types.ts +++ b/sdk/js/src/llm/types.ts @@ -28,18 +28,44 @@ export interface LLMOptions { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; + stop?: string | string[]; + logit_bias?: Record; tools?: ToolDefinition[]; - response_format?: { type: 'json_object' } | { type: 'text' }; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | 'none' + | 'auto' + | 'required' + | { type: 'function'; function: { name: string } }; + +export type ResponseFormat = + | { type: 'text' } + | { type: 'json_object' } + | { type: 'json_schema'; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; } export interface ToolDefinition { type: 'function'; - function: { - name: string; - description?: string; - parameters?: Record; // JSON Schema - }; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; // JSON Schema + strict?: boolean; } /** diff --git a/sdk/js/src/types.ts b/sdk/js/src/types.ts index 879630f3..94546990 100644 --- a/sdk/js/src/types.ts +++ b/sdk/js/src/types.ts @@ -11,8 +11,45 @@ export interface ModelConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; +} + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; } /** @@ -34,8 +71,11 @@ export interface ModelProfileConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; } /** diff --git a/sdk/python/flatagents/assets/flatagent.d.ts b/sdk/python/flatagents/assets/flatagent.d.ts index cb4f0c29..7c5fb9fd 100644 --- a/sdk/python/flatagents/assets/flatagent.d.ts +++ b/sdk/python/flatagents/assets/flatagent.d.ts @@ -36,16 +36,32 @@ * * MODEL FIELDS: * ------------- - * name - Model name (e.g., "gpt-4", "zai-glm-4.6") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom API base URL (for local models/proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom API base URL (for local models/proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) + * + * TOOL CALLING FIELDS: + * -------------------- + * tools - Array of tool definitions for function calling + * tool_choice - Control tool usage: "none", "auto", "required", or specific tool + * parallel_tool_calls - Allow multiple tool calls in single response (default: true) + * + * RESPONSE FORMAT FIELDS: + * ----------------------- + * response_format - Control output format: + * - { type: "text" } - Plain text output (default) + * - { type: "json_object" } - Valid JSON output + * - { type: "json_schema", json_schema: {...} } - Structured output with schema * * MODEL PROFILES: * --------------- @@ -197,8 +213,45 @@ export interface ModelConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; +} + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; } export interface ProfiledModelConfig extends Partial { diff --git a/sdk/python/flatagents/assets/flatagents-runtime.d.ts b/sdk/python/flatagents/assets/flatagents-runtime.d.ts index 6fbc796c..97545c86 100644 --- a/sdk/python/flatagents/assets/flatagents-runtime.d.ts +++ b/sdk/python/flatagents/assets/flatagents-runtime.d.ts @@ -260,17 +260,48 @@ export interface ToolCall { export interface LLMOptions { temperature?: number; max_tokens?: number; + top_p?: number; + top_k?: number; + frequency_penalty?: number; + presence_penalty?: number; + repetition_penalty?: number; + seed?: number; + stop?: string | string[]; + logit_bias?: Record; tools?: ToolDefinition[]; - response_format?: { type: "json_object" } | { type: "text" }; + tool_choice?: ToolChoice; + parallel_tool_calls?: boolean; + response_format?: ResponseFormat; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchemaResponseFormat }; + +export interface JsonSchemaResponseFormat { + name: string; + description?: string; + schema: Record; + strict?: boolean; } export interface ToolDefinition { type: "function"; - function: { - name: string; - description?: string; - parameters?: Record; // JSON Schema - }; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; // JSON Schema + strict?: boolean; // For strict structured outputs } export interface MachineInvoker { diff --git a/sdk/python/flatagents/assets/profiles.d.ts b/sdk/python/flatagents/assets/profiles.d.ts index 8487debc..ee5ed1a6 100644 --- a/sdk/python/flatagents/assets/profiles.d.ts +++ b/sdk/python/flatagents/assets/profiles.d.ts @@ -95,16 +95,19 @@ * MODELPROFILECONFIG FIELDS: * -------------------------- * Defines all parameters for an LLM model. - * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") - * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") - * temperature - Sampling temperature (0.0 to 2.0) - * max_tokens - Maximum tokens to generate - * top_p - Nucleus sampling parameter (0.0 to 1.0) - * top_k - Top-k sampling parameter - * frequency_penalty - Frequency penalty (-2.0 to 2.0) - * presence_penalty - Presence penalty (-2.0 to 2.0) - * seed - Random seed for reproducibility - * base_url - Custom base URL for the API (e.g., for local models or proxies) + * name - Model name (e.g., "gpt-4", "zai-glm-4.6", "claude-3-opus-20240229") + * provider - Provider name (e.g., "openai", "anthropic", "cerebras", "ollama") + * temperature - Sampling temperature (0.0 to 2.0) + * max_tokens - Maximum tokens to generate + * top_p - Nucleus sampling parameter (0.0 to 1.0) + * top_k - Top-k sampling parameter + * frequency_penalty - Frequency penalty (-2.0 to 2.0) + * presence_penalty - Presence penalty (-2.0 to 2.0) + * repetition_penalty - Alternative repetition penalty (some providers) + * seed - Random seed for reproducibility + * base_url - Custom base URL for the API (e.g., for local models or proxies) + * stop - Stop sequence(s) to end generation + * logit_bias - Token bias map (token_id -> bias value) */ export const SPEC_VERSION = "0.9.0"; @@ -131,8 +134,11 @@ export interface ModelProfileConfig { top_k?: number; frequency_penalty?: number; presence_penalty?: number; + repetition_penalty?: number; seed?: number; base_url?: string; + stop?: string | string[]; + logit_bias?: Record; } export type FlatprofilesConfig = ProfilesWrapper; From 135ce311069148d43bcab54e2768155d1e6ea048 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 19:00:46 +0000 Subject: [PATCH 7/8] Add multi-round tool calling support to FlatMachine Implements tool_loop feature for agent states: - Add tool_loop config (boolean or ToolLoopConfig with max_rounds) - Add tools/tool_choice fields to StateDefinition - Add on_tool_call hook for executing tool calls - Implement tool loop execution in Python SDK flatmachine.py - Update FlatAgent.call() to accept _tools and _tool_choice params Example usage: states: research: agent: researcher tool_loop: max_rounds: 10 tools: - type: function function: name: search parameters: { type: object, properties: { query: { type: string } } } input: question: "{{ context.question }}" Hooks must implement on_tool_call(tool_name, arguments, context) to execute tools and return results. https://claude.ai/code/session_01GaVWKmhxt2ncfpUtNHvNc4 --- assets/flatagents-runtime.d.ts | 11 ++ assets/flatmachine.d.ts | 102 +++++++++- flatagents-runtime.d.ts | 11 ++ flatmachine.d.ts | 102 +++++++++- sdk/js/schemas/flatagents-runtime.d.ts | 11 ++ sdk/js/schemas/flatmachine.d.ts | 102 +++++++++- sdk/js/src/types.ts | 17 +- .../flatagents/assets/flatagents-runtime.d.ts | 11 ++ sdk/python/flatagents/assets/flatmachine.d.ts | 102 +++++++++- sdk/python/flatagents/flatagent.py | 27 ++- sdk/python/flatagents/flatmachine.py | 179 +++++++++++++++++- sdk/python/flatagents/hooks.py | 76 +++++++- 12 files changed, 734 insertions(+), 17 deletions(-) diff --git a/assets/flatagents-runtime.d.ts b/assets/flatagents-runtime.d.ts index 97545c86..90038b2c 100644 --- a/assets/flatagents-runtime.d.ts +++ b/assets/flatagents-runtime.d.ts @@ -225,6 +225,17 @@ export interface MachineHooks { /** Called for custom actions defined in state config. */ onAction?(action: string, context: Record): Record | Promise>; + + /** + * Called when an agent requests a tool call during tool_loop execution. + * Must return the tool result which will be sent back to the agent. + * + * @param toolName - Name of the tool to execute + * @param arguments - Arguments passed to the tool (parsed from JSON) + * @param context - Current machine context + * @returns Tool execution result (will be JSON serialized for the agent) + */ + onToolCall?(toolName: string, arguments: Record, context: Record): any | Promise; } export interface LLMBackend { diff --git a/assets/flatmachine.d.ts b/assets/flatmachine.d.ts index 05a0ff34..a5fbc421 100644 --- a/assets/flatmachine.d.ts +++ b/assets/flatmachine.d.ts @@ -43,6 +43,9 @@ * output_to_context - Map agent output to context (Jinja2 templates) * output - Final output (for final states) * transitions - Ordered list of transitions + * tool_loop - Enable multi-round tool calling (boolean or ToolLoopConfig) + * tools - Tool definitions for this state (OpenAI function format) + * tool_choice - Control tool usage: "none", "auto", "required", or specific * * PARALLEL EXECUTION (v0.4.0): * ---------------------------- @@ -203,6 +206,45 @@ * transitions: * - to: continue_immediately * + * TOOL LOOP EXAMPLE: + * ------------------ + * Multi-round tool calling allows an agent to call tools repeatedly until + * it has enough information to produce a final response. + * + * states: + * research: + * agent: researcher + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search_web + * description: Search the web for information + * parameters: + * type: object + * properties: + * query: { type: string, description: "Search query" } + * required: [query] + * - type: function + * function: + * name: read_url + * description: Read content from a URL + * parameters: + * type: object + * properties: + * url: { type: string, description: "URL to read" } + * required: [url] + * input: + * question: "{{ context.question }}" + * output_to_context: + * answer: "{{ output.answer }}" + * transitions: + * - to: done + * + * The machine will call hooks.on_tool_call(tool_name, arguments) for each + * tool call. Hooks must implement this method to execute tools and return results. + * * PERSISTENCE (v0.2.0): * -------------------- * MachineSnapshot - Wire format for checkpoints (execution_id, state, context, step) @@ -324,7 +366,9 @@ export interface StateDefinition { output_to_context?: Record; output?: Record; transitions?: Transition[]; - tool_loop?: boolean; + tool_loop?: boolean | ToolLoopConfig; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; sampling?: "single" | "multi"; foreach?: string; as?: string; @@ -335,6 +379,62 @@ export interface StateDefinition { launch_input?: Record; } +/** + * Configuration for multi-round tool calling loops. + * + * When tool_loop is enabled on a state with an agent, the machine will: + * 1. Call the agent with available tools + * 2. If the agent returns tool calls, execute them + * 3. Send tool results back to the agent + * 4. Repeat until agent returns final response (no tool calls) or max_rounds reached + * + * Example: + * states: + * process: + * agent: assistant + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search + * description: Search the knowledge base + * parameters: + * type: object + * properties: + * query: { type: string } + * input: + * question: "{{ context.question }}" + */ +export interface ToolLoopConfig { + /** Maximum number of tool call rounds (default: 10) */ + max_rounds?: number; + /** Tool definitions (OpenAI function calling format) */ + tools?: ToolDefinition[]; + /** Control tool usage: "none", "auto", "required", or specific tool */ + tool_choice?: ToolChoice; + /** Allow parallel tool calls in single response (default: true) */ + parallel_tool_calls?: boolean; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; +} + export interface MachineInput { name: string; input?: Record; diff --git a/flatagents-runtime.d.ts b/flatagents-runtime.d.ts index 97545c86..90038b2c 100644 --- a/flatagents-runtime.d.ts +++ b/flatagents-runtime.d.ts @@ -225,6 +225,17 @@ export interface MachineHooks { /** Called for custom actions defined in state config. */ onAction?(action: string, context: Record): Record | Promise>; + + /** + * Called when an agent requests a tool call during tool_loop execution. + * Must return the tool result which will be sent back to the agent. + * + * @param toolName - Name of the tool to execute + * @param arguments - Arguments passed to the tool (parsed from JSON) + * @param context - Current machine context + * @returns Tool execution result (will be JSON serialized for the agent) + */ + onToolCall?(toolName: string, arguments: Record, context: Record): any | Promise; } export interface LLMBackend { diff --git a/flatmachine.d.ts b/flatmachine.d.ts index 05a0ff34..a5fbc421 100644 --- a/flatmachine.d.ts +++ b/flatmachine.d.ts @@ -43,6 +43,9 @@ * output_to_context - Map agent output to context (Jinja2 templates) * output - Final output (for final states) * transitions - Ordered list of transitions + * tool_loop - Enable multi-round tool calling (boolean or ToolLoopConfig) + * tools - Tool definitions for this state (OpenAI function format) + * tool_choice - Control tool usage: "none", "auto", "required", or specific * * PARALLEL EXECUTION (v0.4.0): * ---------------------------- @@ -203,6 +206,45 @@ * transitions: * - to: continue_immediately * + * TOOL LOOP EXAMPLE: + * ------------------ + * Multi-round tool calling allows an agent to call tools repeatedly until + * it has enough information to produce a final response. + * + * states: + * research: + * agent: researcher + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search_web + * description: Search the web for information + * parameters: + * type: object + * properties: + * query: { type: string, description: "Search query" } + * required: [query] + * - type: function + * function: + * name: read_url + * description: Read content from a URL + * parameters: + * type: object + * properties: + * url: { type: string, description: "URL to read" } + * required: [url] + * input: + * question: "{{ context.question }}" + * output_to_context: + * answer: "{{ output.answer }}" + * transitions: + * - to: done + * + * The machine will call hooks.on_tool_call(tool_name, arguments) for each + * tool call. Hooks must implement this method to execute tools and return results. + * * PERSISTENCE (v0.2.0): * -------------------- * MachineSnapshot - Wire format for checkpoints (execution_id, state, context, step) @@ -324,7 +366,9 @@ export interface StateDefinition { output_to_context?: Record; output?: Record; transitions?: Transition[]; - tool_loop?: boolean; + tool_loop?: boolean | ToolLoopConfig; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; sampling?: "single" | "multi"; foreach?: string; as?: string; @@ -335,6 +379,62 @@ export interface StateDefinition { launch_input?: Record; } +/** + * Configuration for multi-round tool calling loops. + * + * When tool_loop is enabled on a state with an agent, the machine will: + * 1. Call the agent with available tools + * 2. If the agent returns tool calls, execute them + * 3. Send tool results back to the agent + * 4. Repeat until agent returns final response (no tool calls) or max_rounds reached + * + * Example: + * states: + * process: + * agent: assistant + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search + * description: Search the knowledge base + * parameters: + * type: object + * properties: + * query: { type: string } + * input: + * question: "{{ context.question }}" + */ +export interface ToolLoopConfig { + /** Maximum number of tool call rounds (default: 10) */ + max_rounds?: number; + /** Tool definitions (OpenAI function calling format) */ + tools?: ToolDefinition[]; + /** Control tool usage: "none", "auto", "required", or specific tool */ + tool_choice?: ToolChoice; + /** Allow parallel tool calls in single response (default: true) */ + parallel_tool_calls?: boolean; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; +} + export interface MachineInput { name: string; input?: Record; diff --git a/sdk/js/schemas/flatagents-runtime.d.ts b/sdk/js/schemas/flatagents-runtime.d.ts index 97545c86..90038b2c 100644 --- a/sdk/js/schemas/flatagents-runtime.d.ts +++ b/sdk/js/schemas/flatagents-runtime.d.ts @@ -225,6 +225,17 @@ export interface MachineHooks { /** Called for custom actions defined in state config. */ onAction?(action: string, context: Record): Record | Promise>; + + /** + * Called when an agent requests a tool call during tool_loop execution. + * Must return the tool result which will be sent back to the agent. + * + * @param toolName - Name of the tool to execute + * @param arguments - Arguments passed to the tool (parsed from JSON) + * @param context - Current machine context + * @returns Tool execution result (will be JSON serialized for the agent) + */ + onToolCall?(toolName: string, arguments: Record, context: Record): any | Promise; } export interface LLMBackend { diff --git a/sdk/js/schemas/flatmachine.d.ts b/sdk/js/schemas/flatmachine.d.ts index 05a0ff34..a5fbc421 100644 --- a/sdk/js/schemas/flatmachine.d.ts +++ b/sdk/js/schemas/flatmachine.d.ts @@ -43,6 +43,9 @@ * output_to_context - Map agent output to context (Jinja2 templates) * output - Final output (for final states) * transitions - Ordered list of transitions + * tool_loop - Enable multi-round tool calling (boolean or ToolLoopConfig) + * tools - Tool definitions for this state (OpenAI function format) + * tool_choice - Control tool usage: "none", "auto", "required", or specific * * PARALLEL EXECUTION (v0.4.0): * ---------------------------- @@ -203,6 +206,45 @@ * transitions: * - to: continue_immediately * + * TOOL LOOP EXAMPLE: + * ------------------ + * Multi-round tool calling allows an agent to call tools repeatedly until + * it has enough information to produce a final response. + * + * states: + * research: + * agent: researcher + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search_web + * description: Search the web for information + * parameters: + * type: object + * properties: + * query: { type: string, description: "Search query" } + * required: [query] + * - type: function + * function: + * name: read_url + * description: Read content from a URL + * parameters: + * type: object + * properties: + * url: { type: string, description: "URL to read" } + * required: [url] + * input: + * question: "{{ context.question }}" + * output_to_context: + * answer: "{{ output.answer }}" + * transitions: + * - to: done + * + * The machine will call hooks.on_tool_call(tool_name, arguments) for each + * tool call. Hooks must implement this method to execute tools and return results. + * * PERSISTENCE (v0.2.0): * -------------------- * MachineSnapshot - Wire format for checkpoints (execution_id, state, context, step) @@ -324,7 +366,9 @@ export interface StateDefinition { output_to_context?: Record; output?: Record; transitions?: Transition[]; - tool_loop?: boolean; + tool_loop?: boolean | ToolLoopConfig; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; sampling?: "single" | "multi"; foreach?: string; as?: string; @@ -335,6 +379,62 @@ export interface StateDefinition { launch_input?: Record; } +/** + * Configuration for multi-round tool calling loops. + * + * When tool_loop is enabled on a state with an agent, the machine will: + * 1. Call the agent with available tools + * 2. If the agent returns tool calls, execute them + * 3. Send tool results back to the agent + * 4. Repeat until agent returns final response (no tool calls) or max_rounds reached + * + * Example: + * states: + * process: + * agent: assistant + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search + * description: Search the knowledge base + * parameters: + * type: object + * properties: + * query: { type: string } + * input: + * question: "{{ context.question }}" + */ +export interface ToolLoopConfig { + /** Maximum number of tool call rounds (default: 10) */ + max_rounds?: number; + /** Tool definitions (OpenAI function calling format) */ + tools?: ToolDefinition[]; + /** Control tool usage: "none", "auto", "required", or specific tool */ + tool_choice?: ToolChoice; + /** Allow parallel tool calls in single response (default: true) */ + parallel_tool_calls?: boolean; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; +} + export interface MachineInput { name: string; input?: Record; diff --git a/sdk/js/src/types.ts b/sdk/js/src/types.ts index 94546990..363e59bf 100644 --- a/sdk/js/src/types.ts +++ b/sdk/js/src/types.ts @@ -52,6 +52,20 @@ export interface ToolFunction { strict?: boolean; } +/** + * Configuration for multi-round tool calling loops. + */ +export interface ToolLoopConfig { + /** Maximum number of tool call rounds (default: 10) */ + max_rounds?: number; + /** Tool definitions (OpenAI function calling format) */ + tools?: ToolDefinition[]; + /** Control tool usage */ + tool_choice?: ToolChoice; + /** Allow parallel tool calls in single response (default: true) */ + parallel_tool_calls?: boolean; +} + /** * Model config that references a profile with optional overrides. */ @@ -137,6 +151,8 @@ export interface State { output_to_context?: Record; output?: Record; transitions?: { condition?: string; to: string }[]; + tool_loop?: boolean | ToolLoopConfig; + tools?: ToolDefinition[]; on_error?: string | Record; foreach?: string; as?: string; @@ -145,7 +161,6 @@ export interface State { timeout?: number; launch?: string | string[]; launch_input?: Record; - tool_loop?: boolean; sampling?: "single" | "multi"; } diff --git a/sdk/python/flatagents/assets/flatagents-runtime.d.ts b/sdk/python/flatagents/assets/flatagents-runtime.d.ts index 97545c86..90038b2c 100644 --- a/sdk/python/flatagents/assets/flatagents-runtime.d.ts +++ b/sdk/python/flatagents/assets/flatagents-runtime.d.ts @@ -225,6 +225,17 @@ export interface MachineHooks { /** Called for custom actions defined in state config. */ onAction?(action: string, context: Record): Record | Promise>; + + /** + * Called when an agent requests a tool call during tool_loop execution. + * Must return the tool result which will be sent back to the agent. + * + * @param toolName - Name of the tool to execute + * @param arguments - Arguments passed to the tool (parsed from JSON) + * @param context - Current machine context + * @returns Tool execution result (will be JSON serialized for the agent) + */ + onToolCall?(toolName: string, arguments: Record, context: Record): any | Promise; } export interface LLMBackend { diff --git a/sdk/python/flatagents/assets/flatmachine.d.ts b/sdk/python/flatagents/assets/flatmachine.d.ts index 05a0ff34..a5fbc421 100644 --- a/sdk/python/flatagents/assets/flatmachine.d.ts +++ b/sdk/python/flatagents/assets/flatmachine.d.ts @@ -43,6 +43,9 @@ * output_to_context - Map agent output to context (Jinja2 templates) * output - Final output (for final states) * transitions - Ordered list of transitions + * tool_loop - Enable multi-round tool calling (boolean or ToolLoopConfig) + * tools - Tool definitions for this state (OpenAI function format) + * tool_choice - Control tool usage: "none", "auto", "required", or specific * * PARALLEL EXECUTION (v0.4.0): * ---------------------------- @@ -203,6 +206,45 @@ * transitions: * - to: continue_immediately * + * TOOL LOOP EXAMPLE: + * ------------------ + * Multi-round tool calling allows an agent to call tools repeatedly until + * it has enough information to produce a final response. + * + * states: + * research: + * agent: researcher + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search_web + * description: Search the web for information + * parameters: + * type: object + * properties: + * query: { type: string, description: "Search query" } + * required: [query] + * - type: function + * function: + * name: read_url + * description: Read content from a URL + * parameters: + * type: object + * properties: + * url: { type: string, description: "URL to read" } + * required: [url] + * input: + * question: "{{ context.question }}" + * output_to_context: + * answer: "{{ output.answer }}" + * transitions: + * - to: done + * + * The machine will call hooks.on_tool_call(tool_name, arguments) for each + * tool call. Hooks must implement this method to execute tools and return results. + * * PERSISTENCE (v0.2.0): * -------------------- * MachineSnapshot - Wire format for checkpoints (execution_id, state, context, step) @@ -324,7 +366,9 @@ export interface StateDefinition { output_to_context?: Record; output?: Record; transitions?: Transition[]; - tool_loop?: boolean; + tool_loop?: boolean | ToolLoopConfig; + tools?: ToolDefinition[]; + tool_choice?: ToolChoice; sampling?: "single" | "multi"; foreach?: string; as?: string; @@ -335,6 +379,62 @@ export interface StateDefinition { launch_input?: Record; } +/** + * Configuration for multi-round tool calling loops. + * + * When tool_loop is enabled on a state with an agent, the machine will: + * 1. Call the agent with available tools + * 2. If the agent returns tool calls, execute them + * 3. Send tool results back to the agent + * 4. Repeat until agent returns final response (no tool calls) or max_rounds reached + * + * Example: + * states: + * process: + * agent: assistant + * tool_loop: + * max_rounds: 10 + * tools: + * - type: function + * function: + * name: search + * description: Search the knowledge base + * parameters: + * type: object + * properties: + * query: { type: string } + * input: + * question: "{{ context.question }}" + */ +export interface ToolLoopConfig { + /** Maximum number of tool call rounds (default: 10) */ + max_rounds?: number; + /** Tool definitions (OpenAI function calling format) */ + tools?: ToolDefinition[]; + /** Control tool usage: "none", "auto", "required", or specific tool */ + tool_choice?: ToolChoice; + /** Allow parallel tool calls in single response (default: true) */ + parallel_tool_calls?: boolean; +} + +export type ToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; + +export interface ToolDefinition { + type: "function"; + function: ToolFunction; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: Record; + strict?: boolean; +} + export interface MachineInput { name: string; input?: Record; diff --git a/sdk/python/flatagents/flatagent.py b/sdk/python/flatagents/flatagent.py index dc14b9ba..2eb72b6e 100644 --- a/sdk/python/flatagents/flatagent.py +++ b/sdk/python/flatagents/flatagent.py @@ -629,6 +629,8 @@ async def call( self, tool_provider: Optional["MCPToolProvider"] = None, messages: Optional[List[Dict[str, Any]]] = None, + _tools: Optional[List[Dict[str, Any]]] = None, + _tool_choice: Optional[str] = None, **input_data ) -> "AgentResponse": """ @@ -637,6 +639,8 @@ async def call( Args: tool_provider: Optional MCPToolProvider (overrides constructor value) messages: Optional conversation history (for tool call continuations) + _tools: Optional tool definitions (OpenAI format) - used by tool_loop + _tool_choice: Optional tool choice - used by tool_loop **input_data: Input values available as {{ input.* }} in templates Returns: @@ -649,8 +653,14 @@ async def call( self._tool_provider = tool_provider self._tools_cache = None # Clear cache - # Discover tools if MCP is configured - tools = self._discover_tools() + # Use explicit tools if provided, otherwise discover from MCP + if _tools: + tools = [] # Don't use MCP tools when explicit tools provided + llm_tools = _tools # Already in OpenAI format + else: + tools = self._discover_tools() + llm_tools = self._convert_tools_for_llm(tools) if tools else None + tools_prompt = self._render_tool_prompt(tools) # Render prompts @@ -695,11 +705,13 @@ async def call( params["api_base"] = self.base_url # litellm uses api_base # Add tools if available - if tools: - params["tools"] = self._convert_tools_for_llm(tools) + if llm_tools: + params["tools"] = llm_tools + if _tool_choice: + params["tool_choice"] = _tool_choice # Use JSON mode if we have an output schema and no tools - if self.output_schema and not tools: + if self.output_schema and not llm_tools: params["response_format"] = {"type": "json_object"} # Call LLM via selected backend with metrics tracking @@ -729,7 +741,7 @@ async def call( # Parse output schema if applicable output = None - if self.output_schema and content and not tools: + if self.output_schema and content and not llm_tools: try: # Strip markdown fences - LLMs sometimes wrap JSON in ```json blocks output = json.loads(strip_markdown_json(content)) @@ -743,7 +755,8 @@ async def call( tool_calls = [] for tc in message.tool_calls: tool_name = tc.function.name - server = self._find_tool_server(tool_name, tools) + # Only look up server for MCP tools, not explicit tools + server = self._find_tool_server(tool_name, tools) if tools else "" try: arguments = json.loads(tc.function.arguments) diff --git a/sdk/python/flatagents/flatmachine.py b/sdk/python/flatagents/flatmachine.py index 9aca64e4..034a5872 100644 --- a/sdk/python/flatagents/flatmachine.py +++ b/sdk/python/flatagents/flatmachine.py @@ -1020,9 +1020,26 @@ async def _execute_state( pre_calls = agent.total_api_calls pre_cost = agent.total_cost - execution_config = state.get('execution') - execution_type = get_execution_type(execution_config) - output = await execution_type.execute(agent, agent_input) + # Check for tool_loop configuration + tool_loop_config = state.get('tool_loop') + state_tools = state.get('tools') + state_tool_choice = state.get('tool_choice') + + if tool_loop_config or state_tools: + # Execute with tool loop + output = await self._execute_tool_loop( + agent=agent, + agent_input=agent_input, + tool_loop_config=tool_loop_config, + state_tools=state_tools, + state_tool_choice=state_tool_choice, + context=context, + ) + else: + # Standard execution without tools + execution_config = state.get('execution') + execution_type = get_execution_type(execution_config) + output = await execution_type.execute(agent, agent_input) self.total_api_calls += agent.total_api_calls - pre_calls self.total_cost += agent.total_cost - pre_cost @@ -1045,6 +1062,162 @@ async def _execute_state( return context, output + async def _execute_tool_loop( + self, + agent: FlatAgent, + agent_input: Dict[str, Any], + tool_loop_config: Any, + state_tools: Optional[list], + state_tool_choice: Optional[str], + context: Dict[str, Any], + ) -> Dict[str, Any]: + """ + Execute an agent with multi-round tool calling. + + The loop continues until: + - The agent returns a response without tool calls + - max_rounds is reached + - An error occurs + + Args: + agent: The FlatAgent to execute + agent_input: Input for the agent + tool_loop_config: Tool loop configuration (bool or dict) + state_tools: Tool definitions from state config + state_tool_choice: Tool choice from state config + context: Current machine context + + Returns: + Final agent output after tool loop completes + """ + # Parse tool_loop config + if isinstance(tool_loop_config, dict): + max_rounds = tool_loop_config.get('max_rounds', 10) + config_tools = tool_loop_config.get('tools') + config_tool_choice = tool_loop_config.get('tool_choice') + else: + max_rounds = 10 + config_tools = None + config_tool_choice = None + + # Tools priority: state.tools > tool_loop.tools + tools = state_tools or config_tools or [] + tool_choice = state_tool_choice or config_tool_choice or 'auto' + + if not tools: + logger.warning("tool_loop enabled but no tools defined") + # Fall back to standard execution + response = await agent.call(**agent_input) + return response.output or {"content": response.content} + + # Build conversation history + messages = [] + current_round = 0 + + logger.info(f"Starting tool loop with {len(tools)} tools, max_rounds={max_rounds}") + + while current_round < max_rounds: + current_round += 1 + + # Call agent with tools + # Note: We pass tools via the LLM params, not MCP + # The agent.call method needs to support passing tools + response = await self._call_agent_with_tools( + agent=agent, + agent_input=agent_input if current_round == 1 else {}, + messages=messages if messages else None, + tools=tools, + tool_choice=tool_choice, + ) + + # Check for tool calls + if not response.tool_calls: + # No tool calls - we're done + logger.info(f"Tool loop completed after {current_round} round(s)") + return response.output or {"content": response.content} + + # Add assistant message with tool calls to history + messages.append({ + "role": "assistant", + "content": response.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.tool, + "arguments": json.dumps(tc.arguments), + } + } + for tc in response.tool_calls + ] + }) + + # Execute each tool call + for tc in response.tool_calls: + logger.debug(f"Executing tool: {tc.tool}({tc.arguments})") + + try: + # Call the hook to execute the tool + result = await self._run_hook( + 'on_tool_call', + tc.tool, + tc.arguments, + context + ) + + # Serialize result + if isinstance(result, (dict, list)): + result_str = json.dumps(result) + else: + result_str = str(result) + + except Exception as e: + logger.error(f"Tool execution failed: {tc.tool} - {e}") + result_str = json.dumps({"error": str(e)}) + + # Add tool result to messages + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result_str, + }) + + logger.debug(f"Round {current_round}: {len(response.tool_calls)} tool call(s) executed") + + # Max rounds reached + logger.warning(f"Tool loop hit max_rounds limit ({max_rounds})") + # Return the last response we got + return response.output or {"content": response.content} + + async def _call_agent_with_tools( + self, + agent: FlatAgent, + agent_input: Dict[str, Any], + messages: Optional[list], + tools: list, + tool_choice: str, + ): + """ + Call agent with tools parameter. + + This method adapts the agent.call() to pass tools directly + to the LLM rather than through MCP. + """ + # Build LLM params with tools + # We need to temporarily modify the agent's model config to include tools + # or call the LLM directly + + # For now, we'll call the agent and pass tools through messages context + # The agent.call supports a messages parameter for continuations + + return await agent.call( + messages=messages, + _tools=tools, + _tool_choice=tool_choice, + **agent_input + ) + async def _save_checkpoint( self, event: str, diff --git a/sdk/python/flatagents/hooks.py b/sdk/python/flatagents/hooks.py index 37891e8c..012aa278 100644 --- a/sdk/python/flatagents/hooks.py +++ b/sdk/python/flatagents/hooks.py @@ -149,17 +149,55 @@ def on_action( ) -> Dict[str, Any]: """ Called for custom hook actions defined in states. - + Args: action_name: Name of the action to execute context: Current context - + Returns: Modified context """ logger.warning(f"Unhandled action: {action_name}") return context + def on_tool_call( + self, + tool_name: str, + arguments: Dict[str, Any], + context: Dict[str, Any] + ) -> Any: + """ + Called when an agent requests a tool call during tool_loop execution. + + Must be implemented to execute tools and return results. + The result will be sent back to the agent for continued processing. + + Args: + tool_name: Name of the tool to execute + arguments: Arguments passed to the tool (parsed from JSON) + context: Current machine context (read-only, for reference) + + Returns: + Tool execution result (will be JSON serialized for the agent) + + Raises: + NotImplementedError: If tool_loop is used without implementing this method + + Example: + def on_tool_call(self, tool_name, arguments, context): + if tool_name == "search": + return self.search_engine.search(arguments["query"]) + elif tool_name == "read_file": + with open(arguments["path"]) as f: + return f.read() + else: + raise ValueError(f"Unknown tool: {tool_name}") + """ + raise NotImplementedError( + f"on_tool_call not implemented. Cannot execute tool '{tool_name}'. " + "Implement on_tool_call in your hooks class to use tool_loop." + ) + class LoggingHooks(MachineHooks): """Hooks that log all state transitions.""" @@ -274,6 +312,23 @@ def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str, Any] context = hook.on_action(action_name, context) return context + def on_tool_call( + self, + tool_name: str, + arguments: Dict[str, Any], + context: Dict[str, Any] + ) -> Any: + # Try each hook until one handles the tool call + for hook in self.hooks: + try: + return hook.on_tool_call(tool_name, arguments, context) + except NotImplementedError: + continue + # No hook handled it + raise NotImplementedError( + f"No hook in composite handles tool '{tool_name}'" + ) + class WebhookHooks(MachineHooks): """ @@ -371,6 +426,23 @@ async def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str return resp["context"] return context + async def on_tool_call( + self, + tool_name: str, + arguments: Dict[str, Any], + context: Dict[str, Any] + ) -> Any: + resp = await self._send("tool_call", { + "tool": tool_name, + "arguments": arguments, + "context": context + }) + if resp and "result" in resp: + return resp["result"] + raise NotImplementedError( + f"Webhook did not return result for tool '{tool_name}'" + ) + __all__ = [ "MachineHooks", From fc06424af42e3bd100ee79c598ef593755f969db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 19:18:04 +0000 Subject: [PATCH 8/8] Add integration tests for multi-round tool calling Tests use a real HTTP stub server (aiohttp) - no mocks: - test_single_tool_call_then_response: Basic single tool call flow - test_multiple_tool_calls_in_sequence: Sequential tool calls - test_tool_loop_max_rounds_limit: Verify max_rounds is respected - test_no_tool_calls_immediate_response: LLM responds without tools - test_tool_call_with_error_handling: Tool errors are handled gracefully - test_tool_results_passed_back_to_llm: Verify tool results in messages - test_tool_loop_disabled_no_tools: No tools when feature disabled - test_tool_with_dynamic_result: Tools with dynamic results Also adds dev dependencies (pytest, pytest-asyncio, aiohttp) to pyproject.toml. https://claude.ai/code/session_01GaVWKmhxt2ncfpUtNHvNc4 --- sdk/python/pyproject.toml | 1 + .../tests/integration/tool_loop/__init__.py | 1 + .../integration/tool_loop/test_tool_loop.py | 536 ++++++++++++++++++ 3 files changed, 538 insertions(+) create mode 100644 sdk/python/tests/integration/tool_loop/__init__.py create mode 100644 sdk/python/tests/integration/tool_loop/test_tool_loop.py diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index ab980b6e..d7c7ece6 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -36,6 +36,7 @@ metrics = [ "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp>=1.20.0", ] +dev = ["pytest", "pytest-asyncio", "aiohttp"] # Local development (no cloud dependencies) local = ["litellm", "aisuite[all]", "cel-python", "jsonschema>=4.0", "opentelemetry-api>=1.20.0", "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp>=1.20.0"] # Alias for local diff --git a/sdk/python/tests/integration/tool_loop/__init__.py b/sdk/python/tests/integration/tool_loop/__init__.py new file mode 100644 index 00000000..4be2cbf8 --- /dev/null +++ b/sdk/python/tests/integration/tool_loop/__init__.py @@ -0,0 +1 @@ +# Integration tests for tool_loop feature diff --git a/sdk/python/tests/integration/tool_loop/test_tool_loop.py b/sdk/python/tests/integration/tool_loop/test_tool_loop.py new file mode 100644 index 00000000..f7ed12bb --- /dev/null +++ b/sdk/python/tests/integration/tool_loop/test_tool_loop.py @@ -0,0 +1,536 @@ +""" +Integration tests for multi-round tool calling (tool_loop). + +Uses a real HTTP stub server to simulate LLM responses with tool calls. +No mocks - actual HTTP requests are made to verify the full flow. +""" + +import asyncio +import json +import os +import uuid +import pytest +from aiohttp import web +from typing import Dict, Any, List + +# Set dummy API key for litellm/openai client before importing flatagents +os.environ["OPENAI_API_KEY"] = "sk-stub-test-key-not-real" + +from flatagents.flatmachine import FlatMachine +from flatagents.hooks import MachineHooks + + +class StubLLMServer: + """ + Stub HTTP server that simulates an OpenAI-compatible chat completions API. + + Supports configurable responses including tool calls for testing + multi-round tool calling flows. + """ + + def __init__(self): + self.app = web.Application() + # Handle both /v1/chat/completions and /chat/completions (litellm uses the latter) + self.app.router.add_post('/v1/chat/completions', self.handle_chat) + self.app.router.add_post('/chat/completions', self.handle_chat) + self.runner = None + self.site = None + self.port = None + + # Track requests for assertions + self.requests: List[Dict[str, Any]] = [] + + # Response queue - pop from front for each request + self.responses: List[Dict[str, Any]] = [] + + def add_tool_call_response( + self, + tool_name: str, + arguments: Dict[str, Any], + content: str = "" + ): + """Queue a response that includes a tool call.""" + tool_call_id = f"call_{uuid.uuid4().hex[:8]}" + self.responses.append({ + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion", + "created": 1234567890, + "model": "stub-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": content, + "tool_calls": [{ + "id": tool_call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps(arguments) + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }) + + def add_final_response(self, content: str, output: Dict[str, Any] = None): + """Queue a final response (no tool calls).""" + # If output is provided, wrap content as JSON + if output: + content = json.dumps(output) + + self.responses.append({ + "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", + "object": "chat.completion", + "created": 1234567890, + "model": "stub-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": content, + }, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }) + + async def handle_chat(self, request: web.Request) -> web.Response: + """Handle chat completions requests.""" + body = await request.json() + self.requests.append(body) + + if not self.responses: + return web.json_response( + {"error": "No more stubbed responses"}, + status=500 + ) + + response = self.responses.pop(0) + return web.json_response(response) + + async def start(self) -> str: + """Start the server and return the base URL.""" + self.runner = web.AppRunner(self.app) + await self.runner.setup() + + # Find an available port + self.site = web.TCPSite(self.runner, 'localhost', 0) + await self.site.start() + + # Get the actual port + self.port = self.site._server.sockets[0].getsockname()[1] + return f"http://localhost:{self.port}" + + async def stop(self): + """Stop the server.""" + if self.runner: + await self.runner.cleanup() + + +class ToolExecutorHooks(MachineHooks): + """ + Hooks that execute tools and track calls for testing. + """ + + def __init__(self): + self.tool_calls: List[Dict[str, Any]] = [] + self.tool_results: Dict[str, Any] = {} + + def register_tool(self, name: str, result: Any): + """Register a tool and its expected result.""" + self.tool_results[name] = result + + def on_tool_call( + self, + tool_name: str, + arguments: Dict[str, Any], + context: Dict[str, Any] + ) -> Any: + """Execute a tool call and return the result.""" + self.tool_calls.append({ + "tool": tool_name, + "arguments": arguments, + }) + + if tool_name in self.tool_results: + result = self.tool_results[tool_name] + # If result is callable, call it with arguments + if callable(result): + return result(arguments) + return result + + raise ValueError(f"Unknown tool: {tool_name}") + + +def get_tool_loop_agent_config(base_url: str) -> Dict[str, Any]: + """Create an agent config pointing to stub server.""" + return { + "spec": "flatagent", + "spec_version": "0.9.0", + "data": { + "name": "tool-agent", + "model": { + "provider": "openai", + "name": "stub-model", + "base_url": base_url + }, + "system": "You are a helpful assistant that uses tools to answer questions.", + "user": "Question: {{ input.question }}" + } + } + + +def get_tool_loop_machine_config(base_url: str) -> Dict[str, Any]: + """Create a machine config with tool_loop enabled.""" + return { + "spec": "flatmachine", + "spec_version": "0.9.0", + "data": { + "name": "tool-loop-test", + "context": { + "question": "{{ input.question }}" + }, + "agents": { + "researcher": get_tool_loop_agent_config(base_url) + }, + "states": { + "research": { + "type": "initial", + "agent": "researcher", + "tool_loop": { + "max_rounds": 5 + }, + "tools": [ + { + "type": "function", + "function": { + "name": "search", + "description": "Search for information", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"} + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "calculate", + "description": "Perform a calculation", + "parameters": { + "type": "object", + "properties": { + "expression": {"type": "string"} + }, + "required": ["expression"] + } + } + } + ], + "input": { + "question": "{{ context.question }}" + }, + "output_to_context": { + "answer": "{{ output.content }}" + }, + "transitions": [ + {"to": "done"} + ] + }, + "done": { + "type": "final", + "output": { + "answer": "{{ context.answer }}" + } + } + } + } + } + + +@pytest.fixture +async def stub_server(): + """Fixture that provides a stub LLM server.""" + server = StubLLMServer() + base_url = await server.start() + yield server, base_url + await server.stop() + + +class TestToolLoopIntegration: + """Integration tests for tool_loop feature.""" + + @pytest.mark.asyncio + async def test_single_tool_call_then_response(self, stub_server): + """Test a single tool call followed by final response.""" + server, base_url = stub_server + + # Setup: LLM calls search tool, then returns final answer + server.add_tool_call_response( + tool_name="search", + arguments={"query": "weather in Paris"} + ) + server.add_final_response( + content="The weather in Paris is sunny and 22C." + ) + + # Setup hooks + hooks = ToolExecutorHooks() + hooks.register_tool("search", {"results": ["Paris: Sunny, 22C"]}) + + # Create and run machine + config = get_tool_loop_machine_config(base_url) + machine = FlatMachine(config_dict=config, hooks=hooks) + + result = await machine.execute(input={"question": "What's the weather in Paris?"}) + + # Verify tool was called + assert len(hooks.tool_calls) == 1 + assert hooks.tool_calls[0]["tool"] == "search" + assert hooks.tool_calls[0]["arguments"]["query"] == "weather in Paris" + + # Verify final answer + assert "sunny" in result["answer"].lower() or "22" in result["answer"] + + # Verify two requests were made to LLM (initial + after tool result) + assert len(server.requests) == 2 + + @pytest.mark.asyncio + async def test_multiple_tool_calls_in_sequence(self, stub_server): + """Test multiple sequential tool calls before final response.""" + server, base_url = stub_server + + # Setup: LLM calls search, then calculate, then returns answer + server.add_tool_call_response( + tool_name="search", + arguments={"query": "population of France"} + ) + server.add_tool_call_response( + tool_name="calculate", + arguments={"expression": "67000000 / 1000000"} + ) + server.add_final_response( + content="France has a population of 67 million." + ) + + # Setup hooks + hooks = ToolExecutorHooks() + hooks.register_tool("search", {"population": 67000000}) + hooks.register_tool("calculate", {"result": 67}) + + # Create and run machine + config = get_tool_loop_machine_config(base_url) + machine = FlatMachine(config_dict=config, hooks=hooks) + + result = await machine.execute(input={"question": "Population of France in millions?"}) + + # Verify both tools were called + assert len(hooks.tool_calls) == 2 + assert hooks.tool_calls[0]["tool"] == "search" + assert hooks.tool_calls[1]["tool"] == "calculate" + + # Verify three requests were made to LLM + assert len(server.requests) == 3 + + @pytest.mark.asyncio + async def test_tool_loop_max_rounds_limit(self, stub_server): + """Test that tool_loop respects max_rounds limit.""" + server, base_url = stub_server + + # Setup: LLM keeps calling tools (more than max_rounds) + for i in range(10): + server.add_tool_call_response( + tool_name="search", + arguments={"query": f"query_{i}"} + ) + # Final response (may not be reached due to max_rounds) + server.add_final_response(content="Final answer") + + # Setup hooks + hooks = ToolExecutorHooks() + hooks.register_tool("search", {"result": "found"}) + + # Create machine with max_rounds=3 + config = get_tool_loop_machine_config(base_url) + config["data"]["states"]["research"]["tool_loop"]["max_rounds"] = 3 + machine = FlatMachine(config_dict=config, hooks=hooks) + + result = await machine.execute(input={"question": "Test max rounds"}) + + # Should have stopped at max_rounds (3 tool calls) + assert len(hooks.tool_calls) == 3 + assert len(server.requests) == 3 + + @pytest.mark.asyncio + async def test_no_tool_calls_immediate_response(self, stub_server): + """Test when LLM responds without any tool calls.""" + server, base_url = stub_server + + # Setup: LLM returns answer immediately without tools + server.add_final_response( + content="I already know the answer: 42" + ) + + # Setup hooks + hooks = ToolExecutorHooks() + hooks.register_tool("search", {"result": "unused"}) + + # Create and run machine + config = get_tool_loop_machine_config(base_url) + machine = FlatMachine(config_dict=config, hooks=hooks) + + result = await machine.execute(input={"question": "What is the answer?"}) + + # No tool calls should have been made + assert len(hooks.tool_calls) == 0 + + # Only one request to LLM + assert len(server.requests) == 1 + + # Result should contain the answer + assert "42" in result["answer"] + + @pytest.mark.asyncio + async def test_tool_call_with_error_handling(self, stub_server): + """Test that tool errors are handled gracefully.""" + server, base_url = stub_server + + # Setup: LLM calls tool, tool fails, LLM handles error + server.add_tool_call_response( + tool_name="search", + arguments={"query": "something"} + ) + server.add_final_response( + content="I couldn't find the information due to an error." + ) + + # Setup hooks - tool will raise an error + hooks = ToolExecutorHooks() + def failing_search(args): + raise RuntimeError("Search service unavailable") + hooks.register_tool("search", failing_search) + + # Create and run machine + config = get_tool_loop_machine_config(base_url) + machine = FlatMachine(config_dict=config, hooks=hooks) + + result = await machine.execute(input={"question": "Test error handling"}) + + # Tool was attempted + assert len(hooks.tool_calls) == 1 + + # Machine should have continued and returned a result + assert "answer" in result + + @pytest.mark.asyncio + async def test_tool_results_passed_back_to_llm(self, stub_server): + """Test that tool results are correctly passed back to the LLM.""" + server, base_url = stub_server + + # Setup + server.add_tool_call_response( + tool_name="calculate", + arguments={"expression": "2 + 2"} + ) + server.add_final_response(content="The answer is 4.") + + # Setup hooks with specific result + hooks = ToolExecutorHooks() + hooks.register_tool("calculate", {"result": 4, "expression": "2 + 2"}) + + # Create and run machine + config = get_tool_loop_machine_config(base_url) + machine = FlatMachine(config_dict=config, hooks=hooks) + + await machine.execute(input={"question": "What is 2 + 2?"}) + + # Verify second request includes tool result in messages + assert len(server.requests) >= 2 + second_request = server.requests[1] + + # Should have tool message in messages + messages = second_request.get("messages", []) + tool_messages = [m for m in messages if m.get("role") == "tool"] + assert len(tool_messages) == 1 + + # Tool result should contain our data + tool_content = json.loads(tool_messages[0]["content"]) + assert tool_content["result"] == 4 + + @pytest.mark.asyncio + async def test_tool_loop_disabled_no_tools(self, stub_server): + """Test that without tool_loop, tools are not used.""" + server, base_url = stub_server + + # Setup + server.add_final_response(content="Direct answer without tools") + + # Create config without tool_loop + config = get_tool_loop_machine_config(base_url) + del config["data"]["states"]["research"]["tool_loop"] + del config["data"]["states"]["research"]["tools"] + + hooks = ToolExecutorHooks() + machine = FlatMachine(config_dict=config, hooks=hooks) + + result = await machine.execute(input={"question": "Test no tools"}) + + # No tool calls + assert len(hooks.tool_calls) == 0 + + # Request should not include tools + assert len(server.requests) == 1 + assert "tools" not in server.requests[0] or not server.requests[0].get("tools") + + +class TestToolLoopWithDynamicTools: + """Test tool_loop with dynamic tool behavior.""" + + @pytest.mark.asyncio + async def test_tool_with_dynamic_result(self, stub_server): + """Test tool that returns different results based on input.""" + server, base_url = stub_server + + # LLM will call search twice with different queries + server.add_tool_call_response( + tool_name="search", + arguments={"query": "capital of France"} + ) + server.add_tool_call_response( + tool_name="search", + arguments={"query": "population of Paris"} + ) + server.add_final_response( + content="Paris is the capital of France with a population of 2.1 million." + ) + + # Dynamic tool that returns different results + hooks = ToolExecutorHooks() + def dynamic_search(args): + query = args.get("query", "") + if "capital" in query: + return {"answer": "Paris"} + elif "population" in query: + return {"answer": "2.1 million"} + return {"answer": "Unknown"} + hooks.register_tool("search", dynamic_search) + + config = get_tool_loop_machine_config(base_url) + machine = FlatMachine(config_dict=config, hooks=hooks) + + result = await machine.execute(input={"question": "Tell me about Paris"}) + + # Both searches should have been made + assert len(hooks.tool_calls) == 2 + assert "capital" in hooks.tool_calls[0]["arguments"]["query"] + assert "population" in hooks.tool_calls[1]["arguments"]["query"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])