Skip to content

Commit d7ca9c8

Browse files
Python: Core: add experimental session-mode harness context provider (microsoft#5611)
* Python: Core: add experimental session-mode harness context provider Introduces the _harness namespace and the first context provider: SessionModeContextProvider, with get_session_mode / set_session_mode helpers and a DEFAULT_MODE_SOURCE_ID constant. Behind @experimental(ExperimentalFeature.HARNESS). Also folds in a small _sessions.py cleanup (try/except ImportError -> contextlib.suppress) touched while developing the harness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Core: align session-mode harness with .NET AgentModeProvider Mirror the default mode descriptions and instruction template used by the .NET AgentModeProvider so the cross-language harness UX is consistent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Core: address review feedback on session-mode harness - json.dumps tool outputs to stay valid for arbitrary mode names - normalize configured mode keys (lower+strip) so custom-cased configs work - raise TypeError instead of silently replacing non-dict session state - mark get_session_mode/set_session_mode as @experimental(HARNESS) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Core: rename SessionModeContextProvider to AgentModeProvider Match the .NET AgentModeProvider class name for cross-language consistency. Helpers renamed accordingly: get_session_mode -> get_agent_mode, set_session_mode -> set_agent_mode. The default source_id is now "agent_mode". Construction pattern stays Pythonic (kwargs, not an options object). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Core: address AgentModeProvider review feedback - default_mode now defaults to None and falls back to the first configured mode, decoupling the kwarg from the built-in 'plan'/'execute' set. - get_agent_mode catches ValueError when a previously persisted mode is no longer in available_modes and resets to the default mode (matching the non-string recovery branch). Added regression coverage for both behaviors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 57c901a commit d7ca9c8

4 files changed

Lines changed: 466 additions & 6 deletions

File tree

python/packages/core/agent_framework/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@
8787
MemoryStore,
8888
MemoryTopicRecord,
8989
)
90+
from ._harness._mode import (
91+
DEFAULT_MODE_SOURCE_ID,
92+
AgentModeProvider,
93+
get_agent_mode,
94+
set_agent_mode,
95+
)
9096
from ._harness._todo import (
9197
DEFAULT_TODO_SOURCE_ID,
9298
TodoFileStore,
@@ -279,6 +285,7 @@
279285
"COMPACTION_STATE_KEY",
280286
"DEFAULT_MAX_ITERATIONS",
281287
"DEFAULT_MEMORY_SOURCE_ID",
288+
"DEFAULT_MODE_SOURCE_ID",
282289
"DEFAULT_TODO_SOURCE_ID",
283290
"EXCLUDED_KEY",
284291
"EXCLUDE_REASON_KEY",
@@ -304,6 +311,7 @@
304311
"AgentMiddleware",
305312
"AgentMiddlewareLayer",
306313
"AgentMiddlewareTypes",
314+
"AgentModeProvider",
307315
"AgentResponse",
308316
"AgentResponseUpdate",
309317
"AgentRunInputs",
@@ -469,6 +477,7 @@
469477
"evaluator",
470478
"executor",
471479
"function_middleware",
480+
"get_agent_mode",
472481
"get_run_context",
473482
"handler",
474483
"included_messages",
@@ -485,6 +494,7 @@
485494
"register_state_type",
486495
"resolve_agent_id",
487496
"response_handler",
497+
"set_agent_mode",
488498
"step",
489499
"tool",
490500
"tool_call_args_match",
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from collections.abc import Mapping, Sequence
7+
from typing import Any, cast
8+
9+
from .._feature_stage import ExperimentalFeature, experimental
10+
from .._sessions import AgentSession, ContextProvider, SessionContext
11+
from .._tools import tool
12+
13+
DEFAULT_MODE_SOURCE_ID = "agent_mode"
14+
DEFAULT_MODE_INSTRUCTIONS = (
15+
"## Agent Mode\n\n"
16+
"You can operate in different modes. Depending on the mode you are in, "
17+
"you will be required to follow different processes.\n\n"
18+
"Use the get_mode tool to check your current operating mode.\n"
19+
"Use the set_mode tool to switch between modes as your work progresses. "
20+
"Only use set_mode if the user explicitly instructs/allows you to change modes.\n\n"
21+
"{available_modes}\n"
22+
"\n"
23+
"You are currently operating in the {current_mode} mode.\n"
24+
)
25+
DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
26+
"plan": (
27+
"Use this mode when analyzing requirements, breaking down tasks, and creating plans. "
28+
"This is the interactive mode — ask clarifying questions, discuss options, and get user approval before "
29+
"proceeding."
30+
),
31+
"execute": (
32+
"Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask "
33+
"the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, "
34+
"useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note "
35+
"your choice."
36+
),
37+
}
38+
39+
40+
def _get_mode_state(session: AgentSession, *, source_id: str) -> dict[str, Any]:
41+
"""Return the mutable session state used by the mode provider."""
42+
provider_state = session.state.get(source_id)
43+
if isinstance(provider_state, dict):
44+
return cast(dict[str, Any], provider_state)
45+
if provider_state is not None:
46+
raise TypeError(
47+
f"Session state for source_id {source_id!r} must be a dict, got {type(provider_state).__name__}."
48+
)
49+
state: dict[str, Any] = {}
50+
session.state[source_id] = state
51+
return state
52+
53+
54+
def _normalize_available_modes(available_modes: Sequence[str]) -> dict[str, str]:
55+
"""Return normalized mode names mapped to display names."""
56+
normalized_modes: dict[str, str] = {}
57+
for mode in available_modes:
58+
display_mode = mode.strip()
59+
normalized_mode = display_mode.lower()
60+
if normalized_mode in normalized_modes:
61+
raise ValueError(f"Duplicate mode configured: {mode}.")
62+
normalized_modes[normalized_mode] = display_mode
63+
return normalized_modes
64+
65+
66+
def _normalize_mode(mode: str, *, available_modes: Mapping[str, str]) -> str:
67+
"""Validate and normalize a mode string."""
68+
normalized = mode.strip().lower()
69+
if normalized not in available_modes:
70+
supported_modes = ", ".join(repr(item) for item in available_modes.values())
71+
raise ValueError(f"Invalid mode: {mode}. Supported modes are {supported_modes}.")
72+
return normalized
73+
74+
75+
def _resolve_default_mode(default_mode: str | None, *, available_modes: Mapping[str, str]) -> str:
76+
"""Resolve the default mode, falling back to the first configured mode when omitted."""
77+
if default_mode is None:
78+
return next(iter(available_modes))
79+
return _normalize_mode(default_mode, available_modes=available_modes)
80+
81+
82+
@experimental(feature_id=ExperimentalFeature.HARNESS)
83+
def get_agent_mode(
84+
session: AgentSession,
85+
*,
86+
source_id: str = DEFAULT_MODE_SOURCE_ID,
87+
default_mode: str | None = None,
88+
available_modes: Sequence[str] | None = None,
89+
) -> str:
90+
"""Get the current operating mode from session state.
91+
92+
Args:
93+
session: The agent session to read the mode from.
94+
95+
Keyword Args:
96+
source_id: Unique source ID for the provider state.
97+
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
98+
``available_modes`` is used.
99+
available_modes: Supported modes to validate against. Defaults to the built-in modes.
100+
101+
Returns:
102+
The current mode string.
103+
"""
104+
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
105+
normalized_default_mode = _resolve_default_mode(default_mode, available_modes=normalized_modes)
106+
provider_state = _get_mode_state(session, source_id=source_id)
107+
current_mode = provider_state.get("current_mode")
108+
if isinstance(current_mode, str):
109+
try:
110+
return _normalize_mode(current_mode, available_modes=normalized_modes)
111+
except ValueError:
112+
# Stored mode is no longer in the configured set (e.g. available_modes was reconfigured).
113+
# Fall through and reset to the default mode.
114+
pass
115+
provider_state["current_mode"] = normalized_default_mode
116+
return normalized_default_mode
117+
118+
119+
@experimental(feature_id=ExperimentalFeature.HARNESS)
120+
def set_agent_mode(
121+
session: AgentSession,
122+
mode: str,
123+
*,
124+
source_id: str = DEFAULT_MODE_SOURCE_ID,
125+
available_modes: Sequence[str] | None = None,
126+
) -> str:
127+
"""Set the current operating mode in session state.
128+
129+
Args:
130+
session: The agent session to update the mode in.
131+
mode: The new mode to set.
132+
133+
Keyword Args:
134+
source_id: Unique source ID for the provider state.
135+
available_modes: Supported modes to validate against. Defaults to the built-in modes.
136+
137+
Returns:
138+
The normalized mode string that was stored.
139+
140+
Raises:
141+
ValueError: The requested mode is not configured.
142+
"""
143+
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
144+
normalized_mode = _normalize_mode(mode, available_modes=normalized_modes)
145+
provider_state = _get_mode_state(session, source_id=source_id)
146+
provider_state["current_mode"] = normalized_mode
147+
return normalized_mode
148+
149+
150+
@experimental(feature_id=ExperimentalFeature.HARNESS)
151+
class AgentModeProvider(ContextProvider):
152+
"""Track the agent's operating mode in session state and provide mode tools.
153+
154+
The ``AgentModeProvider`` enables agents to operate in distinct modes during long-running complex tasks.
155+
The current mode is persisted in the ``AgentSession`` state and is included in the instructions provided to the
156+
agent on each invocation.
157+
158+
The set of available modes is configurable with ``mode_descriptions``. By default, two modes are provided:
159+
``"plan"`` (interactive planning) and ``"execute"`` (autonomous execution).
160+
161+
This provider exposes the following tools to the agent:
162+
- ``set_mode``: Switch the agent's operating mode.
163+
- ``get_mode``: Retrieve the agent's current operating mode.
164+
165+
Public helper functions ``get_agent_mode`` and ``set_agent_mode`` allow external code to programmatically read
166+
and change the mode.
167+
"""
168+
169+
def __init__(
170+
self,
171+
source_id: str = DEFAULT_MODE_SOURCE_ID,
172+
*,
173+
default_mode: str | None = None,
174+
mode_descriptions: Mapping[str, str] | None = None,
175+
instructions: str | None = None,
176+
) -> None:
177+
"""Initialize a new agent mode provider.
178+
179+
Args:
180+
source_id: Unique source ID for the provider.
181+
182+
Keyword Args:
183+
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
184+
``mode_descriptions`` is used.
185+
mode_descriptions: Mapping of supported modes to descriptions of when and how to use each mode.
186+
instructions: Custom instructions for using the mode tools. The instructions can contain an
187+
``{available_modes}`` placeholder for the configured list of modes and a ``{current_mode}`` placeholder
188+
for the currently active mode. When omitted, the provider uses a default set of instructions.
189+
190+
Raises:
191+
ValueError: No modes are configured, or the default mode is not configured.
192+
"""
193+
super().__init__(source_id)
194+
mode_descriptions = dict(DEFAULT_MODE_DESCRIPTIONS if mode_descriptions is None else mode_descriptions)
195+
self._mode_display_names = _normalize_available_modes(tuple(mode_descriptions))
196+
if not self._mode_display_names:
197+
raise ValueError("mode_descriptions must contain at least one mode.")
198+
self.mode_descriptions = {mode.strip().lower(): description for mode, description in mode_descriptions.items()}
199+
self.available_modes = tuple(self._mode_display_names)
200+
self.default_mode = _resolve_default_mode(default_mode, available_modes=self._mode_display_names)
201+
self.instructions = instructions
202+
203+
def _build_instructions(self, current_mode: str) -> str:
204+
"""Build the mode guidance injected for the current session."""
205+
mode_lines = "".join(
206+
f'- "{self._mode_display_names[mode]}": {description}\n'
207+
for mode, description in self.mode_descriptions.items()
208+
)
209+
instructions = self.instructions or DEFAULT_MODE_INSTRUCTIONS
210+
return instructions.replace("{available_modes}", mode_lines).replace("{current_mode}", current_mode)
211+
212+
async def before_run(
213+
self,
214+
*,
215+
agent: Any,
216+
session: AgentSession,
217+
context: SessionContext,
218+
state: dict[str, Any],
219+
) -> None:
220+
"""Inject mode tools and instructions before the model runs.
221+
222+
Args:
223+
agent: The agent being invoked.
224+
session: The agent session whose state stores the current mode.
225+
context: The session context to receive instructions and tools.
226+
state: Per-provider invocation state.
227+
"""
228+
del agent, state
229+
current_mode = get_agent_mode(
230+
session,
231+
source_id=self.source_id,
232+
default_mode=self.default_mode,
233+
available_modes=self.available_modes,
234+
)
235+
236+
@tool(name="set_mode", approval_mode="never_require")
237+
def set_mode(mode: str) -> str:
238+
"""Switch the agent's operating mode."""
239+
normalized_mode = set_agent_mode(
240+
session,
241+
mode,
242+
source_id=self.source_id,
243+
available_modes=self.available_modes,
244+
)
245+
return json.dumps({"mode": normalized_mode, "message": f"Mode changed to '{normalized_mode}'."})
246+
247+
@tool(name="get_mode", approval_mode="never_require")
248+
def get_mode() -> str:
249+
"""Get the agent's current operating mode."""
250+
current_mode_value = get_agent_mode(
251+
session,
252+
source_id=self.source_id,
253+
default_mode=self.default_mode,
254+
available_modes=self.available_modes,
255+
)
256+
return json.dumps({"mode": current_mode_value})
257+
258+
context.extend_instructions(
259+
self.source_id,
260+
[self._build_instructions(current_mode)],
261+
)
262+
context.extend_tools(self.source_id, [set_mode, get_mode])

