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]
class LLMClient:
 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.

LLMClient( model: str = 'openrouter/anthropic/claude-3.5-sonnet', api_base: str | None = None, api_key: str | None = None, temperature: float = 0.7, max_tokens: int | None = None, extra_headers: dict[str, str] | None = None, auth_manager: tiphys.auth.profiles.AuthProfileManager | None = None)
 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.

model
api_base
api_key
default_temperature
default_max_tokens
extra_headers
auth_manager
@classmethod
def from_config(cls, config: tiphys.TiphysConfig) -> LLMClient:
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.

@classmethod
def from_settings(cls, settings: tiphys.LLMSettings) -> LLMClient:
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.

async def complete( self, messages: list[Message], tools: list[dict[str, typing.Any]] | None = None, temperature: float | None = None, max_tokens: int | None = None) -> LLMResponse:
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.

async def stream( self, messages: list[Message], tools: list[dict[str, typing.Any]] | None = None, temperature: float | None = None, max_tokens: int | None = None) -> AsyncIterator[StreamDelta]:
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.

@dataclass
class LLMResponse:
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.

LLMResponse( content: str | None = None, tool_calls: list[ToolCall] = <factory>, finish_reason: str | None = None, usage: dict[str, int] | None = None)
content: str | None = None
tool_calls: list[ToolCall]
finish_reason: str | None = None
usage: dict[str, int] | None = None
class Message(pydantic.main.BaseModel):
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.

role: str = PydanticUndefined
content: str | None = None
name: str | None = None
tool_call_id: str | None = None
tool_calls: list[dict[str, typing.Any]] | None = None
@dataclass
class StreamDelta:
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.

StreamDelta( content: str | None = None, tool_call_id: str | None = None, tool_call_name: str | None = None, tool_call_arguments: str | None = None, finish_reason: str | None = None)
content: str | None = None
tool_call_id: str | None = None
tool_call_name: str | None = None
tool_call_arguments: str | None = None
finish_reason: str | None = None
@dataclass
class ToolCall:
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.

ToolCall(id: str, name: str, arguments: dict[str, typing.Any])
id: str
name: str
arguments: dict[str, typing.Any]
class SystemPromptBuilder:
 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
def build( self, mode: Literal['full', 'minimal'] = 'full', agent_name: str = 'Tiphys', tools: list[tiphys.tools.base.ToolDefinition] | None = None, workspace: pathlib.Path | None = None, memory_snippets: list[str] | None = None, custom_instructions: str | None = None) -> str:
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.

def build_system_prompt( mode: Literal['full', 'minimal'] = 'full', agent_name: str = 'Tiphys', tools: list[tiphys.tools.base.ToolDefinition] | None = None, workspace: pathlib.Path | None = None, memory_snippets: list[str] | None = None, custom_instructions: str | None = None) -> str:
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.

class AgentRunner:
 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:

  1. HYDRATE: Load config, skills, session history
  2. THINK: Call LLM with tools
  3. ACT: Execute tool calls (with policy checks)
  4. SLEEP: Persist state and exit
AgentRunner( config: tiphys.TiphysConfig | None = None, llm_client: LLMClient | None = None, session_store: tiphys.agent.session.FileSessionStore | None = None, event_bus: tiphys.server.EventBus | None = None, tool_registry: tiphys.tools.base.ToolRegistry | None = None, *, context: tiphys.context.AppContext | None = None)
 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)
async def step( self, session_key: str, user_message: str, workspace: pathlib.Path | None = None) -> AgentResult:
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.

async def run( self, session_key: str, user_message: str, workspace: pathlib.Path | None = None, max_steps: int = 10) -> str:
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.

async def stream( self, message: str, session_key: str, workspace: pathlib.Path | None = None, _client_id: str | None = None) -> AsyncIterator[Block]:
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.

@dataclass
class AgentContext:
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.

AgentContext( session: SessionState, config: tiphys.TiphysConfig, tools: list[tiphys.tools.base.ToolDefinition] = <factory>, workspace: pathlib.Path | None = None, custom_instructions: str | None = None)
session: SessionState
workspace: pathlib.Path | None = None
custom_instructions: str | None = None
@dataclass
class AgentResult:
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.

