From b4e167b9915d60d7ee1efdebc06536e1ac873ee6 Mon Sep 17 00:00:00 2001 From: "filip.chytil" Date: Thu, 9 Jul 2026 20:53:04 +0200 Subject: [PATCH] agent.run() with query/msg history added --- docs/api-reference.md | 16 +++++- docs/concepts/agents.md | 74 +++++++++++++++++++++++++++- docs/guides/building-agents.md | 8 +-- tinygent/agents/base_agent.py | 9 ++++ tinygent/agents/map_agent.py | 29 ++++++----- tinygent/agents/multi_step_agent.py | 42 ++++++++++------ tinygent/agents/react_agent.py | 32 +++++++----- tinygent/agents/squad_agent.py | 30 ++++++----- tinygent/core/datamodels/agent.py | 47 +++++++++++++++++- tinygent/core/datamodels/memory.py | 10 ++++ tinygent/core/datamodels/messages.py | 26 +++++----- tinygent/memory/base_chat_memory.py | 16 ++++++ 12 files changed, 269 insertions(+), 70 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index f7602d5..b7fb3c9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -157,10 +157,22 @@ agent = TinyReActAgent( **Methods:** -- `run(task: str) -> str`: Execute task synchronously -- `run_stream(task: str) -> AsyncIterator[str]`: Execute with streaming +- `run(input_text=None, *, history=None, run_id=None, checkpoint_id=None, reset=True) -> str`: Execute synchronously +- `run_stream(input_text=None, *, history=None, run_id=None, checkpoint_id=None, reset=True) -> AsyncIterator[str]`: Execute with streaming - `reset()`: Clear agent state +All agents share the same `run` / `run_stream` signature (see [Running Agents](concepts/agents.md#running-agents)): + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `input_text` | `str \| None` | `None` | The query to run. Positional; saved to memory as a human message. | +| `history` | `list[AllTinyMessages] \| None` | `None` | Pre-existing conversation messages loaded into memory before the run. | +| `run_id` | `str \| None` | `None` | Identifier for the run. Auto-generated (UUID) when omitted. | +| `checkpoint_id` | `str \| None` | `None` | Checkpoint to restore before running. | +| `reset` | `bool` | `True` | Reset the agent's state (memory + checkpointer) before running. | + +You must provide `input_text`, `history`, or both — passing neither raises `ValueError`. When only `history` is given, the agent responds to the conversation as-is; when both are given, `input_text` is appended as the latest human message. + --- ### MultiStepAgent diff --git a/docs/concepts/agents.md b/docs/concepts/agents.md index 45d2f77..067a958 100644 --- a/docs/concepts/agents.md +++ b/docs/concepts/agents.md @@ -280,6 +280,74 @@ agent = build_agent( --- +## Running Agents + +Every agent exposes `run` (synchronous) and `run_stream` (async streaming) with the same signature: + +```python +agent.run( + input_text: str | None = None, + *, + history: list[AllTinyMessages] | None = None, + run_id: str | None = None, + checkpoint_id: str | None = None, + reset: bool = True, +) -> str +``` + +You can drive the agent with **a query**, **a message history**, or **both**. Providing neither raises a `ValueError`. + +### Run with a query + +Pass the query as the first positional argument. It is saved to memory as a human message: + +```python +result = agent.run('What is the weather in Prague?') +``` + +### Run with a message history + +Pass a list of messages via `history`. They are loaded into memory before the run, and the agent responds to the conversation as-is (no new query required): + +```python +from tinygent.core.datamodels.messages import TinyHumanMessage +from tinygent.core.datamodels.messages import TinyChatMessage + +result = agent.run( + history=[ + TinyHumanMessage(content='What is the weather in Prague?'), + TinyChatMessage(content='It is sunny, 75°F.'), + TinyHumanMessage(content='How about tomorrow?'), + ], +) +``` + +### Combine query and history + +When both are supplied, `history` seeds the conversation and `input_text` is appended as the latest human message: + +```python +result = agent.run( + 'How about tomorrow?', + history=[ + TinyHumanMessage(content='What is the weather in Prague?'), + TinyChatMessage(content='It is sunny, 75°F.'), + ], +) +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `input_text` | `str \| None` | `None` | The query to run. Saved to memory as a human message. | +| `history` | `list[AllTinyMessages] \| None` | `None` | Pre-existing conversation messages loaded into memory before the run. | +| `run_id` | `str \| None` | `None` | Identifier for the run. Auto-generated (UUID) when omitted. | +| `checkpoint_id` | `str \| None` | `None` | Checkpoint to restore before running. | +| `reset` | `bool` | `True` | Reset the agent's state (memory + checkpointer) before running. Set to `False` to continue an existing conversation. | + +--- + ## Memory Agents can remember conversation history using memory: @@ -297,10 +365,12 @@ agent = build_agent( # First conversation agent.run('What is the weather in Prague?') -# Second conversation - agent remembers context -agent.run('How about tomorrow?') +# Second conversation - pass reset=False so the agent keeps prior context +agent.run('How about tomorrow?', reset=False) ``` +Because `reset` defaults to `True`, each `run` clears memory by default. Pass `reset=False` to continue an existing conversation. + See [Memory](memory.md) for more details. --- diff --git a/docs/guides/building-agents.md b/docs/guides/building-agents.md index 905197d..e5efa37 100644 --- a/docs/guides/building-agents.md +++ b/docs/guides/building-agents.md @@ -125,9 +125,10 @@ result = agent.run( ) print(result) -# Follow-up conversation (uses memory) +# Follow-up conversation (reset=False keeps memory from the previous run) result = agent.run( - 'Actually, make it a 5-day trip and I need accommodations for 2 people.' + 'Actually, make it a 5-day trip and I need accommodations for 2 people.', + reset=False, ) print(result) ``` @@ -345,7 +346,8 @@ while True: if user_input.lower() in ['quit', 'exit']: break - response = agent.run(user_input) + # reset=False so each turn builds on the conversation so far + response = agent.run(user_input, reset=False) print(f"Agent: {response}") ``` diff --git a/tinygent/agents/base_agent.py b/tinygent/agents/base_agent.py index 236ce26..3ac71a7 100644 --- a/tinygent/agents/base_agent.py +++ b/tinygent/agents/base_agent.py @@ -24,6 +24,7 @@ from tinygent.core.datamodels.llm import AbstractLLMConfig from tinygent.core.datamodels.memory import AbstractMemory from tinygent.core.datamodels.memory import AbstractMemoryConfig +from tinygent.core.datamodels.messages import AllTinyMessages from tinygent.core.datamodels.messages import TinyToolCall from tinygent.core.datamodels.messages import TinyToolResult from tinygent.core.datamodels.middleware import AbstractMiddleware @@ -166,6 +167,14 @@ def __init__( ) self._final_answer: str | None = None + def _runtime_validation( + self, + input_text: str | None = None, + history: list[AllTinyMessages] | None = None, + ) -> None: + if input_text is None and history is None: + raise ValueError("Either 'input_text' or 'history' must be set.") + def reset(self) -> None: logger.debug('[BASE AGENT RESET]') diff --git a/tinygent/agents/map_agent.py b/tinygent/agents/map_agent.py index ed548cf..bc09bb2 100644 --- a/tinygent/agents/map_agent.py +++ b/tinygent/agents/map_agent.py @@ -748,20 +748,27 @@ async def _map(self, run_id: str, question: str) -> list[TinyMAPActionProposal]: return self.checkpointer['final_plan'] @tiny_trace('agent_run') - async def _run_agent(self, input_text: str, run_id: str) -> str: + async def _run_agent(self, input_text: str | None, run_id: str) -> str: set_tiny_attributes( { 'agent.type': 'map', 'agent.run_id': run_id, - 'agent.input_text': input_text, + **({'agent.input_text': input_text} if input_text else {}), } ) - logger.debug('Running agent with task: %s', input_text) - self.memory.save_context(TinyHumanMessage(content=input_text)) + if input_text is not None: + self.memory.save_context(TinyHumanMessage(content=input_text)) + + user_query = self.memory.get_last_msg_by_type(TinyHumanMessage) + + if user_query is None: + raise ValueError('Missing user query.') + + logger.debug('Running agent with task: %s', user_query.content) try: - final_plan = await self._map(run_id, input_text) + final_plan = await self._map(run_id, user_query.content) return '\n\n'.join([p.sum for p in final_plan]) except Exception as e: @@ -793,14 +800,14 @@ def setup( def run( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> str: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) @@ -815,14 +822,14 @@ async def _run() -> str: def run_stream( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> AsyncGenerator[str, None]: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) diff --git a/tinygent/agents/multi_step_agent.py b/tinygent/agents/multi_step_agent.py index 60ded4f..d189e52 100644 --- a/tinygent/agents/multi_step_agent.py +++ b/tinygent/agents/multi_step_agent.py @@ -247,25 +247,35 @@ async def _stream_fallback_answer( yield chunk.message @tiny_trace('agent_run') - async def _run_agent(self, input_text: str, run_id: str) -> AsyncGenerator[str]: + async def _run_agent( + self, + input_text: str | None, + run_id: str, + ) -> AsyncGenerator[str]: set_tiny_attributes( { 'agent.type': 'multistep', 'agent.max_iterations': str(self.max_iterations), 'agent.plan_interval': str(self.plan_interval), 'agent.run_id': run_id, - 'agent.input_text': input_text, + **({'agent.input_text': input_text} if input_text else {}), } ) - logger.debug('[%s] Running agent with input %s', run_id, input_text) + if input_text is not None: + self.memory.save_context(TinyHumanMessage(content=input_text)) + + user_query = self.memory.get_last_msg_by_type(TinyHumanMessage) + + if user_query is None: + raise ValueError('Missing user query.') + + logger.debug('[%s] Running agent with input %s', run_id, user_query.content) self._checkpointer['_iteration_number'] = 1 returned_final_answer: bool = False yielded_final_answer: str = '' - self.memory.save_context(TinyHumanMessage(content=input_text)) - while not returned_final_answer and ( self._checkpointer['_iteration_number'] <= self.max_iterations ): @@ -282,7 +292,9 @@ async def _run_agent(self, input_text: str, run_id: str) -> AsyncGenerator[str]: == 0 ): # Create new plan - plan_generator = self._stream_steps(run_id=run_id, task=input_text) + plan_generator = self._stream_steps( + run_id=run_id, task=user_query.content + ) self._checkpointer['_planned_steps'] = [] async for planner_msg in plan_generator: @@ -310,7 +322,9 @@ async def _run_agent(self, input_text: str, run_id: str) -> AsyncGenerator[str]: try: # Execute action - async for msg in self._stream_action(run_id=run_id, task=input_text): + async for msg in self._stream_action( + run_id=run_id, task=user_query.content + ): if msg.is_message and isinstance( msg.message, TinyChatMessageChunk ): @@ -381,7 +395,7 @@ async def _run_agent(self, input_text: str, run_id: str) -> AsyncGenerator[str]: logger.debug('--- FALLBACK FINAL ANSWER ---') async for chunk in self._stream_fallback_answer( - run_id=run_id, task=input_text + run_id=run_id, task=user_query.content ): yield_fallback = True final_yielded_answer += chunk.content @@ -421,14 +435,14 @@ def setup( def run( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> str: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) @@ -445,14 +459,14 @@ async def _run() -> str: def run_stream( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> AsyncGenerator[str, None]: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) diff --git a/tinygent/agents/react_agent.py b/tinygent/agents/react_agent.py index 439e325..5296511 100644 --- a/tinygent/agents/react_agent.py +++ b/tinygent/agents/react_agent.py @@ -241,21 +241,27 @@ async def _stream_fallback( @tiny_trace('agent_run') async def _run_agent( - self, input_text: str, run_id: str + self, input_text: str | None, run_id: str ) -> AsyncGenerator[str, None]: set_tiny_attributes( { 'agent.type': 'react', 'agent.max_iterations': str(self.max_iterations), 'agent.run_id': run_id, - 'agent.input_text': input_text, + **({'agent.input_text': input_text} if input_text else {}), } ) - logger.debug('Running agent with task: %s', input_text) - self._init_state() + if input_text is not None: + self.memory.save_context(TinyHumanMessage(content=input_text)) + + user_query = self.memory.get_last_msg_by_type(TinyHumanMessage) + if user_query is None: + raise ValueError('Missing user query.') - self.memory.save_context(TinyHumanMessage(content=input_text)) + logger.debug('Running agent with task: %s', user_query.content) + + self._init_state() while not self.checkpointer['returned_final_answer'] and ( self.checkpointer['iteration_number'] <= self.max_iterations @@ -270,7 +276,7 @@ async def _run_agent( try: reasoning_result = await self._stream_reasoning( - run_id=run_id, task=input_text + run_id=run_id, task=user_query.content ) logger.debug( '[%d. ITERATION - Reasoning Result]: %s', @@ -382,7 +388,7 @@ async def _run_agent( final_yielded_answer = '' async for fallback_chunk in self._stream_fallback( - run_id=run_id, task=input_text + run_id=run_id, task=user_query.content ): yielded_fallback = True final_yielded_answer += fallback_chunk @@ -420,14 +426,14 @@ def setup( def run( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> str: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) @@ -444,14 +450,14 @@ async def _run() -> str: def run_stream( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> AsyncGenerator[str, None]: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) diff --git a/tinygent/agents/squad_agent.py b/tinygent/agents/squad_agent.py index 5fec43a..189c200 100644 --- a/tinygent/agents/squad_agent.py +++ b/tinygent/agents/squad_agent.py @@ -254,27 +254,35 @@ class _ClassificationQueryResult(TinyModel): @tiny_trace('agent_run') async def _run_agent( - self, input_text: str, run_id: str + self, input_text: str | None, run_id: str ) -> AsyncGenerator[str, None]: set_tiny_attributes( { 'agent.type': 'squad', 'agent.run_id': run_id, - 'agent.input_text': input_text, 'agent.squad_size': len(self._squad), 'agent.squad_members': ','.join( f'{member.name} - {member.description}' for member in self._squad ), + **({'agent.input_text': input_text} if input_text else {}), } ) - logger.debug('Running agent with task: %s', input_text) + + if input_text is not None: + self.memory.save_context(TinyHumanMessage(content=input_text)) + + user_query = self.memory.get_last_msg_by_type(TinyHumanMessage) + + if user_query is None: + raise ValueError('Missing user query.') + + logger.debug('Running agent with task: %s', user_query.content) final_answer = '' - self.memory.save_context(TinyHumanMessage(content=input_text)) try: classification_result = await self._classify_query( - run_id=run_id, input_text=input_text + run_id=run_id, input_text=user_query.content ) selected_member = self._get_squad_member( classification_result.selected_member @@ -326,14 +334,14 @@ def setup( def run( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> str: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) @@ -350,14 +358,14 @@ async def _run() -> str: def run_stream( self, - input_text: str, + input_text: str | None = None, *, + history: list[AllTinyMessages] | None = None, run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, - history: list[AllTinyMessages] | None = None, ) -> AsyncGenerator[str, None]: - logger.debug('[USER INPUT] %s', input_text) + self._runtime_validation(input_text, history) run_id = run_id or str(uuid.uuid4()) self.setup(reset=reset, history=history, checkpoint_id=checkpoint_id) diff --git a/tinygent/core/datamodels/agent.py b/tinygent/core/datamodels/agent.py index 3543341..fc2db06 100644 --- a/tinygent/core/datamodels/agent.py +++ b/tinygent/core/datamodels/agent.py @@ -6,6 +6,7 @@ from typing import ClassVar from typing import Generic from typing import TypeVar +from typing import overload from tinygent.agents.middleware.agent import TinyMiddlewareAgent from tinygent.core.datamodels.checkpointer import AbstractCheckpointer @@ -45,28 +46,70 @@ def reset(self) -> None: """Reset the agent's internal state.""" raise NotImplementedError('Subclasses must implement this method.') - @abstractmethod + @overload def run( self, input_text: str, *, + history: list[AllTinyMessages] | None = None, + run_id: str | None = None, + checkpoint_id: str | None = None, + reset: bool = True, + ) -> str: ... + + @overload + def run( + self, + *, + history: list[AllTinyMessages], run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, + ) -> str: ... + + @abstractmethod + def run( + self, + input_text: str | None = None, + *, history: list[AllTinyMessages] | None = None, + run_id: str | None = None, + checkpoint_id: str | None = None, + reset: bool = True, ) -> str: """Run the agent with the given input text.""" raise NotImplementedError('Subclasses must implement this method.') - @abstractmethod + @overload def run_stream( self, input_text: str, *, + history: list[AllTinyMessages] | None = None, + run_id: str | None = None, + checkpoint_id: str | None = None, + reset: bool = True, + ) -> AsyncGenerator[str, None]: ... + + @overload + def run_stream( + self, + *, + history: list[AllTinyMessages], run_id: str | None = None, checkpoint_id: str | None = None, reset: bool = True, + ) -> AsyncGenerator[str, None]: ... + + @abstractmethod + def run_stream( + self, + input_text: str | None = None, + *, history: list[AllTinyMessages] | None = None, + run_id: str | None = None, + checkpoint_id: str | None = None, + reset: bool = True, ) -> AsyncGenerator[str, None]: """Run the agent in streaming mode with the given input text.""" raise NotImplementedError('Subclasses must implement this method.') diff --git a/tinygent/core/datamodels/memory.py b/tinygent/core/datamodels/memory.py index ddefdf4..87b69c7 100644 --- a/tinygent/core/datamodels/memory.py +++ b/tinygent/core/datamodels/memory.py @@ -5,10 +5,12 @@ from typing import TypeVar from tinygent.core.datamodels.messages import AllTinyMessages +from tinygent.core.datamodels.messages import TinyMessage from tinygent.core.runtime.executors import run_sync_in_executor from tinygent.core.types.builder import TinyModelBuildable T = TypeVar('T', bound='AbstractMemory') +M = TypeVar('M', bound='TinyMessage') class AbstractMemoryConfig(TinyModelBuildable[T], Generic[T]): @@ -48,6 +50,14 @@ def save_multiple_context(self, messages: list[AllTinyMessages]) -> None: """Save multiple messages to memory.""" raise NotImplementedError('Subclasses must implement this method.') + @abstractmethod + def get_last_msg_by_type(self, message_type: type[M]) -> M | None: + raise NotImplementedError('Subclasses must implement this method.') + + @abstractmethod + def get_last_n_msgs_by_type(self, message_type: type[M], n: int) -> list[M]: + raise NotImplementedError('Subclasses must implement this method.') + @abstractmethod def clear(self) -> None: """Clear the memory.""" diff --git a/tinygent/core/datamodels/messages.py b/tinygent/core/datamodels/messages.py index c8a9178..6dc84ac 100644 --- a/tinygent/core/datamodels/messages.py +++ b/tinygent/core/datamodels/messages.py @@ -289,19 +289,21 @@ def tiny_str(self) -> str: | TinySummaryMessage ) +TinyMessage = ( + TinyPlanMessage + | TinyReasoningMessage + | TinyChatMessage + | TinyToolCall + | TinySquadMemberMessage + | TinyHumanMessage + | TinySystemMessage + | TinyUserMessage + | TinyToolResult + | TinySummaryMessage +) + AllTinyMessages = Annotated[ - ( - TinyPlanMessage - | TinyReasoningMessage - | TinyChatMessage - | TinyToolCall - | TinySquadMemberMessage - | TinyHumanMessage - | TinySystemMessage - | TinyUserMessage - | TinyToolResult - | TinySummaryMessage - ), + TinyMessage, Field(discriminator='type'), ] diff --git a/tinygent/memory/base_chat_memory.py b/tinygent/memory/base_chat_memory.py index bf439bd..e6fa02c 100644 --- a/tinygent/memory/base_chat_memory.py +++ b/tinygent/memory/base_chat_memory.py @@ -3,14 +3,18 @@ from abc import ABC from io import StringIO import typing +from typing import TypeVar from tinygent.core.chat_history import BaseChatHistory from tinygent.core.datamodels.memory import AbstractMemory +from tinygent.core.datamodels.messages import TinyMessage from tinygent.utils.pydantic_utils import tiny_deep_copy if typing.TYPE_CHECKING: from tinygent.core.datamodels.messages import AllTinyMessages +M = TypeVar('M', bound='TinyMessage') + class BaseChatMemory(AbstractMemory, ABC): def __init__(self) -> None: @@ -26,6 +30,18 @@ def save_multiple_context(self, messages: list[AllTinyMessages]) -> None: for msg in messages: self.save_context(msg) + def get_last_msg_by_type(self, message_type: type[M]) -> M | None: + msgs = [ + msg for msg in self._chat_history.messages if isinstance(msg, message_type) + ] + return msgs[-1] if msgs else None + + def get_last_n_msgs_by_type(self, message_type: type[M], n: int) -> list[M]: + msgs = [ + msg for msg in self._chat_history.messages if isinstance(msg, message_type) + ] + return list(reversed(msgs))[:n] + def clear(self) -> None: self._chat_history.clear()