python/packages/core/agent_framework/_sessions.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from abc import abstractmethod
2323
from base64 import urlsafe_b64encode
2424
from collections.abc import Awaitable, Callable, Mapping, Sequence
25+
from contextlib import suppress
2526
from pathlib import Path
2627
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast
2728

@@ -94,7 +95,7 @@ def _serialize_value(value: Any) -> Any:
9495
if hasattr(value, "to_dict") and callable(value.to_dict):
9596
return value.to_dict() # pyright: ignore[reportUnknownMemberType]
9697
# Pydantic BaseModel support — import lazily to avoid hard dep at module level
97-
try:
98+
with suppress(ImportError):
9899
from pydantic import BaseModel
99100

100101
if isinstance(value, BaseModel):
@@ -104,8 +105,6 @@ def _serialize_value(value: Any) -> Any:
104105
# Auto-register for round-trip deserialization
105106
_STATE_TYPE_REGISTRY.setdefault(type_id, value.__class__)
106107
return data
107-
except ImportError:
108-
pass
109108
if isinstance(value, list):
110109
return [_serialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
111110
if isinstance(value, dict):
@@ -122,14 +121,12 @@ def _deserialize_value(value: Any) -> Any:
122121
if hasattr(cls, "from_dict"):
123122
return cls.from_dict(value) # type: ignore[union-attr]
124123
# Pydantic BaseModel support
125-
try:
124+
with suppress(ImportError):
126125
from pydantic import BaseModel
127126

128127
if issubclass(cls, BaseModel):
129128
data: dict[str, Any] = {str(k): v for k, v in value.items() if k != "type"} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
130129
return cls.model_validate(data)
131-
except ImportError:
132-
pass
133130
if isinstance(value, list):
134131
return [_deserialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
135132
if isinstance(value, dict):

0 commit comments

Comments
 (0)