Python: Telegram hosted agent sample#5393
Python: Telegram hosted agent sample#5393eavanvalkenburg wants to merge 2 commits intomicrosoft:feature/python-harnessfrom
Conversation
Co-authored-by: Copilot <copilot@github.com>
90285fd to
708a28c
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new Telegram “hosted agent” sample under python/samples/04-hosting/telegram/, and promotes several “harness” components (todo list, saved items, session mode) into agent-framework-core as experimental building blocks. Also updates the OpenAI chat client to ignore additional reasoning-summary streaming event types and adds coverage for that behavior.
Changes:
- Add a full Telegram long-polling sample (agent wiring, session persistence, streaming edits, reminders via JobQueue, inline approval buttons).
- Introduce experimental harness APIs in core (
Todo*,SavedItems*,SessionMode*) with unit tests and public exports. - Extend OpenAI streaming parsing to explicitly ignore
response.reasoning_summary_part.*events, with tests.
Reviewed changes
Copilot reviewed 23 out of 25 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| python/samples/README.md | Updates samples index to mention Telegram under hosting/connectors. |
| python/samples/04-hosting/telegram/pyproject.toml | Adds sample-local uv project + dependencies. |
| python/samples/04-hosting/telegram/providers.py | Implements Telegram-specific history provider + reminder context/tools. |
| python/samples/04-hosting/telegram/main.py | Wires agent + providers/stores; starts Telegram polling app. |
| python/samples/04-hosting/telegram/helpers.py | Shared state, formatting, local persistence, and utility helpers. |
| python/samples/04-hosting/telegram/handlers.py | Telegram command handlers, streaming edit loop, approvals, reminders callback. |
| python/samples/04-hosting/telegram/init.py | Declares sample package. |
| python/samples/04-hosting/telegram/README.md | Documents setup, features, and how to run the Telegram sample. |
| python/samples/04-hosting/telegram/.python-version | Pins sample Python version for local dev. |
| python/samples/04-hosting/telegram/.gitignore | Ignores runtime sessions/ data. |
| python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py | Adds Pyright missing-import suppression for optional orjson. |
| python/samples/02-agents/conversations/file_history_provider.py | Adds Pyright missing-import suppression for optional orjson. |
| python/pyrightconfig.samples.json | Excludes the Telegram sample from sample Pyright runs. |
| python/packages/openai/tests/openai/test_openai_chat_client.py | Adds tests for ignored reasoning summary part events + delta ID tracking. |
| python/packages/openai/agent_framework_openai/_chat_client.py | Ignores response.reasoning_summary_part.* events explicitly. |
| python/packages/core/tests/core/test_harness_todo.py | Adds unit tests for new todo harness models/stores/provider. |
| python/packages/core/tests/core/test_harness_saved_items.py | Adds unit tests for new saved-items harness models/stores/provider. |
| python/packages/core/tests/core/test_harness_mode.py | Adds unit tests for new session-mode harness helpers/provider. |
| python/packages/core/agent_framework/_harness/_todo.py | Adds todo harness implementation (models, stores, provider, tools). |
| python/packages/core/agent_framework/_harness/_saved_items.py | Adds saved-items harness implementation (models, stores, provider, tools). |
| python/packages/core/agent_framework/_harness/_mode.py | Adds session-mode harness helpers and provider. |
| python/packages/core/agent_framework/_harness/init.py | Introduces harness package entrypoint. |
| python/packages/core/agent_framework/_feature_stage.py | Adds ExperimentalFeature.HARNESS. |
| python/packages/core/agent_framework/init.py | Re-exports harness APIs from top-level agent_framework. |
| ) | ||
|
|
||
| # 2. Create the Telegram application and store the mutable sample state in bot_data. | ||
| application = ApplicationBuilder().token(os.environ["TELEGRAM_BOT_TOKEN"]).concurrent_updates(True).build() |
There was a problem hiding this comment.
concurrent_updates(True) can race for messages from the same chat: two updates can pass _has_active_turn before active_turn_task is set, resulting in overlapping streams/edits against the same placeholder message and shared ChatState. Consider disabling concurrent updates for this sample or adding a per-chat async lock around the “start turn” path (check+register).
| application = ApplicationBuilder().token(os.environ["TELEGRAM_BOT_TOKEN"]).concurrent_updates(True).build() | |
| # Process updates sequentially in this sample to avoid races on shared per-chat state. | |
| application = ApplicationBuilder().token(os.environ["TELEGRAM_BOT_TOKEN"]).concurrent_updates(False).build() |
| session_entry = chat_state.sessions.get(reminder_entry.session_id) | ||
| if session_entry is None: | ||
| session_entry = _get_session_entry_by_session_id(chat_state, reminder_entry.session_id) | ||
| if session_entry is None: |
There was a problem hiding this comment.
This lookup uses reminder_entry.session_id as a key into chat_state.sessions, but chat_state.sessions is keyed by session label (e.g., session-1), not session id. As written, this branch will always miss and fall back to _get_session_entry_by_session_id. Remove the incorrect lookup (or index sessions by id) to avoid dead code and confusion.
| session_entry = chat_state.sessions.get(reminder_entry.session_id) | |
| if session_entry is None: | |
| session_entry = _get_session_entry_by_session_id(chat_state, reminder_entry.session_id) | |
| if session_entry is None: | |
| session_entry = _get_session_entry_by_session_id(chat_state, reminder_entry.session_id) | |
| if session_entry is None: |
| def _get_state_path(self, session: AgentSession) -> Path: | ||
| """Return the JSON file path for one session.""" | ||
| session_directory = self.base_path | ||
| if self.owner_state_key is not None: | ||
| owner_value = session.state.get(self.owner_state_key) | ||
| if owner_value is None: | ||
| raise RuntimeError( | ||
| f"TodoFileStore requires session.state[{self.owner_state_key!r}] to be set for file-backed storage." | ||
| ) | ||
| session_directory = session_directory / f"{self.owner_prefix}{owner_value}" / self.kind | ||
| session_directory = session_directory / session.session_id | ||
| session_directory.mkdir(parents=True, exist_ok=True) | ||
| return session_directory / self.state_filename |
There was a problem hiding this comment.
TodoFileStore builds filesystem paths directly from owner_state_key and session.session_id without validating/sanitizing them. If these values contain path separators or .., callers can write outside base_path (path traversal). Please add the same kind of resolved-path validation/encoding that FileHistoryProvider uses (resolve the final path and assert it stays under the storage root, or encode unsafe segments).
| def _get_state_path(self, session: AgentSession) -> Path: | |
| """Return the JSON file path for one session.""" | |
| session_directory = self.base_path | |
| if self.owner_state_key is not None: | |
| owner_value = session.state.get(self.owner_state_key) | |
| if owner_value is None: | |
| raise RuntimeError( | |
| f"TodoFileStore requires session.state[{self.owner_state_key!r}] to be set for file-backed storage." | |
| ) | |
| session_directory = session_directory / f"{self.owner_prefix}{owner_value}" / self.kind | |
| session_directory = session_directory / session.session_id | |
| session_directory.mkdir(parents=True, exist_ok=True) | |
| return session_directory / self.state_filename | |
| def _resolve_storage_path(self, *parts: str) -> Path: | |
| """Resolve a storage path and ensure it remains within the configured root.""" | |
| storage_root = self.base_path.resolve() | |
| candidate_path = storage_root.joinpath(*parts).resolve(strict=False) | |
| try: | |
| candidate_path.relative_to(storage_root) | |
| except ValueError as exc: | |
| raise ValueError(f"Resolved todo storage path escapes base_path: {candidate_path}") from exc | |
| return candidate_path | |
| def _get_state_path(self, session: AgentSession) -> Path: | |
| """Return the JSON file path for one session.""" | |
| path_parts: list[str] = [] | |
| if self.owner_state_key is not None: | |
| owner_value = session.state.get(self.owner_state_key) | |
| if owner_value is None: | |
| raise RuntimeError( | |
| f"TodoFileStore requires session.state[{self.owner_state_key!r}] to be set for file-backed storage." | |
| ) | |
| path_parts.extend([f"{self.owner_prefix}{owner_value}", self.kind]) | |
| path_parts.extend([session.session_id, self.state_filename]) | |
| state_path = self._resolve_storage_path(*path_parts) | |
| state_path.parent.mkdir(parents=True, exist_ok=True) | |
| return state_path |
| raise ValueError("ids must contain at least one todo ID.") | ||
|
|
||
| items, next_id = self.store.load_state(session, source_id=self.source_id) | ||
| remaining_items = [item for item in items if item.id not in set(ids)] |
There was a problem hiding this comment.
remove_todos recreates set(ids) inside the list comprehension, which is unnecessary work for every item. Compute the set once (like complete_todos does) and reuse it to keep this tool cheap for larger todo lists.
| remaining_items = [item for item in items if item.id not in set(ids)] | |
| id_set = set(ids) | |
| remaining_items = [item for item in items if item.id not in id_set] |
| def _get_owner_directory(self, session: AgentSession) -> Path: | ||
| """Return the owner-level storage directory.""" | ||
| owner_directory = self.base_path / f"{self.owner_prefix}{self._get_owner_id(session)}" / self.kind | ||
| owner_directory.mkdir(parents=True, exist_ok=True) | ||
| return owner_directory | ||
|
|
||
| def _get_session_directory(self, session: AgentSession, *, session_id: str | None = None) -> Path: | ||
| """Return the session-level storage directory.""" | ||
| session_directory = self._get_owner_directory(session) / (session_id or session.session_id) | ||
| session_directory.mkdir(parents=True, exist_ok=True) | ||
| return session_directory |
There was a problem hiding this comment.
SavedItemsFileStore constructs owner/session directories directly from owner_state_key and session_id values without guarding against path traversal (e.g., ../ segments or path separators). Please apply resolved-path validation/encoding similar to FileHistoryProvider so persisted saved items cannot escape base_path even if the session state is attacker-controlled.
| "azure-ai-agents>=1.1.0", | ||
| "azure-monitor-opentelemetry>=1.8.7", | ||
| "orjson>=3.11.8", | ||
| "python-telegram-bot[ext]>=22.7", |
There was a problem hiding this comment.
python-telegram-bot[ext] doesn’t include the JobQueue extra that this sample relies on (the code calls application.job_queue and the runtime error message instructs installing python-telegram-bot[job-queue]). Update the dependency extra to include JobQueue so the reminder tooling works out of the box.
| "python-telegram-bot[ext]>=22.7", | |
| "python-telegram-bot[ext,job-queue]>=22.7", |
| [project] | ||
| name = "telegram" | ||
| version = "0.1.0" | ||
| description = "Add your description here" |
There was a problem hiding this comment.
The project metadata still has the placeholder description ("Add your description here"). Please replace it with a meaningful one (e.g., that this is an Agent Framework Telegram hosting sample) so uv/package tooling and readers don’t surface placeholder text.
| description = "Add your description here" | |
| description = "Agent Framework Telegram hosting sample" |
| From the `python/` directory: | ||
|
|
||
| ```bash | ||
| uv run samples/04-hosting/telegram/main.py |
There was a problem hiding this comment.
The run instructions say to execute this sample from the python/ directory (uv run samples/04-hosting/telegram/main.py), but the dependencies it needs (e.g., python-telegram-bot, tiktoken, orjson) are declared in the sample-local pyproject.toml, not in python/pyproject.toml. Update the README to either (a) run from the sample directory, or (b) use uv run --project samples/04-hosting/telegram ... so the declared dependencies are actually used.
| From the `python/` directory: | |
| ```bash | |
| uv run samples/04-hosting/telegram/main.py | |
| From the `python/` directory, run the sample with its local project so `uv` installs and uses the dependencies declared in `samples/04-hosting/telegram/pyproject.toml`: | |
| ```bash | |
| uv run --project samples/04-hosting/telegram samples/04-hosting/telegram/main.py |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Motivation and Context
This PR adds a first end-to-end Telegram hosting sample for the Python repo and promotes the reusable pieces that sample needed into the core package.
The goal is to show how to connect a Telegram bot to an Agent Framework agent for direct 1:1 chat while also making todos, saved items, and session mode reusable outside the sample.
Description
This PR adds a new
samples/04-hosting/telegram/sample that:python-telegram-botlong polling for direct 1:1 Telegram conversationsFoundryChatClient-backed agent with Foundry web search, code interpreter, and a UTC time toolsamples/04-hosting/telegram/sessions/user_<telegram_user_id>/...To support that sample, this PR also adds reusable harness components to
agent_frameworkunderpackages/core/agent_framework/_harness/and re-exports them from the root package:TodoSessionStore,TodoFileStore, andTodoListContextProviderSavedItemsSessionStore,SavedItemsFileStore, andSavedItemsContextProviderSessionModeContextProviderplusget_session_mode/set_session_modeThese new harness APIs are marked with the
HARNESSexperimental feature stage and include focused core tests organized as one test file per harness module.In addition, this PR updates the OpenAI Responses client to ignore
response.reasoning_summary_part.*events and adds coverage for that behavior.Contribution Checklist