AgentResult( final_response: str | None = None, tool_results: list[ToolResult] = <factory>, thinking: str | None = None, continue_loop: bool = False)
final_response: str | None = None
tool_results: list[ToolResult]
thinking: str | None = None
continue_loop: bool = False
@dataclass
class ToolResult:
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.

ToolResult( tool_call_id: str, tool_name: str, success: bool, output: str, error: str | None = None)
tool_call_id: str
tool_name: str
success: bool
output: str
error: str | None = None
class DmScope(builtins.str, enum.Enum):
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.

MAIN = <DmScope.MAIN: 'main'>
PER_PEER = <DmScope.PER_PEER: 'per_peer'>
PER_CHANNEL_PEER = <DmScope.PER_CHANNEL_PEER: 'per_channel_peer'>
FULL = <DmScope.FULL: 'full'>
class Peer(pydantic.main.BaseModel):
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.

id: str = PydanticUndefined
kind: PeerKind = <PeerKind.USER: 'user'>
username: str | None = None
display_name: str | None = None
is_bot: bool = False
raw: dict[str, typing.Any] = PydanticUndefined
class PeerKind(builtins.str, enum.Enum):
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.

USER = <PeerKind.USER: 'user'>
BOT = <PeerKind.BOT: 'bot'>
SYSTEM = <PeerKind.SYSTEM: 'system'>
class SessionMessage(pydantic.main.BaseModel):
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.

role: str = PydanticUndefined
content: str | None = None
name: str | None = None
tool_call_id: str | None = None
tool_calls: list[dict[str, typing.Any]] | None = None
timestamp: datetime.datetime = PydanticUndefined
class SessionState(pydantic.main.BaseModel):
 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.

session_key: str = PydanticUndefined
agent_id: str = PydanticUndefined
messages: list[SessionMessage] = PydanticUndefined
created_at: datetime.datetime = PydanticUndefined
updated_at: datetime.datetime = PydanticUndefined
metadata: dict[str, typing.Any] = PydanticUndefined
last_message_id: str | None = None
def add_message( self, role: str, content: str | None = None, name: str | None = None, tool_call_id: str | None = None, tool_calls: list[dict[str, typing.Any]] | None = None) -> SessionMessage:
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.

def get_messages_for_llm(self) -> list[dict[str, typing.Any]]:
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.

def clear_messages(self) -> None:
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.

def truncate_to(self, max_messages: int) -> int:
 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.

def compact(self, guard: tiphys.agent.context_guard.ContextGuard) -> int:
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.

SessionStore = <class 'tiphys.agent.session.FileSessionStore'>
def build_session_key( agent_id: str, channel: str, account: str, peer: Peer, scope: DmScope, group_id: str | None = None) -> str:
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.

def get_session_store() -> tiphys.agent.session.SessionStoreProtocol:
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.

@dataclass
class Block:
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.

Block(type: Literal['text', 'code', 'thinking'], content: str)
type: Literal['text', 'code', 'thinking']
content: str
@dataclass
class BlockChunker:
 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 tags by stripping them from user output.

BlockChunker( buffer: str = '', thinking_buffer: str = '', in_thinking: bool = False, code_fence_count: int = 0, accumulated_thinking: str = '')
buffer: str = ''
thinking_buffer: str = ''
in_thinking: bool = False
code_fence_count: int = 0
accumulated_thinking: str = ''
in_code_block: bool
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.

def process(self, delta: str) -> list[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.

def flush(self) -> list[Block]:
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.

def get_thinking(self) -> str:
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.

def reset(self) -> None:
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 = ""

Reset the chunker to initial state.

def strip_thinking_tags(text: str) -> str:
155def strip_thinking_tags(text: str) -> str:
156    """
157    Remove <think>...</think> tags from text.
158
159    Args:
160        text: Text that may contain thinking tags.
161
162    Returns:
163        Text with thinking tags and their content removed.
164    """
165    return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()

Remove ... tags from text.

Args: text: Text that may contain thinking tags.

Returns: Text with thinking tags and their content removed.

def extract_thinking(text: str) -> str:
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 ... tags.

Args: text: Text that may contain thinking tags.

Returns: Concatenated content from all thinking tags.