forked from Rockachopa/Timmy-time-dashboard
feat: Lightning interface, swarm routing, sovereignty audit, embodiment prep
Lightning Backend Interface: - Abstract LightningBackend with pluggable implementations - MockBackend for development (auto-settle invoices) - LndBackend stub with gRPC integration path documented - Backend factory for runtime selection via LIGHTNING_BACKEND env Intelligent Swarm Routing: - CapabilityManifest for agent skill declarations - Task scoring based on keywords + capabilities + bid price - RoutingDecision audit logging to SQLite - Agent stats tracking (wins, consideration rate) Sovereignty Audit: - Comprehensive audit report (docs/SOVEREIGNTY_AUDIT.md) - 9.2/10 sovereignty score - Documented all external dependencies and local alternatives Substrate-Agnostic Agent Interface: - TimAgent abstract base class - Perception/Action/Memory/Communication types - OllamaAdapter implementation - Foundation for future embodiment (robot, VR) Tests: - 36 new tests for Lightning and routing - 472 total tests passing - Maintained 0 warning policy
This commit is contained in:
0
src/agent_core/__init__.py
Normal file
0
src/agent_core/__init__.py
Normal file
368
src/agent_core/interface.py
Normal file
368
src/agent_core/interface.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""TimAgent Interface — The substrate-agnostic agent contract.
|
||||
|
||||
This is the foundation for embodiment. Whether Timmy runs on:
|
||||
- A server with Ollama (today)
|
||||
- A Raspberry Pi with sensors
|
||||
- A Boston Dynamics Spot robot
|
||||
- A VR avatar
|
||||
|
||||
The interface remains constant. Implementation varies.
|
||||
|
||||
Architecture:
|
||||
perceive() → reason → act()
|
||||
↑ ↓
|
||||
←←← remember() ←←←←←←┘
|
||||
|
||||
All methods return effects that can be logged, audited, and replayed.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum, auto
|
||||
from typing import Any, Optional
|
||||
import uuid
|
||||
|
||||
|
||||
class PerceptionType(Enum):
|
||||
"""Types of sensory input an agent can receive."""
|
||||
TEXT = auto() # Natural language
|
||||
IMAGE = auto() # Visual input
|
||||
AUDIO = auto() # Sound/speech
|
||||
SENSOR = auto() # Temperature, distance, etc.
|
||||
MOTION = auto() # Accelerometer, gyroscope
|
||||
NETWORK = auto() # API calls, messages
|
||||
INTERNAL = auto() # Self-monitoring (battery, temp)
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Types of actions an agent can perform."""
|
||||
TEXT = auto() # Generate text response
|
||||
SPEAK = auto() # Text-to-speech
|
||||
MOVE = auto() # Physical movement
|
||||
GRIP = auto() # Manipulate objects
|
||||
CALL = auto() # API/network call
|
||||
EMIT = auto() # Signal/light/sound
|
||||
SLEEP = auto() # Power management
|
||||
|
||||
|
||||
class AgentCapability(Enum):
|
||||
"""High-level capabilities a TimAgent may possess."""
|
||||
REASONING = "reasoning"
|
||||
CODING = "coding"
|
||||
WRITING = "writing"
|
||||
ANALYSIS = "analysis"
|
||||
VISION = "vision"
|
||||
SPEECH = "speech"
|
||||
NAVIGATION = "navigation"
|
||||
MANIPULATION = "manipulation"
|
||||
LEARNING = "learning"
|
||||
COMMUNICATION = "communication"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentIdentity:
|
||||
"""Immutable identity for an agent instance.
|
||||
|
||||
This persists across sessions and substrates. If Timmy moves
|
||||
from cloud to robot, the identity follows.
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
@classmethod
|
||||
def generate(cls, name: str, version: str = "1.0.0") -> "AgentIdentity":
|
||||
"""Generate a new unique identity."""
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Perception:
|
||||
"""A sensory input to the agent.
|
||||
|
||||
Substrate-agnostic representation. A camera image and a
|
||||
LiDAR point cloud are both Perception instances.
|
||||
"""
|
||||
type: PerceptionType
|
||||
data: Any # Content depends on type
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
source: str = "unknown" # e.g., "camera_1", "microphone", "user_input"
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def text(cls, content: str, source: str = "user") -> "Perception":
|
||||
"""Factory for text perception."""
|
||||
return cls(
|
||||
type=PerceptionType.TEXT,
|
||||
data=content,
|
||||
source=source,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sensor(cls, kind: str, value: float, unit: str = "") -> "Perception":
|
||||
"""Factory for sensor readings."""
|
||||
return cls(
|
||||
type=PerceptionType.SENSOR,
|
||||
data={"kind": kind, "value": value, "unit": unit},
|
||||
source=f"sensor_{kind}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Action:
|
||||
"""An action the agent intends to perform.
|
||||
|
||||
Actions are effects — they describe what should happen,
|
||||
not how. The substrate implements the "how."
|
||||
"""
|
||||
type: ActionType
|
||||
payload: Any # Action-specific data
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
confidence: float = 1.0 # 0-1, agent's certainty
|
||||
deadline: Optional[str] = None # When action must complete
|
||||
|
||||
@classmethod
|
||||
def respond(cls, text: str, confidence: float = 1.0) -> "Action":
|
||||
"""Factory for text response action."""
|
||||
return cls(
|
||||
type=ActionType.TEXT,
|
||||
payload=text,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def move(cls, vector: tuple[float, float, float], speed: float = 1.0) -> "Action":
|
||||
"""Factory for movement action (x, y, z meters)."""
|
||||
return cls(
|
||||
type=ActionType.MOVE,
|
||||
payload={"vector": vector, "speed": speed},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memory:
|
||||
"""A stored experience or fact.
|
||||
|
||||
Memories are substrate-agnostic. A conversation history
|
||||
and a video recording are both Memory instances.
|
||||
"""
|
||||
id: str
|
||||
content: Any
|
||||
created_at: str
|
||||
access_count: int = 0
|
||||
last_accessed: Optional[str] = None
|
||||
importance: float = 0.5 # 0-1, for pruning decisions
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
def touch(self) -> None:
|
||||
"""Mark memory as accessed."""
|
||||
self.access_count += 1
|
||||
self.last_accessed = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Communication:
|
||||
"""A message to/from another agent or human."""
|
||||
sender: str
|
||||
recipient: str
|
||||
content: Any
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
protocol: str = "direct" # e.g., "http", "websocket", "speech"
|
||||
encrypted: bool = False
|
||||
|
||||
|
||||
class TimAgent(ABC):
|
||||
"""Abstract base class for all Timmy agent implementations.
|
||||
|
||||
This is the substrate-agnostic interface. Implementations:
|
||||
- OllamaAgent: LLM-based reasoning (today)
|
||||
- RobotAgent: Physical embodiment (future)
|
||||
- SimulationAgent: Virtual environment (future)
|
||||
|
||||
Usage:
|
||||
agent = OllamaAgent(identity) # Today's implementation
|
||||
|
||||
perception = Perception.text("Hello Timmy")
|
||||
memory = agent.perceive(perception)
|
||||
|
||||
action = agent.reason("How should I respond?")
|
||||
result = agent.act(action)
|
||||
|
||||
agent.remember(memory) # Store for future
|
||||
"""
|
||||
|
||||
def __init__(self, identity: AgentIdentity) -> None:
|
||||
self._identity = identity
|
||||
self._capabilities: set[AgentCapability] = set()
|
||||
self._state: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def identity(self) -> AgentIdentity:
|
||||
"""Return this agent's immutable identity."""
|
||||
return self._identity
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[AgentCapability]:
|
||||
"""Return set of supported capabilities."""
|
||||
return self._capabilities.copy()
|
||||
|
||||
def has_capability(self, capability: AgentCapability) -> bool:
|
||||
"""Check if agent supports a capability."""
|
||||
return capability in self._capabilities
|
||||
|
||||
@abstractmethod
|
||||
def perceive(self, perception: Perception) -> Memory:
|
||||
"""Process sensory input and create a memory.
|
||||
|
||||
This is the entry point for all agent interaction.
|
||||
A text message, camera frame, or temperature reading
|
||||
all enter through perceive().
|
||||
|
||||
Args:
|
||||
perception: Sensory input
|
||||
|
||||
Returns:
|
||||
Memory: Stored representation of the perception
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def reason(self, query: str, context: list[Memory]) -> Action:
|
||||
"""Reason about a situation and decide on action.
|
||||
|
||||
This is where "thinking" happens. The agent uses its
|
||||
substrate-appropriate reasoning (LLM, neural net, rules)
|
||||
to decide what to do.
|
||||
|
||||
Args:
|
||||
query: What to reason about
|
||||
context: Relevant memories for context
|
||||
|
||||
Returns:
|
||||
Action: What the agent decides to do
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def act(self, action: Action) -> Any:
|
||||
"""Execute an action in the substrate.
|
||||
|
||||
This is where the abstract action becomes concrete:
|
||||
- TEXT → Generate LLM response
|
||||
- MOVE → Send motor commands
|
||||
- SPEAK → Call TTS engine
|
||||
|
||||
Args:
|
||||
action: The action to execute
|
||||
|
||||
Returns:
|
||||
Result of the action (substrate-specific)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remember(self, memory: Memory) -> None:
|
||||
"""Store a memory for future retrieval.
|
||||
|
||||
The storage mechanism depends on substrate:
|
||||
- Cloud: SQLite, vector DB
|
||||
- Robot: Local flash storage
|
||||
- Hybrid: Synced with conflict resolution
|
||||
|
||||
Args:
|
||||
memory: Experience to store
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def recall(self, query: str, limit: int = 5) -> list[Memory]:
|
||||
"""Retrieve relevant memories.
|
||||
|
||||
Args:
|
||||
query: What to search for
|
||||
limit: Maximum memories to return
|
||||
|
||||
Returns:
|
||||
List of relevant memories, sorted by relevance
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def communicate(self, message: Communication) -> bool:
|
||||
"""Send/receive communication with another agent.
|
||||
|
||||
Args:
|
||||
message: Message to send
|
||||
|
||||
Returns:
|
||||
True if communication succeeded
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_state(self) -> dict[str, Any]:
|
||||
"""Get current agent state for monitoring/debugging."""
|
||||
return {
|
||||
"identity": self._identity,
|
||||
"capabilities": list(self._capabilities),
|
||||
"state": self._state.copy(),
|
||||
}
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Graceful shutdown. Persist state, close connections."""
|
||||
# Override in subclass for cleanup
|
||||
pass
|
||||
|
||||
|
||||
class AgentEffect:
|
||||
"""Log entry for agent actions — for audit and replay.
|
||||
|
||||
The complete history of an agent's life can be captured
|
||||
as a sequence of AgentEffects. This enables:
|
||||
- Debugging: What did the agent see and do?
|
||||
- Audit: Why did it make that decision?
|
||||
- Replay: Reconstruct agent state from log
|
||||
- Training: Learn from agent experiences
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: Optional[str] = None) -> None:
|
||||
self._effects: list[dict] = []
|
||||
self._log_path = log_path
|
||||
|
||||
def log_perceive(self, perception: Perception, memory_id: str) -> None:
|
||||
"""Log a perception event."""
|
||||
self._effects.append({
|
||||
"type": "perceive",
|
||||
"perception_type": perception.type.name,
|
||||
"source": perception.source,
|
||||
"memory_id": memory_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
def log_reason(self, query: str, action_type: ActionType) -> None:
|
||||
"""Log a reasoning event."""
|
||||
self._effects.append({
|
||||
"type": "reason",
|
||||
"query": query,
|
||||
"action_type": action_type.name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
def log_act(self, action: Action, result: Any) -> None:
|
||||
"""Log an action event."""
|
||||
self._effects.append({
|
||||
"type": "act",
|
||||
"action_type": action.type.name,
|
||||
"confidence": action.confidence,
|
||||
"result_type": type(result).__name__,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
def export(self) -> list[dict]:
|
||||
"""Export effect log for analysis."""
|
||||
return self._effects.copy()
|
||||
259
src/agent_core/ollama_adapter.py
Normal file
259
src/agent_core/ollama_adapter.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Ollama-based implementation of TimAgent interface.
|
||||
|
||||
This adapter wraps the existing Timmy Ollama agent to conform
|
||||
to the substrate-agnostic TimAgent interface. It's the bridge
|
||||
between the old codebase and the new embodiment-ready architecture.
|
||||
|
||||
Usage:
|
||||
from agent_core import AgentIdentity, Perception
|
||||
from agent_core.ollama_adapter import OllamaAgent
|
||||
|
||||
identity = AgentIdentity.generate("Timmy")
|
||||
agent = OllamaAgent(identity)
|
||||
|
||||
perception = Perception.text("Hello!")
|
||||
memory = agent.perceive(perception)
|
||||
action = agent.reason("How should I respond?", [memory])
|
||||
result = agent.act(action)
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent_core.interface import (
|
||||
AgentCapability,
|
||||
AgentIdentity,
|
||||
Perception,
|
||||
PerceptionType,
|
||||
Action,
|
||||
ActionType,
|
||||
Memory,
|
||||
Communication,
|
||||
TimAgent,
|
||||
AgentEffect,
|
||||
)
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
|
||||
class OllamaAgent(TimAgent):
|
||||
"""TimAgent implementation using local Ollama LLM.
|
||||
|
||||
This is the production agent for Timmy Time v2. It uses
|
||||
Ollama for reasoning and SQLite for memory persistence.
|
||||
|
||||
Capabilities:
|
||||
- REASONING: LLM-based inference
|
||||
- CODING: Code generation and analysis
|
||||
- WRITING: Long-form content creation
|
||||
- ANALYSIS: Data processing and insights
|
||||
- COMMUNICATION: Multi-agent messaging
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identity: AgentIdentity,
|
||||
model: Optional[str] = None,
|
||||
effect_log: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Initialize Ollama-based agent.
|
||||
|
||||
Args:
|
||||
identity: Agent identity (persistent across sessions)
|
||||
model: Ollama model to use (default from config)
|
||||
effect_log: Path to log agent effects (optional)
|
||||
"""
|
||||
super().__init__(identity)
|
||||
|
||||
# Initialize underlying Ollama agent
|
||||
self._timmy = create_timmy(model=model)
|
||||
|
||||
# Set capabilities based on what Ollama can do
|
||||
self._capabilities = {
|
||||
AgentCapability.REASONING,
|
||||
AgentCapability.CODING,
|
||||
AgentCapability.WRITING,
|
||||
AgentCapability.ANALYSIS,
|
||||
AgentCapability.COMMUNICATION,
|
||||
}
|
||||
|
||||
# Effect logging for audit/replay
|
||||
self._effect_log = AgentEffect(effect_log) if effect_log else None
|
||||
|
||||
# Simple in-memory working memory (short term)
|
||||
self._working_memory: list[Memory] = []
|
||||
self._max_working_memory = 10
|
||||
|
||||
def perceive(self, perception: Perception) -> Memory:
|
||||
"""Process perception and store in memory.
|
||||
|
||||
For text perceptions, we might do light preprocessing
|
||||
(summarization, keyword extraction) before storage.
|
||||
"""
|
||||
# Create memory from perception
|
||||
memory = Memory(
|
||||
id=f"mem_{len(self._working_memory)}",
|
||||
content={
|
||||
"type": perception.type.name,
|
||||
"data": perception.data,
|
||||
"source": perception.source,
|
||||
},
|
||||
created_at=perception.timestamp,
|
||||
tags=self._extract_tags(perception),
|
||||
)
|
||||
|
||||
# Add to working memory
|
||||
self._working_memory.append(memory)
|
||||
if len(self._working_memory) > self._max_working_memory:
|
||||
self._working_memory.pop(0) # FIFO eviction
|
||||
|
||||
# Log effect
|
||||
if self._effect_log:
|
||||
self._effect_log.log_perceive(perception, memory.id)
|
||||
|
||||
return memory
|
||||
|
||||
def reason(self, query: str, context: list[Memory]) -> Action:
|
||||
"""Use LLM to reason and decide on action.
|
||||
|
||||
This is where the Ollama agent does its work. We construct
|
||||
a prompt from the query and context, then interpret the
|
||||
response as an action.
|
||||
"""
|
||||
# Build context string from memories
|
||||
context_str = self._format_context(context)
|
||||
|
||||
# Construct prompt
|
||||
prompt = f"""You are {self._identity.name}, an AI assistant.
|
||||
|
||||
Context from previous interactions:
|
||||
{context_str}
|
||||
|
||||
Current query: {query}
|
||||
|
||||
Respond naturally and helpfully."""
|
||||
|
||||
# Run LLM inference
|
||||
result = self._timmy.run(prompt, stream=False)
|
||||
response_text = result.content if hasattr(result, "content") else str(result)
|
||||
|
||||
# Create text response action
|
||||
action = Action.respond(response_text, confidence=0.9)
|
||||
|
||||
# Log effect
|
||||
if self._effect_log:
|
||||
self._effect_log.log_reason(query, action.type)
|
||||
|
||||
return action
|
||||
|
||||
def act(self, action: Action) -> Any:
|
||||
"""Execute action in the Ollama substrate.
|
||||
|
||||
For text actions, the "execution" is just returning the
|
||||
text (already generated during reasoning). For future
|
||||
action types (MOVE, SPEAK), this would trigger the
|
||||
appropriate Ollama tool calls.
|
||||
"""
|
||||
result = None
|
||||
|
||||
if action.type == ActionType.TEXT:
|
||||
result = action.payload
|
||||
elif action.type == ActionType.SPEAK:
|
||||
# Would call TTS here
|
||||
result = {"spoken": action.payload, "tts_engine": "pyttsx3"}
|
||||
elif action.type == ActionType.CALL:
|
||||
# Would make API call
|
||||
result = {"status": "not_implemented", "payload": action.payload}
|
||||
else:
|
||||
result = {"error": f"Action type {action.type} not supported by OllamaAgent"}
|
||||
|
||||
# Log effect
|
||||
if self._effect_log:
|
||||
self._effect_log.log_act(action, result)
|
||||
|
||||
return result
|
||||
|
||||
def remember(self, memory: Memory) -> None:
|
||||
"""Store memory persistently.
|
||||
|
||||
For now, working memory is sufficient. In the future,
|
||||
this would write to SQLite or vector DB for long-term
|
||||
memory across sessions.
|
||||
"""
|
||||
# Mark as accessed to update importance
|
||||
memory.touch()
|
||||
|
||||
# TODO: Persist to SQLite for long-term memory
|
||||
# This would integrate with the existing briefing system
|
||||
pass
|
||||
|
||||
def recall(self, query: str, limit: int = 5) -> list[Memory]:
|
||||
"""Retrieve relevant memories.
|
||||
|
||||
Simple keyword matching for now. Future: vector similarity.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
scored = []
|
||||
|
||||
for memory in self._working_memory:
|
||||
score = 0
|
||||
content_str = str(memory.content).lower()
|
||||
|
||||
# Simple keyword overlap
|
||||
query_words = set(query_lower.split())
|
||||
content_words = set(content_str.split())
|
||||
overlap = len(query_words & content_words)
|
||||
score += overlap
|
||||
|
||||
# Boost recent memories
|
||||
score += memory.importance
|
||||
|
||||
scored.append((score, memory))
|
||||
|
||||
# Sort by score descending
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# Return top N
|
||||
return [m for _, m in scored[:limit]]
|
||||
|
||||
def communicate(self, message: Communication) -> bool:
|
||||
"""Send message to another agent.
|
||||
|
||||
This would use the swarm comms layer for inter-agent
|
||||
messaging. For now, it's a stub.
|
||||
"""
|
||||
# TODO: Integrate with swarm.comms
|
||||
return True
|
||||
|
||||
def _extract_tags(self, perception: Perception) -> list[str]:
|
||||
"""Extract searchable tags from perception."""
|
||||
tags = [perception.type.name, perception.source]
|
||||
|
||||
if perception.type == PerceptionType.TEXT:
|
||||
# Simple keyword extraction
|
||||
text = str(perception.data).lower()
|
||||
keywords = ["code", "bug", "help", "question", "task"]
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
tags.append(kw)
|
||||
|
||||
return tags
|
||||
|
||||
def _format_context(self, memories: list[Memory]) -> str:
|
||||
"""Format memories into context string for prompt."""
|
||||
if not memories:
|
||||
return "No previous context."
|
||||
|
||||
parts = []
|
||||
for mem in memories[-5:]: # Last 5 memories
|
||||
if isinstance(mem.content, dict):
|
||||
data = mem.content.get("data", "")
|
||||
parts.append(f"- {data}")
|
||||
else:
|
||||
parts.append(f"- {mem.content}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def get_effect_log(self) -> Optional[list[dict]]:
|
||||
"""Export effect log if logging is enabled."""
|
||||
if self._effect_log:
|
||||
return self._effect_log.export()
|
||||
return None
|
||||
Reference in New Issue
Block a user