tiphys.agent
Tiphys agent components.
Provides the ReAct loop, LLM integration, and session management.
1""" 2Tiphys agent components. 3 4Provides the ReAct loop, LLM integration, and session management. 5""" 6 7from tiphys.agent.llm import LLMClient, LLMResponse, Message, StreamDelta, ToolCall 8from tiphys.agent.prompt import SystemPromptBuilder, build_system_prompt 9from tiphys.agent.runner import AgentContext, AgentResult, AgentRunner, ToolResult 10from tiphys.agent.session import ( 11 DmScope, 12 Peer, 13 SessionMessage, 14 SessionState, 15 SessionStore, 16 build_session_key, 17 get_session_store, 18) 19from tiphys.agent.streaming import Block, BlockChunker, extract_thinking, strip_thinking_tags 20from tiphys.channels.base import PeerKind 21 22__all__ = [ 23 # LLM 24 "LLMClient", 25 "LLMResponse", 26 "Message", 27 "StreamDelta", 28 "ToolCall", 29 # Prompt 30 "SystemPromptBuilder", 31 "build_system_prompt", 32 # Runner 33 "AgentRunner", 34 "AgentContext", 35 "AgentResult", 36 "ToolResult", 37 # Session 38 "DmScope", 39 "Peer", 40 "PeerKind", 41 "SessionMessage", 42 "SessionState", 43 "SessionStore", 44 "build_session_key", 45 "get_session_store", 46 # Streaming 47 "Block", 48 "BlockChunker", 49 "strip_thinking_tags", 50 "extract_thinking", 51]
69class LLMClient: 70 """ 71 Client for interacting with LLMs via LiteLLM. 72 73 Supports streaming and non-streaming calls, with tool use. 74 Supports OpenRouter, OpenAI, Anthropic, Ollama, and custom endpoints. 75 """ 76 77 def __init__( 78 self, 79 model: str = "openrouter/anthropic/claude-3.5-sonnet", 80 api_base: str | None = None, 81 api_key: str | None = None, 82 temperature: float = 0.7, 83 max_tokens: int | None = None, 84 extra_headers: dict[str, str] | None = None, 85 auth_manager: "AuthProfileManager | None" = None, 86 ) -> None: 87 """ 88 Initialize the LLM client. 89 90 Args: 91 model: Model identifier in LiteLLM format. 92 api_base: Optional custom API base URL. 93 api_key: Optional API key. 94 temperature: Default sampling temperature. 95 max_tokens: Default max tokens to generate. 96 extra_headers: Extra HTTP headers for requests. 97 auth_manager: Optional manager for multiple API keys. 98 """ 99 self.model = model 100 self.api_base = api_base 101 self.api_key = api_key 102 self.default_temperature = temperature 103 self.default_max_tokens = max_tokens 104 self.extra_headers = extra_headers or {} 105 self.auth_manager = auth_manager 106 107 @classmethod 108 def from_config(cls, config: TiphysConfig) -> "LLMClient": 109 """Create an LLM client from configuration.""" 110 llm_settings = config.llm 111 112 # Get model ID with proper prefix 113 model = llm_settings.get_model_id() 114 115 # Get API base (auto-detect for known providers) 116 api_base = llm_settings.get_api_base() 117 118 # Get API key (check env vars as fallback) 119 api_key = llm_settings.api_key or cls._get_api_key_from_env(llm_settings) 120 121 # Get extra headers (for OpenRouter app identification) 122 extra_headers = llm_settings.get_extra_headers() 123 124 return cls( 125 model=model, 126 api_base=api_base, 127 api_key=api_key, 128 temperature=llm_settings.temperature, 129 max_tokens=llm_settings.max_tokens, 130 extra_headers=extra_headers, 131 ) 132 133 @classmethod 134 def from_settings(cls, settings: LLMSettings) -> "LLMClient": 135 """Create an LLM client from LLMSettings directly.""" 136 model = settings.get_model_id() 137 api_base = settings.get_api_base() 138 api_key = settings.api_key or cls._get_api_key_from_env(settings) 139 extra_headers = settings.get_extra_headers() 140 141 return cls( 142 model=model, 143 api_base=api_base, 144 api_key=api_key, 145 temperature=settings.temperature, 146 max_tokens=settings.max_tokens, 147 extra_headers=extra_headers, 148 ) 149 150 @staticmethod 151 def _get_api_key_from_env(settings: LLMSettings) -> str | None: 152 """Get API key from environment variables based on provider.""" 153 from tiphys.config import LLMProvider 154 155 env_vars = { 156 LLMProvider.OPENROUTER: "OPENROUTER_API_KEY", 157 LLMProvider.OPENAI: "OPENAI_API_KEY", 158 LLMProvider.ANTHROPIC: "ANTHROPIC_API_KEY", 159 } 160 161 env_var = env_vars.get(settings.provider) 162 if env_var: 163 return os.environ.get(env_var) 164 return None 165 166 async def complete( 167 self, 168 messages: list[Message], 169 tools: list[dict[str, Any]] | None = None, 170 temperature: float | None = None, 171 max_tokens: int | None = None, 172 ) -> LLMResponse: 173 """ 174 Make a non-streaming completion request. 175 176 Args: 177 messages: Conversation messages. 178 tools: Optional tool definitions in OpenAI format. 179 temperature: Sampling temperature. Uses default if None. 180 max_tokens: Maximum tokens to generate. Uses default if None. 181 182 Returns: 183 LLMResponse with content and/or tool calls. 184 """ 185 temp = temperature if temperature is not None else self.default_temperature 186 tokens = max_tokens if max_tokens is not None else self.default_max_tokens 187 kwargs = self._build_kwargs(messages, tools, temp, tokens) 188 189 await logger.adebug( 190 "LLM request", 191 model=self.model, 192 message_count=len(messages), 193 has_tools=tools is not None, 194 ) 195 196 response = await litellm.acompletion(**kwargs) 197 198 return self._parse_response(response) 199 200 async def stream( 201 self, 202 messages: list[Message], 203 tools: list[dict[str, Any]] | None = None, 204 temperature: float | None = None, 205 max_tokens: int | None = None, 206 ) -> AsyncIterator[StreamDelta]: 207 """ 208 Make a streaming completion request. 209 210 Args: 211 messages: Conversation messages. 212 tools: Optional tool definitions in OpenAI format. 213 temperature: Sampling temperature. Uses default if None. 214 max_tokens: Maximum tokens to generate. Uses default if None. 215 216 Yields: 217 StreamDelta objects as they arrive. 218 """ 219 temp = temperature if temperature is not None else self.default_temperature 220 tokens = max_tokens if max_tokens is not None else self.default_max_tokens 221 kwargs = self._build_kwargs(messages, tools, temp, tokens) 222 kwargs["stream"] = True 223 224 await logger.adebug( 225 "LLM stream request", 226 model=self.model, 227 message_count=len(messages), 228 ) 229 230 response = await litellm.acompletion(**kwargs) 231 232 async for chunk in response: 233 delta = self._parse_stream_chunk(chunk) 234 if delta: 235 yield delta 236 237 def _build_kwargs( 238 self, 239 messages: list[Message], 240 tools: list[dict[str, Any]] | None, 241 temperature: float, 242 max_tokens: int | None, 243 ) -> dict[str, Any]: 244 """Build kwargs for LiteLLM call.""" 245 kwargs: dict[str, Any] = { 246 "model": self.model, 247 "messages": [m.model_dump(exclude_none=True) for m in messages], 248 "temperature": temperature, 249 } 250 251 if self.api_base: 252 kwargs["api_base"] = self.api_base 253 254 # Determine API key 255 api_key = self.api_key 256 if self.auth_manager: 257 # Extract provider from model string (e.g. "openrouter/...") 258 provider = self.model.split("/")[0] if "/" in self.model else "unknown" 259 profile = self.auth_manager.get_active(provider, self.model) 260 if profile: 261 api_key = profile.api_key 262 # Store which profile we used for error reporting 263 kwargs["metadata"] = {"auth_profile_id": profile.id} 264 265 if api_key: 266 kwargs["api_key"] = api_key 267 268 if tools: 269 kwargs["tools"] = tools 270 271 if max_tokens: 272 kwargs["max_tokens"] = max_tokens 273 274 # Add extra headers (for OpenRouter app identification, etc.) 275 if self.extra_headers: 276 kwargs["extra_headers"] = self.extra_headers 277 278 return kwargs 279 280 def _parse_response(self, response: Any) -> LLMResponse: 281 """Parse a LiteLLM response into our format.""" 282 choice = response.choices[0] 283 message = choice.message 284 285 tool_calls: list[ToolCall] = [] 286 if message.tool_calls: 287 for tc in message.tool_calls: 288 # Parse arguments from JSON string 289 import json 290 291 try: 292 args = json.loads(tc.function.arguments) 293 except json.JSONDecodeError: 294 args = {} 295 296 tool_calls.append( 297 ToolCall( 298 id=tc.id, 299 name=tc.function.name, 300 arguments=args, 301 ) 302 ) 303 304 usage = None 305 if hasattr(response, "usage") and response.usage: 306 usage = { 307 "prompt_tokens": response.usage.prompt_tokens, 308 "completion_tokens": response.usage.completion_tokens, 309 "total_tokens": response.usage.total_tokens, 310 } 311 312 return LLMResponse( 313 content=message.content, 314 tool_calls=tool_calls, 315 finish_reason=choice.finish_reason, 316 usage=usage, 317 ) 318 319 def _parse_stream_chunk(self, chunk: Any) -> StreamDelta | None: 320 """Parse a streaming chunk into a delta.""" 321 if not chunk.choices: 322 return None 323 324 choice = chunk.choices[0] 325 delta = choice.delta 326 327 result = StreamDelta(finish_reason=choice.finish_reason) 328 329 if hasattr(delta, "content") and delta.content: 330 result.content = delta.content 331 332 if hasattr(delta, "tool_calls") and delta.tool_calls: 333 tc = delta.tool_calls[0] 334 if hasattr(tc, "id") and tc.id: 335 result.tool_call_id = tc.id 336 if hasattr(tc, "function"): 337 if hasattr(tc.function, "name") and tc.function.name: 338 result.tool_call_name = tc.function.name 339 if hasattr(tc.function, "arguments") and tc.function.arguments: 340 result.tool_call_arguments = tc.function.arguments 341 342 # Return None if delta is empty 343 if ( 344 result.content is None 345 and result.tool_call_id is None 346 and result.tool_call_name is None 347 and result.tool_call_arguments is None 348 and result.finish_reason is None 349 ): 350 return None 351 352 return result
Client for interacting with LLMs via LiteLLM.
Supports streaming and non-streaming calls, with tool use. Supports OpenRouter, OpenAI, Anthropic, Ollama, and custom endpoints.
77 def __init__( 78 self, 79 model: str = "openrouter/anthropic/claude-3.5-sonnet", 80 api_base: str | None = None, 81 api_key: str | None = None, 82 temperature: float = 0.7, 83 max_tokens: int | None = None, 84 extra_headers: dict[str, str] | None = None, 85 auth_manager: "AuthProfileManager | None" = None, 86 ) -> None: 87 """ 88 Initialize the LLM client. 89 90 Args: 91 model: Model identifier in LiteLLM format. 92 api_base: Optional custom API base URL. 93 api_key: Optional API key. 94 temperature: Default sampling temperature. 95 max_tokens: Default max tokens to generate. 96 extra_headers: Extra HTTP headers for requests. 97 auth_manager: Optional manager for multiple API keys. 98 """ 99 self.model = model 100 self.api_base = api_base 101 self.api_key = api_key 102 self.default_temperature = temperature 103 self.default_max_tokens = max_tokens 104 self.extra_headers = extra_headers or {} 105 self.auth_manager = auth_manager
Initialize the LLM client.
Args: model: Model identifier in LiteLLM format. api_base: Optional custom API base URL. api_key: Optional API key. temperature: Default sampling temperature. max_tokens: Default max tokens to generate. extra_headers: Extra HTTP headers for requests. auth_manager: Optional manager for multiple API keys.
107 @classmethod 108 def from_config(cls, config: TiphysConfig) -> "LLMClient": 109 """Create an LLM client from configuration.""" 110 llm_settings = config.llm 111 112 # Get model ID with proper prefix 113 model = llm_settings.get_model_id() 114 115 # Get API base (auto-detect for known providers) 116 api_base = llm_settings.get_api_base() 117 118 # Get API key (check env vars as fallback) 119 api_key = llm_settings.api_key or cls._get_api_key_from_env(llm_settings) 120 121 # Get extra headers (for OpenRouter app identification) 122 extra_headers = llm_settings.get_extra_headers() 123 124 return cls( 125 model=model, 126 api_base=api_base, 127 api_key=api_key, 128 temperature=llm_settings.temperature, 129 max_tokens=llm_settings.max_tokens, 130 extra_headers=extra_headers, 131 )
Create an LLM client from configuration.
133 @classmethod 134 def from_settings(cls, settings: LLMSettings) -> "LLMClient": 135 """Create an LLM client from LLMSettings directly.""" 136 model = settings.get_model_id() 137 api_base = settings.get_api_base() 138 api_key = settings.api_key or cls._get_api_key_from_env(settings) 139 extra_headers = settings.get_extra_headers() 140 141 return cls( 142 model=model, 143 api_base=api_base, 144 api_key=api_key, 145 temperature=settings.temperature, 146 max_tokens=settings.max_tokens, 147 extra_headers=extra_headers, 148 )
Create an LLM client from LLMSettings directly.
166 async def complete( 167 self, 168 messages: list[Message], 169 tools: list[dict[str, Any]] | None = None, 170 temperature: float | None = None, 171 max_tokens: int | None = None, 172 ) -> LLMResponse: 173 """ 174 Make a non-streaming completion request. 175 176 Args: 177 messages: Conversation messages. 178 tools: Optional tool definitions in OpenAI format. 179 temperature: Sampling temperature. Uses default if None. 180 max_tokens: Maximum tokens to generate. Uses default if None. 181 182 Returns: 183 LLMResponse with content and/or tool calls. 184 """ 185 temp = temperature if temperature is not None else self.default_temperature 186 tokens = max_tokens if max_tokens is not None else self.default_max_tokens 187 kwargs = self._build_kwargs(messages, tools, temp, tokens) 188 189 await logger.adebug( 190 "LLM request", 191 model=self.model, 192 message_count=len(messages), 193 has_tools=tools is not None, 194 ) 195 196 response = await litellm.acompletion(**kwargs) 197 198 return self._parse_response(response)
Make a non-streaming completion request.
Args: messages: Conversation messages. tools: Optional tool definitions in OpenAI format. temperature: Sampling temperature. Uses default if None. max_tokens: Maximum tokens to generate. Uses default if None.
Returns: LLMResponse with content and/or tool calls.
200 async def stream( 201 self, 202 messages: list[Message], 203 tools: list[dict[str, Any]] | None = None, 204 temperature: float | None = None, 205 max_tokens: int | None = None, 206 ) -> AsyncIterator[StreamDelta]: 207 """ 208 Make a streaming completion request. 209 210 Args: 211 messages: Conversation messages. 212 tools: Optional tool definitions in OpenAI format. 213 temperature: Sampling temperature. Uses default if None. 214 max_tokens: Maximum tokens to generate. Uses default if None. 215 216 Yields: 217 StreamDelta objects as they arrive. 218 """ 219 temp = temperature if temperature is not None else self.default_temperature 220 tokens = max_tokens if max_tokens is not None else self.default_max_tokens 221 kwargs = self._build_kwargs(messages, tools, temp, tokens) 222 kwargs["stream"] = True 223 224 await logger.adebug( 225 "LLM stream request", 226 model=self.model, 227 message_count=len(messages), 228 ) 229 230 response = await litellm.acompletion(**kwargs) 231 232 async for chunk in response: 233 delta = self._parse_stream_chunk(chunk) 234 if delta: 235 yield delta
Make a streaming completion request.
Args: messages: Conversation messages. tools: Optional tool definitions in OpenAI format. temperature: Sampling temperature. Uses default if None. max_tokens: Maximum tokens to generate. Uses default if None.
Yields: StreamDelta objects as they arrive.
38@dataclass 39class LLMResponse: 40 """Response from an LLM call.""" 41 42 content: str | None = None 43 tool_calls: list[ToolCall] = field(default_factory=list) 44 finish_reason: str | None = None 45 usage: dict[str, int] | None = None
Response from an LLM call.
59class Message(BaseModel): 60 """A message in a conversation.""" 61 62 role: str # "system", "user", "assistant", "tool" 63 content: str | None = None 64 name: str | None = None # For tool messages 65 tool_call_id: str | None = None # For tool results 66 tool_calls: list[dict[str, Any]] | None = None # For assistant tool calls
A message in a conversation.
48@dataclass 49class StreamDelta: 50 """A delta from a streaming LLM response.""" 51 52 content: str | None = None 53 tool_call_id: str | None = None 54 tool_call_name: str | None = None 55 tool_call_arguments: str | None = None 56 finish_reason: str | None = None
A delta from a streaming LLM response.
29@dataclass 30class ToolCall: 31 """A tool call from the LLM.""" 32 33 id: str 34 name: str 35 arguments: dict[str, Any]
A tool call from the LLM.
15class SystemPromptBuilder: 16 """ 17 Builds system prompts dynamically based on context and available tools. 18 19 Supports two modes: 20 - full: Complete prompt for main agent with identity, tools, workspace 21 - minimal: Lightweight prompt for sub-agents with just tools 22 """ 23 24 def build( 25 self, 26 mode: Literal["full", "minimal"] = "full", 27 agent_name: str = "Tiphys", 28 tools: list["ToolDefinition"] | None = None, 29 workspace: Path | None = None, 30 memory_snippets: list[str] | None = None, 31 custom_instructions: str | None = None, 32 ) -> str: 33 """ 34 Build a system prompt. 35 36 Args: 37 mode: "full" for main agent, "minimal" for sub-agents. 38 agent_name: Name of the agent. 39 tools: List of available tools. 40 workspace: Current working directory. 41 memory_snippets: Recalled memory snippets. 42 custom_instructions: Additional user-defined instructions. 43 44 Returns: 45 Complete system prompt string. 46 """ 47 sections: list[str] = [] 48 49 # Identity section (full mode only) 50 if mode == "full": 51 sections.append(self._build_identity(agent_name)) 52 53 # Custom instructions 54 if custom_instructions: 55 sections.append(self._build_custom_instructions(custom_instructions)) 56 57 # Tools section 58 if tools: 59 sections.append(self._build_tools_section(tools)) 60 61 # Workspace context 62 if workspace and workspace.exists(): 63 sections.append(self._build_workspace(workspace)) 64 65 # Memory recall 66 if memory_snippets: 67 sections.append(self._build_memory(memory_snippets)) 68 69 # Guidelines (full mode only) 70 if mode == "full": 71 sections.append(self._build_guidelines()) 72 73 return "\n\n".join(sections) 74 75 def _build_identity(self, agent_name: str) -> str: 76 """Build the identity section.""" 77 return f"""# Identity 78 79You are {agent_name}, a helpful AI assistant. 80You have access to tools to help accomplish tasks. 81Think step by step and use tools when needed. 82Be concise and accurate in your responses.""" 83 84 def _build_custom_instructions(self, instructions: str) -> str: 85 """Build custom instructions section.""" 86 return f"""# Custom Instructions 87 88{instructions}""" 89 90 def _build_tools_section(self, tools: list["ToolDefinition"]) -> str: 91 """Build the tools documentation section.""" 92 lines = ["# Available Tools", ""] 93 94 for tool in tools: 95 lines.append(f"## {tool.name}") 96 lines.append(f"{tool.description}") 97 98 # Add argument schema 99 schema = tool.args_schema.model_json_schema() 100 if "properties" in schema: 101 lines.append("") 102 lines.append("**Arguments:**") 103 for prop_name, prop_info in schema["properties"].items(): 104 prop_type = prop_info.get("type", "any") 105 prop_desc = prop_info.get("description", "") 106 required = prop_name in schema.get("required", []) 107 req_marker = " (required)" if required else "" 108 lines.append(f"- `{prop_name}` ({prop_type}){req_marker}: {prop_desc}") 109 110 lines.append("") 111 112 return "\n".join(lines) 113 114 def _build_workspace(self, workspace: Path) -> str: 115 """Build the workspace context section.""" 116 items: list[str] = [] 117 118 try: 119 # List top-level files and directories, limit to 20 120 for item in sorted(workspace.iterdir())[:20]: 121 if item.name.startswith("."): 122 continue # Skip hidden files 123 prefix = "📁" if item.is_dir() else "📄" 124 items.append(f" {prefix} {item.name}") 125 except PermissionError: 126 items.append(" (permission denied)") 127 128 if not items: 129 items.append(" (empty)") 130 131 return f"""# Workspace 132 133Current directory: {workspace} 134 135Contents: 136{chr(10).join(items)}""" 137 138 def _build_memory(self, snippets: list[str]) -> str: 139 """Build the memory recall section.""" 140 formatted = "\n---\n".join(snippets) 141 return f"""# Recalled Memory 142 143The following information may be relevant to the current task: 144 145{formatted}""" 146 147 def _build_guidelines(self) -> str: 148 """Build the guidelines section.""" 149 return """# Guidelines 150 151- Think through problems step by step before acting 152- Use tools when they can help accomplish the task 153- If you're unsure, ask for clarification 154- Be concise but thorough in your responses 155- When using tools, explain what you're doing and why 156- If a tool fails, try to understand why and adapt your approach"""
Builds system prompts dynamically based on context and available tools.
Supports two modes:
- full: Complete prompt for main agent with identity, tools, workspace
- minimal: Lightweight prompt for sub-agents with just tools
24 def build( 25 self, 26 mode: Literal["full", "minimal"] = "full", 27 agent_name: str = "Tiphys", 28 tools: list["ToolDefinition"] | None = None, 29 workspace: Path | None = None, 30 memory_snippets: list[str] | None = None, 31 custom_instructions: str | None = None, 32 ) -> str: 33 """ 34 Build a system prompt. 35 36 Args: 37 mode: "full" for main agent, "minimal" for sub-agents. 38 agent_name: Name of the agent. 39 tools: List of available tools. 40 workspace: Current working directory. 41 memory_snippets: Recalled memory snippets. 42 custom_instructions: Additional user-defined instructions. 43 44 Returns: 45 Complete system prompt string. 46 """ 47 sections: list[str] = [] 48 49 # Identity section (full mode only) 50 if mode == "full": 51 sections.append(self._build_identity(agent_name)) 52 53 # Custom instructions 54 if custom_instructions: 55 sections.append(self._build_custom_instructions(custom_instructions)) 56 57 # Tools section 58 if tools: 59 sections.append(self._build_tools_section(tools)) 60 61 # Workspace context 62 if workspace and workspace.exists(): 63 sections.append(self._build_workspace(workspace)) 64 65 # Memory recall 66 if memory_snippets: 67 sections.append(self._build_memory(memory_snippets)) 68 69 # Guidelines (full mode only) 70 if mode == "full": 71 sections.append(self._build_guidelines()) 72 73 return "\n\n".join(sections)
Build a system prompt.
Args: mode: "full" for main agent, "minimal" for sub-agents. agent_name: Name of the agent. tools: List of available tools. workspace: Current working directory. memory_snippets: Recalled memory snippets. custom_instructions: Additional user-defined instructions.
Returns: Complete system prompt string.
163def build_system_prompt( 164 mode: Literal["full", "minimal"] = "full", 165 agent_name: str = "Tiphys", 166 tools: list["ToolDefinition"] | None = None, 167 workspace: Path | None = None, 168 memory_snippets: list[str] | None = None, 169 custom_instructions: str | None = None, 170) -> str: 171 """ 172 Build a system prompt (convenience function). 173 174 See SystemPromptBuilder.build for full documentation. 175 """ 176 return _builder.build( 177 mode=mode, 178 agent_name=agent_name, 179 tools=tools, 180 workspace=workspace, 181 memory_snippets=memory_snippets, 182 custom_instructions=custom_instructions, 183 )
Build a system prompt (convenience function).
See SystemPromptBuilder.build for full documentation.
66class AgentRunner: 67 """ 68 Ephemeral agent execution unit. 69 70 Implements the stateless hydration pattern: 71 1. HYDRATE: Load config, skills, session history 72 2. THINK: Call LLM with tools 73 3. ACT: Execute tool calls (with policy checks) 74 4. SLEEP: Persist state and exit 75 """ 76 77 def __init__( 78 self, 79 config: TiphysConfig | None = None, 80 llm_client: LLMClient | None = None, 81 session_store: SessionStore | None = None, 82 event_bus: EventBus | None = None, 83 tool_registry: ToolRegistry | None = None, 84 *, 85 context: AppContext | None = None, 86 ) -> None: 87 """ 88 Initialize the agent runner. 89 90 Args: 91 config: Configuration. Defaults to loading from environment. 92 llm_client: LLM client. Created from config if not provided. 93 session_store: Session store. Uses global store if not provided. 94 event_bus: Event bus. Uses global bus if not provided. 95 tool_registry: Tool registry. Uses global registry if not provided. 96 context: Optional AppContext. When provided, uses context's services 97 instead of individual parameters (preferred approach). 98 99 Example: 100 # Preferred: Use AppContext 101 runner = AgentRunner(context=ctx) 102 103 # Legacy: Individual parameters (still supported) 104 runner = AgentRunner(config=config, event_bus=bus) 105 """ 106 # If context is provided, use its services (preferred path) 107 if context is not None: 108 self.config = context.config 109 self.event_bus = context.event_bus 110 self.tool_registry: ToolRegistry | None = context.tool_registry 111 self.castor_guard: TiphysGuard | None = context.castor_guard 112 # LLM client and session store still use defaults or overrides 113 self.llm = llm_client or LLMClient.from_config(self.config) 114 self.session_store = session_store or get_session_store() 115 else: 116 # Legacy path: individual parameters 117 self.config = config or load_config() 118 self.llm = llm_client or LLMClient.from_config(self.config) 119 self.session_store = session_store or get_session_store() 120 self.event_bus = event_bus or get_event_bus() 121 self.tool_registry = tool_registry 122 self.castor_guard = None 123 124 async def step( 125 self, 126 session_key: str, 127 user_message: str, 128 workspace: Path | None = None, 129 ) -> AgentResult: 130 """ 131 Execute a single step of the ReAct loop. 132 133 Args: 134 session_key: Session identifier. 135 user_message: The user's message. 136 workspace: Optional working directory for context. 137 138 Returns: 139 AgentResult with response and/or tool results. 140 """ 141 # 1. HYDRATE 142 context = await self._hydrate(session_key, workspace) 143 144 # Add user message to session 145 context.session.add_message(role="user", content=user_message) 146 147 # Compact session before thinking 148 self._compact_session(context.session) 149 150 # 2. THINK 151 response = await self._think(context) 152 153 # 3. ACT (if tool calls) 154 if response.tool_calls: 155 tool_results = await self._execute_tools(response, context) 156 157 # Add tool results to session 158 for result in tool_results: 159 context.session.add_message( 160 role="tool", 161 content=result.output, 162 name=result.tool_name, 163 tool_call_id=result.tool_call_id, 164 ) 165 166 # 4. SLEEP 167 await self._persist(context) 168 169 return AgentResult( 170 tool_results=tool_results, 171 continue_loop=True, 172 ) 173 174 # No tool calls - final response 175 # 4. SLEEP 176 await self._persist(context) 177 178 return AgentResult( 179 final_response=response.content, 180 continue_loop=False, 181 ) 182 183 async def run( 184 self, 185 session_key: str, 186 user_message: str, 187 workspace: Path | None = None, 188 max_steps: int = 10, 189 ) -> str: 190 """ 191 Run the agent to completion. 192 193 Executes the ReAct loop until the agent produces a final response 194 or max_steps is reached. 195 196 Args: 197 session_key: Session identifier. 198 user_message: The user's message. 199 workspace: Optional working directory. 200 max_steps: Maximum tool execution steps. 201 202 Returns: 203 The agent's final response. 204 """ 205 steps = 0 206 current_message = user_message 207 208 while steps < max_steps: 209 result = await self.step(session_key, current_message, workspace) 210 211 if not result.continue_loop: 212 return result.final_response or "" 213 214 # Continue with empty message (tool results already in session) 215 current_message = "" 216 steps += 1 217 218 await logger.awarning("Agent reached max steps", max_steps=max_steps) 219 return "I've reached the maximum number of steps. Please try a simpler request." 220 221 async def stream( 222 self, 223 message: str, 224 session_key: str, 225 workspace: Path | None = None, 226 _client_id: str | None = None, 227 ) -> AsyncIterator[Block]: 228 """ 229 Stream agent response with block chunking. 230 231 Args: 232 message: The user's message. 233 session_key: Session identifier. 234 workspace: Optional working directory. 235 _client_id: Optional client ID (reserved for future use). 236 237 Yields: 238 Block objects as they become complete. 239 """ 240 user_message = message 241 # 1. HYDRATE 242 context = await self._hydrate(session_key, workspace) 243 244 # Add user message 245 context.session.add_message(role="user", content=user_message) 246 247 # Compact session before thinking 248 self._compact_session(context.session) 249 250 # Build messages for LLM 251 messages = self._build_messages(context) 252 253 # Get tool schemas if we have tools 254 tools = self._get_tool_schemas(context) if context.tools else None 255 256 # Stream response 257 chunker = BlockChunker() 258 full_content = "" 259 260 async for delta in self.llm.stream(messages, tools=tools): 261 if delta.content: 262 full_content += delta.content 263 264 # Process through chunker 265 for block in chunker.process(delta.content): 266 yield block 267 268 # Emit events 269 if block.type == "thinking": 270 await self.event_bus.broadcast( 271 ThoughtEvent(content=block.content, agent_id=self.config.agent_id) 272 ) 273 else: 274 await self.event_bus.broadcast( 275 TextEvent( 276 content=block.content, 277 agent_id=self.config.agent_id, 278 is_final=False, 279 ) 280 ) 281 282 # Flush remaining content and broadcast 283 for block in chunker.flush(): 284 yield block 285 286 # Emit events for flushed blocks (important for short responses!) 287 if block.type == "thinking": 288 await self.event_bus.broadcast( 289 ThoughtEvent(content=block.content, agent_id=self.config.agent_id) 290 ) 291 else: 292 await self.event_bus.broadcast( 293 TextEvent( 294 content=block.content, 295 agent_id=self.config.agent_id, 296 is_final=True, # Mark as final since we're flushing 297 ) 298 ) 299 300 # Add assistant response to session 301 context.session.add_message(role="assistant", content=full_content) 302 303 # Persist 304 await self._persist(context) 305 306 async def _hydrate(self, session_key: str, workspace: Path | None) -> AgentContext: 307 """Load session and build context.""" 308 session = self.session_store.get_or_create(session_key, self.config.agent_id) 309 310 # Get tools from registry 311 tools: list[ToolDefinition] = [] 312 if self.tool_registry: 313 tools = list(self.tool_registry.get_all().values()) 314 315 return AgentContext( 316 session=session, 317 config=self.config, 318 tools=tools, 319 workspace=workspace, 320 ) 321 322 async def _think(self, context: AgentContext) -> LLMResponse: 323 """Call the LLM with current context.""" 324 messages = self._build_messages(context) 325 tools = self._get_tool_schemas(context) if context.tools else None 326 327 return await self.llm.complete(messages, tools=tools) 328 329 async def _execute_tools( 330 self, response: LLMResponse, context: AgentContext 331 ) -> list[ToolResult]: 332 """Execute tool calls from the LLM response.""" 333 results: list[ToolResult] = [] 334 335 for tool_call in response.tool_calls: 336 # Emit tool call event 337 await self.event_bus.broadcast( 338 ToolCallEvent( 339 tool_name=tool_call.name, 340 arguments=tool_call.arguments, 341 tool_call_id=tool_call.id, 342 agent_id=self.config.agent_id, 343 ) 344 ) 345 346 # Execute the tool 347 result = await self._execute_single_tool(tool_call, context) 348 results.append(result) 349 350 # Emit result event 351 await self.event_bus.broadcast( 352 ToolResultEvent( 353 tool_call_id=tool_call.id, 354 tool_name=tool_call.name, 355 result=result.output, 356 success=result.success, 357 agent_id=self.config.agent_id, 358 ) 359 ) 360 361 # Add assistant message with tool calls to session 362 context.session.add_message( 363 role="assistant", 364 content=response.content, 365 tool_calls=[ 366 { 367 "id": tc.id, 368 "type": "function", 369 "function": {"name": tc.name, "arguments": str(tc.arguments)}, 370 } 371 for tc in response.tool_calls 372 ], 373 ) 374 375 return results 376 377 async def _execute_single_tool(self, tool_call: Any, _context: AgentContext) -> ToolResult: 378 """Execute a single tool call. 379 380 Routes through Castor guard when available, otherwise falls back 381 to direct execution. 382 """ 383 if self.castor_guard is not None: 384 return await self._execute_via_castor(tool_call) 385 386 return await self._execute_direct(tool_call) 387 388 async def _execute_via_castor(self, tool_call: Any) -> ToolResult: 389 """Execute a tool through the Castor guard pipeline.""" 390 from tiphys.kernel.guard import ToolExecutionError 391 392 try: 393 result = await self.castor_guard.execute_tool( # type: ignore[union-attr] 394 tool_name=tool_call.name, 395 arguments=tool_call.arguments, 396 ) 397 return ToolResult( 398 tool_call_id=tool_call.id, 399 tool_name=tool_call.name, 400 success=True, 401 output=str(result), 402 ) 403 except ToolExecutionError as e: 404 return ToolResult( 405 tool_call_id=tool_call.id, 406 tool_name=tool_call.name, 407 success=False, 408 output=str(e), 409 error=e.tool_name, 410 ) 411 except Exception as e: 412 await logger.aerror( 413 "Castor tool execution failed", 414 tool=tool_call.name, 415 error=str(e), 416 ) 417 return ToolResult( 418 tool_call_id=tool_call.id, 419 tool_name=tool_call.name, 420 success=False, 421 output=f"Error: {e}", 422 error=str(e), 423 ) 424 425 async def _execute_direct(self, tool_call: Any) -> ToolResult: 426 """Execute a tool directly (without Castor guard).""" 427 if not self.tool_registry: 428 return ToolResult( 429 tool_call_id=tool_call.id, 430 tool_name=tool_call.name, 431 success=False, 432 output="No tool registry configured", 433 error="no_registry", 434 ) 435 436 tool = self.tool_registry.get(tool_call.name) 437 if not tool: 438 return ToolResult( 439 tool_call_id=tool_call.id, 440 tool_name=tool_call.name, 441 success=False, 442 output=f"Unknown tool: {tool_call.name}", 443 error="unknown_tool", 444 ) 445 446 try: 447 # Execute the tool handler 448 output = await tool.handler(**tool_call.arguments) 449 450 return ToolResult( 451 tool_call_id=tool_call.id, 452 tool_name=tool_call.name, 453 success=True, 454 output=str(output), 455 ) 456 457 except Exception as e: 458 await logger.aerror( 459 "Tool execution failed", 460 tool=tool_call.name, 461 error=str(e), 462 ) 463 464 return ToolResult( 465 tool_call_id=tool_call.id, 466 tool_name=tool_call.name, 467 success=False, 468 output=f"Error: {e}", 469 error=str(e), 470 ) 471 472 async def _persist(self, context: AgentContext) -> None: 473 """Persist session state.""" 474 self.session_store.save(context.session) 475 476 def _compact_session(self, session: SessionState) -> None: 477 """Compact session history if it exceeds context window.""" 478 guard = ContextGuard(model=self.llm.model) 479 session.compact(guard) 480 481 def _build_messages(self, context: AgentContext) -> list[Message]: 482 """Build messages list for LLM call.""" 483 messages: list[Message] = [] 484 485 # Check if we need to add/update system message 486 system_prompt = build_system_prompt( 487 mode="full", 488 agent_name=self.config.agent_name, 489 tools=context.tools if context.tools else None, 490 workspace=context.workspace, 491 custom_instructions=context.custom_instructions, 492 ) 493 494 # Add system message 495 messages.append(Message(role="system", content=system_prompt)) 496 497 # Add session messages (skip any existing system message) 498 for msg in context.session.messages: 499 if msg.role != "system": 500 messages.append( 501 Message( 502 role=msg.role, 503 content=msg.content, 504 name=msg.name, 505 tool_call_id=msg.tool_call_id, 506 tool_calls=msg.tool_calls, 507 ) 508 ) 509 510 return messages 511 512 def _get_tool_schemas(self, context: AgentContext) -> list[dict[str, Any]]: 513 """Get tool schemas in OpenAI format.""" 514 schemas: list[dict[str, Any]] = [] 515 516 for tool in context.tools: 517 schemas.append( 518 { 519 "type": "function", 520 "function": { 521 "name": tool.name, 522 "description": tool.description, 523 "parameters": tool.args_schema.model_json_schema(), 524 }, 525 } 526 ) 527 528 return schemas
Ephemeral agent execution unit.
Implements the stateless hydration pattern:
- HYDRATE: Load config, skills, session history
- THINK: Call LLM with tools
- ACT: Execute tool calls (with policy checks)
- SLEEP: Persist state and exit
77 def __init__( 78 self, 79 config: TiphysConfig | None = None, 80 llm_client: LLMClient | None = None, 81 session_store: SessionStore | None = None, 82 event_bus: EventBus | None = None, 83 tool_registry: ToolRegistry | None = None, 84 *, 85 context: AppContext | None = None, 86 ) -> None: 87 """ 88 Initialize the agent runner. 89 90 Args: 91 config: Configuration. Defaults to loading from environment. 92 llm_client: LLM client. Created from config if not provided. 93 session_store: Session store. Uses global store if not provided. 94 event_bus: Event bus. Uses global bus if not provided. 95 tool_registry: Tool registry. Uses global registry if not provided. 96 context: Optional AppContext. When provided, uses context's services 97 instead of individual parameters (preferred approach). 98 99 Example: 100 # Preferred: Use AppContext 101 runner = AgentRunner(context=ctx) 102 103 # Legacy: Individual parameters (still supported) 104 runner = AgentRunner(config=config, event_bus=bus) 105 """ 106 # If context is provided, use its services (preferred path) 107 if context is not None: 108 self.config = context.config 109 self.event_bus = context.event_bus 110 self.tool_registry: ToolRegistry | None = context.tool_registry 111 self.castor_guard: TiphysGuard | None = context.castor_guard 112 # LLM client and session store still use defaults or overrides 113 self.llm = llm_client or LLMClient.from_config(self.config) 114 self.session_store = session_store or get_session_store() 115 else: 116 # Legacy path: individual parameters 117 self.config = config or load_config() 118 self.llm = llm_client or LLMClient.from_config(self.config) 119 self.session_store = session_store or get_session_store() 120 self.event_bus = event_bus or get_event_bus() 121 self.tool_registry = tool_registry 122 self.castor_guard = None
Initialize the agent runner.
Args: config: Configuration. Defaults to loading from environment. llm_client: LLM client. Created from config if not provided. session_store: Session store. Uses global store if not provided. event_bus: Event bus. Uses global bus if not provided. tool_registry: Tool registry. Uses global registry if not provided. context: Optional AppContext. When provided, uses context's services instead of individual parameters (preferred approach).
Example: # Preferred: Use AppContext runner = AgentRunner(context=ctx)
# Legacy: Individual parameters (still supported)
runner = AgentRunner(config=config, event_bus=bus)
124 async def step( 125 self, 126 session_key: str, 127 user_message: str, 128 workspace: Path | None = None, 129 ) -> AgentResult: 130 """ 131 Execute a single step of the ReAct loop. 132 133 Args: 134 session_key: Session identifier. 135 user_message: The user's message. 136 workspace: Optional working directory for context. 137 138 Returns: 139 AgentResult with response and/or tool results. 140 """ 141 # 1. HYDRATE 142 context = await self._hydrate(session_key, workspace) 143 144 # Add user message to session 145 context.session.add_message(role="user", content=user_message) 146 147 # Compact session before thinking 148 self._compact_session(context.session) 149 150 # 2. THINK 151 response = await self._think(context) 152 153 # 3. ACT (if tool calls) 154 if response.tool_calls: 155 tool_results = await self._execute_tools(response, context) 156 157 # Add tool results to session 158 for result in tool_results: 159 context.session.add_message( 160 role="tool", 161 content=result.output, 162 name=result.tool_name, 163 tool_call_id=result.tool_call_id, 164 ) 165 166 # 4. SLEEP 167 await self._persist(context) 168 169 return AgentResult( 170 tool_results=tool_results, 171 continue_loop=True, 172 ) 173 174 # No tool calls - final response 175 # 4. SLEEP 176 await self._persist(context) 177 178 return AgentResult( 179 final_response=response.content, 180 continue_loop=False, 181 )
Execute a single step of the ReAct loop.
Args: session_key: Session identifier. user_message: The user's message. workspace: Optional working directory for context.
Returns: AgentResult with response and/or tool results.
183 async def run( 184 self, 185 session_key: str, 186 user_message: str, 187 workspace: Path | None = None, 188 max_steps: int = 10, 189 ) -> str: 190 """ 191 Run the agent to completion. 192 193 Executes the ReAct loop until the agent produces a final response 194 or max_steps is reached. 195 196 Args: 197 session_key: Session identifier. 198 user_message: The user's message. 199 workspace: Optional working directory. 200 max_steps: Maximum tool execution steps. 201 202 Returns: 203 The agent's final response. 204 """ 205 steps = 0 206 current_message = user_message 207 208 while steps < max_steps: 209 result = await self.step(session_key, current_message, workspace) 210 211 if not result.continue_loop: 212 return result.final_response or "" 213 214 # Continue with empty message (tool results already in session) 215 current_message = "" 216 steps += 1 217 218 await logger.awarning("Agent reached max steps", max_steps=max_steps) 219 return "I've reached the maximum number of steps. Please try a simpler request."
Run the agent to completion.
Executes the ReAct loop until the agent produces a final response or max_steps is reached.
Args: session_key: Session identifier. user_message: The user's message. workspace: Optional working directory. max_steps: Maximum tool execution steps.
Returns: The agent's final response.
221 async def stream( 222 self, 223 message: str, 224 session_key: str, 225 workspace: Path | None = None, 226 _client_id: str | None = None, 227 ) -> AsyncIterator[Block]: 228 """ 229 Stream agent response with block chunking. 230 231 Args: 232 message: The user's message. 233 session_key: Session identifier. 234 workspace: Optional working directory. 235 _client_id: Optional client ID (reserved for future use). 236 237 Yields: 238 Block objects as they become complete. 239 """ 240 user_message = message 241 # 1. HYDRATE 242 context = await self._hydrate(session_key, workspace) 243 244 # Add user message 245 context.session.add_message(role="user", content=user_message) 246 247 # Compact session before thinking 248 self._compact_session(context.session) 249 250 # Build messages for LLM 251 messages = self._build_messages(context) 252 253 # Get tool schemas if we have tools 254 tools = self._get_tool_schemas(context) if context.tools else None 255 256 # Stream response 257 chunker = BlockChunker() 258 full_content = "" 259 260 async for delta in self.llm.stream(messages, tools=tools): 261 if delta.content: 262 full_content += delta.content 263 264 # Process through chunker 265 for block in chunker.process(delta.content): 266 yield block 267 268 # Emit events 269 if block.type == "thinking": 270 await self.event_bus.broadcast( 271 ThoughtEvent(content=block.content, agent_id=self.config.agent_id) 272 ) 273 else: 274 await self.event_bus.broadcast( 275 TextEvent( 276 content=block.content, 277 agent_id=self.config.agent_id, 278 is_final=False, 279 ) 280 ) 281 282 # Flush remaining content and broadcast 283 for block in chunker.flush(): 284 yield block 285 286 # Emit events for flushed blocks (important for short responses!) 287 if block.type == "thinking": 288 await self.event_bus.broadcast( 289 ThoughtEvent(content=block.content, agent_id=self.config.agent_id) 290 ) 291 else: 292 await self.event_bus.broadcast( 293 TextEvent( 294 content=block.content, 295 agent_id=self.config.agent_id, 296 is_final=True, # Mark as final since we're flushing 297 ) 298 ) 299 300 # Add assistant response to session 301 context.session.add_message(role="assistant", content=full_content) 302 303 # Persist 304 await self._persist(context)
Stream agent response with block chunking.
Args: message: The user's message. session_key: Session identifier. workspace: Optional working directory. _client_id: Optional client ID (reserved for future use).
Yields: Block objects as they become complete.
55@dataclass 56class AgentContext: 57 """Context for agent execution.""" 58 59 session: SessionState 60 config: TiphysConfig 61 tools: list[ToolDefinition] = field(default_factory=list) 62 workspace: Path | None = None 63 custom_instructions: str | None = None
Context for agent execution.
45@dataclass 46class AgentResult: 47 """Result from a single agent step.""" 48 49 final_response: str | None = None 50 tool_results: list[ToolResult] = field(default_factory=list) 51 thinking: str | None = None 52 continue_loop: bool = False
Result from a single agent step.
34@dataclass 35class ToolResult: 36 """Result from executing a tool.""" 37 38 tool_call_id: str 39 tool_name: str 40 success: bool 41 output: str 42 error: str | None = None
Result from executing a tool.
28class DmScope(str, Enum): 29 """DM session scoping modes.""" 30 31 MAIN = "main" # All DMs share context 32 PER_PEER = "per_peer" # Each user gets own context 33 PER_CHANNEL_PEER = "per_channel_peer" # Per channel + peer 34 FULL = "full" # Maximum granularity
DM session scoping modes.
31class Peer(BaseModel): 32 """Represents a user or entity on a messaging platform.""" 33 34 id: str 35 kind: PeerKind = PeerKind.USER 36 username: str | None = None 37 display_name: str | None = None 38 is_bot: bool = False 39 raw: dict[str, Any] = Field(default_factory=dict)
Represents a user or entity on a messaging platform.
23class PeerKind(str, Enum): 24 """Type of peer in a conversation.""" 25 26 USER = "user" 27 BOT = "bot" 28 SYSTEM = "system"
Type of peer in a conversation.
37class SessionMessage(BaseModel): 38 """A message in a session.""" 39 40 role: str # "system", "user", "assistant", "tool" 41 content: str | None = None 42 name: str | None = None 43 tool_call_id: str | None = None 44 tool_calls: list[dict[str, Any]] | None = None 45 timestamp: datetime = Field(default_factory=datetime.utcnow)
A message in a session.
48class SessionState(BaseModel): 49 """Persisted session state.""" 50 51 session_key: str 52 agent_id: str 53 messages: list[SessionMessage] = Field(default_factory=list) 54 created_at: datetime = Field(default_factory=datetime.utcnow) 55 updated_at: datetime = Field(default_factory=datetime.utcnow) 56 metadata: dict[str, Any] = Field(default_factory=dict) 57 last_message_id: str | None = None 58 59 def add_message( 60 self, 61 role: str, 62 content: str | None = None, 63 name: str | None = None, 64 tool_call_id: str | None = None, 65 tool_calls: list[dict[str, Any]] | None = None, 66 ) -> SessionMessage: 67 """Add a message to the session.""" 68 message = SessionMessage( 69 role=role, 70 content=content, 71 name=name, 72 tool_call_id=tool_call_id, 73 tool_calls=tool_calls, 74 ) 75 self.messages.append(message) 76 self.updated_at = datetime.utcnow() 77 return message 78 79 def get_messages_for_llm(self) -> list[dict[str, Any]]: 80 """Get messages in LLM-compatible format.""" 81 return [m.model_dump(exclude={"timestamp"}, exclude_none=True) for m in self.messages] 82 83 def clear_messages(self) -> None: 84 """Clear all messages from the session.""" 85 self.messages = [] 86 self.updated_at = datetime.utcnow() 87 88 def truncate_to(self, max_messages: int) -> int: 89 """ 90 Truncate message history to max_messages. 91 92 Keeps the system message (if any) and the most recent messages. 93 94 Returns: 95 Number of messages removed. 96 """ 97 if len(self.messages) <= max_messages: 98 return 0 99 100 # Find system message 101 system_msg = None 102 other_msgs: list[SessionMessage] = [] 103 for msg in self.messages: 104 if msg.role == "system": 105 system_msg = msg 106 else: 107 other_msgs.append(msg) 108 109 # Calculate how many to keep 110 keep_count = max_messages - (1 if system_msg else 0) 111 removed = len(other_msgs) - keep_count 112 113 # Rebuild messages 114 new_messages: list[SessionMessage] = [] 115 if system_msg: 116 new_messages.append(system_msg) 117 new_messages.extend(other_msgs[-keep_count:]) 118 119 self.messages = new_messages 120 self.updated_at = datetime.utcnow() 121 122 return removed 123 124 def compact(self, guard: "ContextGuard") -> int: 125 """ 126 Compact session history using a ContextGuard. 127 128 Args: 129 guard: The context guard to use for token counting and truncation. 130 131 Returns: 132 Number of messages removed. 133 """ 134 # Convert SessionMessage to dict for ContextGuard 135 msg_dicts = [m.model_dump(exclude={"timestamp"}, exclude_none=True) for m in self.messages] 136 137 if guard.count_tokens(msg_dicts) <= guard.max_context: 138 return 0 139 140 original_count = len(self.messages) 141 142 # Find system message 143 system_msg = None 144 other_msgs = [] 145 for m in self.messages: 146 if m.role == "system": 147 system_msg = m 148 else: 149 other_msgs.append(m) 150 151 # Keep removing oldest non-system messages until it fits 152 while len(other_msgs) > 5: 153 other_msgs.pop(0) 154 155 candidate = [] 156 if system_msg: 157 candidate.append(system_msg) 158 candidate.extend(other_msgs) 159 160 cand_dicts = [m.model_dump(exclude={"timestamp"}, exclude_none=True) for m in candidate] 161 162 if guard.count_tokens(cand_dicts) <= guard.max_context: 163 self.messages = candidate 164 self.updated_at = datetime.utcnow() 165 removed = original_count - len(self.messages) 166 logger.info("Session compacted", removed=removed, session_key=self.session_key) 167 return removed 168 169 return 0
Persisted session state.
59 def add_message( 60 self, 61 role: str, 62 content: str | None = None, 63 name: str | None = None, 64 tool_call_id: str | None = None, 65 tool_calls: list[dict[str, Any]] | None = None, 66 ) -> SessionMessage: 67 """Add a message to the session.""" 68 message = SessionMessage( 69 role=role, 70 content=content, 71 name=name, 72 tool_call_id=tool_call_id, 73 tool_calls=tool_calls, 74 ) 75 self.messages.append(message) 76 self.updated_at = datetime.utcnow() 77 return message
Add a message to the session.
79 def get_messages_for_llm(self) -> list[dict[str, Any]]: 80 """Get messages in LLM-compatible format.""" 81 return [m.model_dump(exclude={"timestamp"}, exclude_none=True) for m in self.messages]
Get messages in LLM-compatible format.
83 def clear_messages(self) -> None: 84 """Clear all messages from the session.""" 85 self.messages = [] 86 self.updated_at = datetime.utcnow()
Clear all messages from the session.
88 def truncate_to(self, max_messages: int) -> int: 89 """ 90 Truncate message history to max_messages. 91 92 Keeps the system message (if any) and the most recent messages. 93 94 Returns: 95 Number of messages removed. 96 """ 97 if len(self.messages) <= max_messages: 98 return 0 99 100 # Find system message 101 system_msg = None 102 other_msgs: list[SessionMessage] = [] 103 for msg in self.messages: 104 if msg.role == "system": 105 system_msg = msg 106 else: 107 other_msgs.append(msg) 108 109 # Calculate how many to keep 110 keep_count = max_messages - (1 if system_msg else 0) 111 removed = len(other_msgs) - keep_count 112 113 # Rebuild messages 114 new_messages: list[SessionMessage] = [] 115 if system_msg: 116 new_messages.append(system_msg) 117 new_messages.extend(other_msgs[-keep_count:]) 118 119 self.messages = new_messages 120 self.updated_at = datetime.utcnow() 121 122 return removed
Truncate message history to max_messages.
Keeps the system message (if any) and the most recent messages.
Returns: Number of messages removed.
124 def compact(self, guard: "ContextGuard") -> int: 125 """ 126 Compact session history using a ContextGuard. 127 128 Args: 129 guard: The context guard to use for token counting and truncation. 130 131 Returns: 132 Number of messages removed. 133 """ 134 # Convert SessionMessage to dict for ContextGuard 135 msg_dicts = [m.model_dump(exclude={"timestamp"}, exclude_none=True) for m in self.messages] 136 137 if guard.count_tokens(msg_dicts) <= guard.max_context: 138 return 0 139 140 original_count = len(self.messages) 141 142 # Find system message 143 system_msg = None 144 other_msgs = [] 145 for m in self.messages: 146 if m.role == "system": 147 system_msg = m 148 else: 149 other_msgs.append(m) 150 151 # Keep removing oldest non-system messages until it fits 152 while len(other_msgs) > 5: 153 other_msgs.pop(0) 154 155 candidate = [] 156 if system_msg: 157 candidate.append(system_msg) 158 candidate.extend(other_msgs) 159 160 cand_dicts = [m.model_dump(exclude={"timestamp"}, exclude_none=True) for m in candidate] 161 162 if guard.count_tokens(cand_dicts) <= guard.max_context: 163 self.messages = candidate 164 self.updated_at = datetime.utcnow() 165 removed = original_count - len(self.messages) 166 logger.info("Session compacted", removed=removed, session_key=self.session_key) 167 return removed 168 169 return 0
Compact session history using a ContextGuard.
Args: guard: The context guard to use for token counting and truncation.
Returns: Number of messages removed.
172def build_session_key( 173 agent_id: str, 174 channel: str, 175 account: str, 176 peer: Peer, 177 scope: DmScope, 178 group_id: str | None = None, 179) -> str: 180 """ 181 Build a session key based on scoping mode. 182 183 Args: 184 agent_id: The agent identifier. 185 channel: Channel name (e.g., "telegram", "discord"). 186 account: Bot account identifier. 187 peer: The peer in the conversation. 188 scope: How to scope the session. 189 group_id: Optional group/chat ID for isolation. 190 191 Returns: 192 Composite session key string. 193 """ 194 parts = [agent_id] 195 196 match scope: 197 case DmScope.MAIN: 198 parts.append("main") 199 case DmScope.PER_PEER: 200 parts.extend([peer.kind.value, peer.id]) 201 case DmScope.PER_CHANNEL_PEER: 202 # If group_id is present, include it for isolation 203 if group_id: 204 parts.extend([channel, "group", group_id, peer.id]) 205 else: 206 parts.extend([channel, peer.kind.value, peer.id]) 207 case DmScope.FULL: 208 parts.extend([channel, account, peer.kind.value, peer.id]) 209 if group_id: 210 parts.append(group_id) 211 212 return ":".join(parts)
Build a session key based on scoping mode.
Args: agent_id: The agent identifier. channel: Channel name (e.g., "telegram", "discord"). account: Bot account identifier. peer: The peer in the conversation. scope: How to scope the session. group_id: Optional group/chat ID for isolation.
Returns: Composite session key string.
434def get_session_store() -> SessionStoreProtocol: 435 """Get the global session store.""" 436 global _session_store 437 if _session_store is None: 438 _session_store = FileSessionStore() 439 return _session_store
Get the global session store.
13@dataclass 14class Block: 15 """A complete semantic block of content.""" 16 17 type: Literal["text", "code", "thinking"] 18 content: str
A complete semantic block of content.
21@dataclass 22class BlockChunker: 23 """ 24 Buffers streaming tokens into complete semantic blocks. 25 26 Prevents jittery UI by only emitting complete paragraphs or code blocks. 27 Also handles <think> tags by stripping them from user output. 28 """ 29 30 buffer: str = "" 31 thinking_buffer: str = "" 32 in_thinking: bool = False 33 code_fence_count: int = 0 34 accumulated_thinking: str = "" 35 36 @property 37 def in_code_block(self) -> bool: 38 """Whether we're currently inside a code block.""" 39 return self.code_fence_count % 2 == 1 40 41 def process(self, delta: str) -> list[Block]: 42 """ 43 Process a token delta and return complete blocks. 44 45 Args: 46 delta: The new token(s) to process. 47 48 Returns: 49 List of complete blocks ready to emit. 50 """ 51 blocks: list[Block] = [] 52 53 # Add delta to appropriate buffer 54 if self.in_thinking: 55 self.thinking_buffer += delta 56 else: 57 self.buffer += delta 58 59 # Check for thinking tag transitions 60 blocks.extend(self._handle_thinking_tags()) 61 62 # Don't emit content while thinking 63 if self.in_thinking: 64 return blocks 65 66 # Track code fence state 67 self.code_fence_count += delta.count("```") 68 69 # Don't emit partial code blocks 70 if self.in_code_block: 71 return blocks 72 73 # Emit complete paragraphs (separated by double newline) 74 while "\n\n" in self.buffer: 75 block_content, self.buffer = self.buffer.split("\n\n", 1) 76 if block_content.strip(): 77 block_type: Literal["text", "code"] = "code" if "```" in block_content else "text" 78 blocks.append(Block(type=block_type, content=block_content.strip())) 79 80 return blocks 81 82 def _handle_thinking_tags(self) -> list[Block]: 83 """Handle transitions in and out of thinking tags.""" 84 blocks: list[Block] = [] 85 86 # Check for start of thinking 87 if not self.in_thinking and "<think>" in self.buffer: 88 self.in_thinking = True 89 before, after = self.buffer.split("<think>", 1) 90 self.buffer = before 91 self.thinking_buffer = after 92 93 # Check for end of thinking 94 if self.in_thinking and "</think>" in self.thinking_buffer: 95 self.in_thinking = False 96 thinking_content, after = self.thinking_buffer.split("</think>", 1) 97 98 # Store thinking content 99 self.accumulated_thinking += thinking_content.strip() 100 101 # Emit thinking block if there's content 102 if thinking_content.strip(): 103 blocks.append(Block(type="thinking", content=thinking_content.strip())) 104 105 # Move remaining content back to main buffer 106 self.buffer += after 107 self.thinking_buffer = "" 108 109 return blocks 110 111 def flush(self) -> list[Block]: 112 """ 113 Flush remaining buffer content. 114 115 Call this when the stream ends to get any remaining content. 116 117 Returns: 118 List of remaining blocks. 119 """ 120 blocks: list[Block] = [] 121 122 # If we were thinking, close it 123 if self.in_thinking and self.thinking_buffer.strip(): 124 self.accumulated_thinking += self.thinking_buffer.strip() 125 blocks.append(Block(type="thinking", content=self.thinking_buffer.strip())) 126 self.thinking_buffer = "" 127 self.in_thinking = False 128 129 # Emit remaining buffer content 130 if self.buffer.strip(): 131 block_type: Literal["text", "code"] = "code" if "```" in self.buffer else "text" 132 blocks.append(Block(type=block_type, content=self.buffer.strip())) 133 self.buffer = "" 134 135 return blocks 136 137 def get_thinking(self) -> str: 138 """ 139 Get accumulated thinking content. 140 141 Returns: 142 All thinking content collected during processing. 143 """ 144 return self.accumulated_thinking 145 146 def reset(self) -> None: 147 """Reset the chunker to initial state.""" 148 self.buffer = "" 149 self.thinking_buffer = "" 150 self.in_thinking = False 151 self.code_fence_count = 0 152 self.accumulated_thinking = ""
Buffers streaming tokens into complete semantic blocks.
Prevents jittery UI by only emitting complete paragraphs or code blocks.
Also handles
36 @property 37 def in_code_block(self) -> bool: 38 """Whether we're currently inside a code block.""" 39 return self.code_fence_count % 2 == 1
Whether we're currently inside a code block.
41 def process(self, delta: str) -> list[Block]: 42 """ 43 Process a token delta and return complete blocks. 44 45 Args: 46 delta: The new token(s) to process. 47 48 Returns: 49 List of complete blocks ready to emit. 50 """ 51 blocks: list[Block] = [] 52 53 # Add delta to appropriate buffer 54 if self.in_thinking: 55 self.thinking_buffer += delta 56 else: 57 self.buffer += delta 58 59 # Check for thinking tag transitions 60 blocks.extend(self._handle_thinking_tags()) 61 62 # Don't emit content while thinking 63 if self.in_thinking: 64 return blocks 65 66 # Track code fence state 67 self.code_fence_count += delta.count("```") 68 69 # Don't emit partial code blocks 70 if self.in_code_block: 71 return blocks 72 73 # Emit complete paragraphs (separated by double newline) 74 while "\n\n" in self.buffer: 75 block_content, self.buffer = self.buffer.split("\n\n", 1) 76 if block_content.strip(): 77 block_type: Literal["text", "code"] = "code" if "```" in block_content else "text" 78 blocks.append(Block(type=block_type, content=block_content.strip())) 79 80 return blocks
Process a token delta and return complete blocks.
Args: delta: The new token(s) to process.
Returns: List of complete blocks ready to emit.
111 def flush(self) -> list[Block]: 112 """ 113 Flush remaining buffer content. 114 115 Call this when the stream ends to get any remaining content. 116 117 Returns: 118 List of remaining blocks. 119 """ 120 blocks: list[Block] = [] 121 122 # If we were thinking, close it 123 if self.in_thinking and self.thinking_buffer.strip(): 124 self.accumulated_thinking += self.thinking_buffer.strip() 125 blocks.append(Block(type="thinking", content=self.thinking_buffer.strip())) 126 self.thinking_buffer = "" 127 self.in_thinking = False 128 129 # Emit remaining buffer content 130 if self.buffer.strip(): 131 block_type: Literal["text", "code"] = "code" if "```" in self.buffer else "text" 132 blocks.append(Block(type=block_type, content=self.buffer.strip())) 133 self.buffer = "" 134 135 return blocks
Flush remaining buffer content.
Call this when the stream ends to get any remaining content.
Returns: List of remaining blocks.
137 def get_thinking(self) -> str: 138 """ 139 Get accumulated thinking content. 140 141 Returns: 142 All thinking content collected during processing. 143 """ 144 return self.accumulated_thinking
Get accumulated thinking content.
Returns: All thinking content collected during processing.
168def extract_thinking(text: str) -> str: 169 """ 170 Extract content from <think>...</think> tags. 171 172 Args: 173 text: Text that may contain thinking tags. 174 175 Returns: 176 Concatenated content from all thinking tags. 177 """ 178 matches = re.findall(r"<think>(.*?)</think>", text, flags=re.DOTALL) 179 return "\n".join(match.strip() for match in matches)
Extract content from
Args: text: Text that may contain thinking tags.
Returns: Concatenated content from all thinking tags.