chore: respecting stop reason (#17)

* chore: respecting stop reason

* fix: add back template substitution

* typo fix

* increase timeout allow slower model
This commit is contained in:
Zane Chen
2026-05-19 20:16:37 -04:00
committed by GitHub
parent f45e79af53
commit 8e71c10f13
83 changed files with 732 additions and 210 deletions
@@ -50,6 +50,8 @@ class LLMProvider:
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
+13 -5
View File
@@ -19,7 +19,7 @@ Giving the agent the ability to actually *do* things, from chatting only to taki
## Key Components ## Key Components
- **Stop Reason**: Chat Loop can stop because of "end_turn" or "tool_use" - **Stop Reason**: Chat loop branches on `stop_reason``"tool_calls"` to execute tools, `"stop"` for normal completion, `"length"` for truncated responses
- **Tools**: Manages available tools and executes tool calls - **Tools**: Manages available tools and executes tool calls
- **Tool Calling Loop**: Agent calls tools, adds results to history, continues conversation - **Tool Calling Loop**: Agent calls tools, adds results to history, continues conversation
@@ -59,10 +59,11 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
assistant_msg: Message = { assistant_msg: Message = {
"role": "assistant", "role": "assistant",
@@ -71,10 +72,17 @@ class AgentSession:
} }
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
break
return content return content
``` ```
+16 -5
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -65,10 +66,11 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -86,12 +88,21 @@ class AgentSession:
assistant_msg["tool_calls"] = tool_call_dicts assistant_msg["tool_calls"] = tool_call_dicts
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
+2 -2
View File
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+16 -5
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -81,10 +82,11 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -102,12 +104,21 @@ class AgentSession:
assistant_msg["tool_calls"] = tool_call_dicts assistant_msg["tool_calls"] = tool_call_dicts
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
+2 -2
View File
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+16 -5
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -83,10 +84,11 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -104,12 +106,21 @@ class AgentSession:
assistant_msg["tool_calls"] = tool_call_dicts assistant_msg["tool_calls"] = tool_call_dicts
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+16 -5
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -91,10 +92,11 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -112,12 +114,21 @@ class AgentSession:
assistant_msg["tool_calls"] = tool_call_dicts assistant_msg["tool_calls"] = tool_call_dicts
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+16 -5
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -104,13 +105,14 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -128,12 +130,21 @@ class AgentSession:
assistant_msg["tool_calls"] = tool_call_dicts assistant_msg["tool_calls"] = tool_call_dicts
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -134,7 +134,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+16 -5
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -115,13 +116,14 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -139,12 +141,21 @@ class AgentSession:
assistant_msg["tool_calls"] = tool_call_dicts assistant_msg["tool_calls"] = tool_call_dicts
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
+1 -1
View File
@@ -134,7 +134,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -93,7 +93,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -157,11 +158,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -180,12 +182,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -137,7 +137,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -96,7 +96,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -157,11 +158,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -180,12 +182,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -137,7 +137,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -168,11 +169,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -191,12 +193,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
+1 -1
View File
@@ -153,7 +153,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -168,11 +169,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -191,12 +193,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
+1 -1
View File
@@ -153,7 +153,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -168,11 +169,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -191,12 +193,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -154,7 +154,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -168,11 +169,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -191,12 +193,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -154,7 +154,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -168,11 +169,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -191,12 +193,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -154,7 +154,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -45,17 +45,31 @@ class PromptBuilder:
return "\n\n".join(layers) return "\n\n".join(layers)
def _substitute_paths(self, text: str) -> str:
"""Replace {{placeholder}} tokens with resolved config paths."""
cfg = self.context.config
replacements = {
"{{workspace}}": str(cfg.workspace),
"{{skills_path}}": str(cfg.skills_path),
"{{crons_path}}": str(cfg.crons_path),
"{{memories_path}}": str(cfg.memories_path),
"{{agents_path}}": str(cfg.agents_path),
}
for placeholder, value in replacements.items():
text = text.replace(placeholder, value)
return text
def _load_bootstrap_context(self) -> str: def _load_bootstrap_context(self) -> str:
"""Load BOOTSTRAP.md + AGENTS.md + cron list.""" """Load BOOTSTRAP.md + AGENTS.md + cron list."""
parts = [] parts = []
bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md" bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md"
if bootstrap_path.exists(): if bootstrap_path.exists():
parts.append(bootstrap_path.read_text().strip()) parts.append(self._substitute_paths(bootstrap_path.read_text().strip()))
agents_path = self.context.config.workspace / "AGENTS.md" agents_path = self.context.config.workspace / "AGENTS.md"
if agents_path.exists(): if agents_path.exists():
parts.append(agents_path.read_text().strip()) parts.append(self._substitute_paths(agents_path.read_text().strip()))
# Dynamic cron list # Dynamic cron list
cron_list = self._format_cron_list() cron_list = self._format_cron_list()
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -177,11 +178,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -200,12 +202,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -154,7 +154,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -45,17 +45,31 @@ class PromptBuilder:
return "\n\n".join(layers) return "\n\n".join(layers)
def _substitute_paths(self, text: str) -> str:
"""Replace {{placeholder}} tokens with resolved config paths."""
cfg = self.context.config
replacements = {
"{{workspace}}": str(cfg.workspace),
"{{skills_path}}": str(cfg.skills_path),
"{{crons_path}}": str(cfg.crons_path),
"{{memories_path}}": str(cfg.memories_path),
"{{agents_path}}": str(cfg.agents_path),
}
for placeholder, value in replacements.items():
text = text.replace(placeholder, value)
return text
def _load_bootstrap_context(self) -> str: def _load_bootstrap_context(self) -> str:
"""Load BOOTSTRAP.md + AGENTS.md + cron list.""" """Load BOOTSTRAP.md + AGENTS.md + cron list."""
parts = [] parts = []
bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md" bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md"
if bootstrap_path.exists(): if bootstrap_path.exists():
parts.append(bootstrap_path.read_text().strip()) parts.append(self._substitute_paths(bootstrap_path.read_text().strip()))
agents_path = self.context.config.workspace / "AGENTS.md" agents_path = self.context.config.workspace / "AGENTS.md"
if agents_path.exists(): if agents_path.exists():
parts.append(agents_path.read_text().strip()) parts.append(self._substitute_paths(agents_path.read_text().strip()))
# Dynamic cron list # Dynamic cron list
cron_list = self._format_cron_list() cron_list = self._format_cron_list()
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -185,11 +186,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -208,12 +210,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -154,7 +154,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -45,17 +45,31 @@ class PromptBuilder:
return "\n\n".join(layers) return "\n\n".join(layers)
def _substitute_paths(self, text: str) -> str:
"""Replace {{placeholder}} tokens with resolved config paths."""
cfg = self.context.config
replacements = {
"{{workspace}}": str(cfg.workspace),
"{{skills_path}}": str(cfg.skills_path),
"{{crons_path}}": str(cfg.crons_path),
"{{memories_path}}": str(cfg.memories_path),
"{{agents_path}}": str(cfg.agents_path),
}
for placeholder, value in replacements.items():
text = text.replace(placeholder, value)
return text
def _load_bootstrap_context(self) -> str: def _load_bootstrap_context(self) -> str:
"""Load BOOTSTRAP.md + AGENTS.md + cron list.""" """Load BOOTSTRAP.md + AGENTS.md + cron list."""
parts = [] parts = []
bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md" bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md"
if bootstrap_path.exists(): if bootstrap_path.exists():
parts.append(bootstrap_path.read_text().strip()) parts.append(self._substitute_paths(bootstrap_path.read_text().strip()))
agents_path = self.context.config.workspace / "AGENTS.md" agents_path = self.context.config.workspace / "AGENTS.md"
if agents_path.exists(): if agents_path.exists():
parts.append(agents_path.read_text().strip()) parts.append(self._substitute_paths(agents_path.read_text().strip()))
# Dynamic cron list # Dynamic cron list
cron_list = self._format_cron_list() cron_list = self._format_cron_list()
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -185,11 +186,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -208,12 +210,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
@@ -154,7 +154,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
@@ -45,17 +45,31 @@ class PromptBuilder:
return "\n\n".join(layers) return "\n\n".join(layers)
def _substitute_paths(self, text: str) -> str:
"""Replace {{placeholder}} tokens with resolved config paths."""
cfg = self.context.config
replacements = {
"{{workspace}}": str(cfg.workspace),
"{{skills_path}}": str(cfg.skills_path),
"{{crons_path}}": str(cfg.crons_path),
"{{memories_path}}": str(cfg.memories_path),
"{{agents_path}}": str(cfg.agents_path),
}
for placeholder, value in replacements.items():
text = text.replace(placeholder, value)
return text
def _load_bootstrap_context(self) -> str: def _load_bootstrap_context(self) -> str:
"""Load BOOTSTRAP.md + AGENTS.md + cron list.""" """Load BOOTSTRAP.md + AGENTS.md + cron list."""
parts = [] parts = []
bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md" bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md"
if bootstrap_path.exists(): if bootstrap_path.exists():
parts.append(bootstrap_path.read_text().strip()) parts.append(self._substitute_paths(bootstrap_path.read_text().strip()))
agents_path = self.context.config.workspace / "AGENTS.md" agents_path = self.context.config.workspace / "AGENTS.md"
if agents_path.exists(): if agents_path.exists():
parts.append(agents_path.read_text().strip()) parts.append(self._substitute_paths(agents_path.read_text().strip()))
# Dynamic cron list # Dynamic cron list
cron_list = self._format_cron_list() cron_list = self._format_cron_list()
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )
+1 -1
View File
@@ -31,7 +31,7 @@ cookie: Yes, <content>.
cd 17-memory cd 17-memory
uv run my-bot chat uv run my-bot chat
# You: Remember that I my name is Zane # You: Remember that my name is Zane
# Pickle: Got it! I've saved that preference. # Pickle: Got it! I've saved that preference.
uv run my-bot chat uv run my-bot chat
+1 -1
View File
@@ -100,7 +100,7 @@ class ChatLoop:
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.response_queue.get(), timeout=60.0 self.response_queue.get(), timeout=120.0
) )
self.display_agent_response(response.content) self.display_agent_response(response.content)
+16 -5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
import json import json
import logging
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -185,11 +186,12 @@ class AgentSession:
self.state.add_message(user_msg) self.state.add_message(user_msg)
tool_schemas = self.tools.get_tool_schemas() tool_schemas = self.tools.get_tool_schemas()
logger = logging.getLogger(__name__)
while True: while True:
messages = self.state.build_messages() messages = self.state.build_messages()
self.state = await self.context_guard.check_and_compact(self.state) self.state = await self.context_guard.check_and_compact(self.state)
content, tool_calls = await self.agent.llm.chat(messages, tool_schemas) content, tool_calls, stop_reason = await self.agent.llm.chat(messages, tool_schemas)
tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [ tool_call_dicts: list[ChatCompletionMessageToolCallParam] = [
{ {
@@ -208,12 +210,21 @@ class AgentSession:
self.state.add_message(assistant_msg) self.state.add_message(assistant_msg)
if not tool_calls: if stop_reason == "tool_calls":
break await self._handle_tool_calls(tool_calls)
continue
await self._handle_tool_calls(tool_calls) if stop_reason == "length":
logger.warning(
"LLM response truncated (max_tokens reached), "
"returning partial response"
)
continue if stop_reason == "content_filter":
logger.warning("LLM response filtered by content filter")
return content if content else "I'm unable to respond to that request."
break
return content return content
+1 -1
View File
@@ -154,7 +154,7 @@ class ContextGuard:
summary_prompt = COMPACT_PROMPT.format(conversation=old_text) summary_prompt = COMPACT_PROMPT.format(conversation=old_text)
response, _ = await state.agent.llm.chat( response, _, _ = await state.agent.llm.chat(
[{"role": "user", "content": summary_prompt}], [{"role": "user", "content": summary_prompt}],
[], # No tools needed [], # No tools needed
) )
+16 -2
View File
@@ -45,17 +45,31 @@ class PromptBuilder:
return "\n\n".join(layers) return "\n\n".join(layers)
def _substitute_paths(self, text: str) -> str:
"""Replace {{placeholder}} tokens with resolved config paths."""
cfg = self.context.config
replacements = {
"{{workspace}}": str(cfg.workspace),
"{{skills_path}}": str(cfg.skills_path),
"{{crons_path}}": str(cfg.crons_path),
"{{memories_path}}": str(cfg.memories_path),
"{{agents_path}}": str(cfg.agents_path),
}
for placeholder, value in replacements.items():
text = text.replace(placeholder, value)
return text
def _load_bootstrap_context(self) -> str: def _load_bootstrap_context(self) -> str:
"""Load BOOTSTRAP.md + AGENTS.md + cron list.""" """Load BOOTSTRAP.md + AGENTS.md + cron list."""
parts = [] parts = []
bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md" bootstrap_path = self.context.config.workspace / "BOOTSTRAP.md"
if bootstrap_path.exists(): if bootstrap_path.exists():
parts.append(bootstrap_path.read_text().strip()) parts.append(self._substitute_paths(bootstrap_path.read_text().strip()))
agents_path = self.context.config.workspace / "AGENTS.md" agents_path = self.context.config.workspace / "AGENTS.md"
if agents_path.exists(): if agents_path.exists():
parts.append(agents_path.read_text().strip()) parts.append(self._substitute_paths(agents_path.read_text().strip()))
# Dynamic cron list # Dynamic cron list
cron_list = self._format_cron_list() cron_list = self._format_cron_list()
+2 -2
View File
@@ -1,5 +1,5 @@
"""LLM provider abstraction.""" """LLM provider abstraction."""
from .base import LLMProvider, LLMToolCall from .base import LLMProvider, LLMToolCall, StopReason
__all__ = ["LLMProvider", "LLMToolCall"] __all__ = ["LLMProvider", "LLMToolCall", "StopReason"]
+18 -3
View File
@@ -5,10 +5,13 @@ from typing import Any, Optional, cast
from litellm import acompletion, Choices, TYPE_CHECKING from litellm import acompletion, Choices, TYPE_CHECKING
from litellm.types.completion import ChatCompletionMessageParam as Message from litellm.types.completion import ChatCompletionMessageParam as Message
from litellm.types.utils import OpenAIChatCompletionFinishReason
if TYPE_CHECKING: if TYPE_CHECKING:
from mybot.utils.config import LLMConfig from mybot.utils.config import LLMConfig
StopReason = OpenAIChatCompletionFinishReason
@dataclass @dataclass
class LLMToolCall: class LLMToolCall:
@@ -55,12 +58,21 @@ class LLMProvider:
messages: list[Message], messages: list[Message],
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[str, list[LLMToolCall]]: ) -> tuple[str, list[LLMToolCall], StopReason]:
"""Default implementation using litellm. Subclasses can override.""" """Send a chat request to the LLM.
Default implementation using litellm. Subclasses can override
if provider-specific behavior is needed.
Returns:
Tuple of (content, tool_calls, stop_reason)
"""
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"api_key": self.api_key, "api_key": self.api_key,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
} }
if self.api_base: if self.api_base:
@@ -71,7 +83,9 @@ class LLMProvider:
response = await acompletion(**request_kwargs) response = await acompletion(**request_kwargs)
message = cast(Choices, response.choices[0]).message choice = cast(Choices, response.choices[0])
message = choice.message
stop_reason = choice.finish_reason
return ( return (
message.content or "", message.content or "",
@@ -83,4 +97,5 @@ class LLMProvider:
) )
for tc in (message.tool_calls or []) for tc in (message.tool_calls or [])
], ],
stop_reason,
) )