fix: context provider hardening and extension points (#9104)

## Summary

Batch of fixes and improvements targeting context providers, metadata
resolution, and upstream integrations.

### Key Changes

**Context Providers (fix-3, fix-6)**
- Injectable `write_tools` on all five context providers (Workspace,
Filesystem, GitHub, Linear, Slack)
- `query_timeout` enforces one wall-clock deadline per query tool call

**Workspace Security (fix-1, fix-2)**
- `enforce_excludes` guards all direct-path operations (read, write,
delete)
- Git backend keeps PAT off disk using bare origin + env credential
helper

**Metadata Resolution — Agent, Team, Workflow Consistency**
- All three now use consistent precedence: `component < session <
call-site` (later wins)
- Fixed `_resolve_run_params` loop order in Workflow (was backwards)
- Fixed `_update_metadata` merge direction in Workflow (session now wins
over workflow)
- Added missing metadata/dependencies support to
`_arun_background_stream`
- Agent/Team/Workflow instances are no longer mutated per run — deepcopy
isolation
- Added metadata resolution cookbooks for Agent and Workflow (Team
already had one)

**Slack Delivery (fix-4)**
- Unfurl/markdown options including pause sites
- Inert card args support

**Auth & Scheduling (fix-5, fix-7)**
- `AuthConfig(interactive=False)` raises instead of blocking on
`run_local_server`
- `get_schedules` gains `raise_on_error`;
`ScheduleManager.list_all/alist_all` added

**Team.arun**
- Session metadata read moved before `resolve_run_options` to honor
session-level overrides
- `initialize_team` moved to top in `arun_dispatch` to match sync
behavior

### Also Included
- Upstream fixes: learnings owner match, archive race test, AgentOSTools
schedule reporting, file-generation import warnings
- Updated callers of `_aload_or_create_session` for 3-tuple return
(job_queue.py, status_persist.py)
- Fixed test expectations to match correct precedence

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## Checklist

- [x] Formatted and validated
- [x] Tests pass (539 workflow tests)
- [x] Metadata resolution consistent across Agent, Team, Workflow

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Mustafa Esoofally <[email protected]>
This commit is contained in:
Ashpreet
2026-08-26 14:10:13 -04:00
committed by GitHub
co-authored by Claude Fable 5 Mustafa Esoofally
parent b99ba1b410
commit 8fbf53a291
68 changed files with 4020 additions and 213 deletions
@@ -0,0 +1,95 @@
"""
Metadata Resolution
=============================
Demonstrates the three-layer metadata resolution for Agents:
agent.metadata < session.metadata < call-site metadata
Session-stored metadata overrides agent defaults, and call-site
metadata overrides both. This works for both sync and async runs.
"""
import asyncio
import time
from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.models.openai import OpenAIResponses
from agno.session.agent import AgentSession
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
db = InMemoryDb()
# Pre-seed a session with metadata (simulates a returning user)
session = AgentSession(
session_id="demo-session",
agent_id="metadata-demo-agent",
metadata={
"user_tier": "premium", # Will override agent's "free"
"session_pref": "dark_mode", # Session-only key
},
created_at=int(time.time()),
)
db.upsert_session(session)
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
agent = Agent(
id="metadata-demo-agent",
model=OpenAIResponses(id="gpt-5-mini"),
db=db,
metadata={
"user_tier": "free", # Will be overridden by session
"agent_env": "production", # Agent-only key
},
)
# ---------------------------------------------------------------------------
# Run Agent
# ---------------------------------------------------------------------------
async def run_async_demo() -> None:
print("\n=== Async run with all three layers ===")
result = await agent.arun(
input="Say 'async test'",
session_id="demo-session",
metadata={"user_tier": "vip"},
)
print(f"Metadata: {result.metadata}")
print(f" user_tier = '{result.metadata['user_tier']}' (call-site wins)")
print(f" session_pref = '{result.metadata['session_pref']}' (from session)")
print(f" agent_env = '{result.metadata['agent_env']}' (from agent)")
if __name__ == "__main__":
# Session metadata overrides agent metadata
print("=== Session overrides agent (sync) ===")
result1 = agent.run(input="Say 'test1'", session_id="demo-session")
print(f"Metadata: {result1.metadata}")
print(f" user_tier = '{result1.metadata['user_tier']}' (session wins over agent)")
# Call-site metadata overrides both
print("\n=== Call-site overrides all (sync) ===")
result2 = agent.run(
input="Say 'test2'",
session_id="demo-session",
metadata={"user_tier": "enterprise", "request_id": "req-123"},
)
print(f"Metadata: {result2.metadata}")
print(f" user_tier = '{result2.metadata['user_tier']}' (call-site wins)")
# Async run
asyncio.run(run_async_demo())
# New session uses agent defaults
print("\n=== New session uses agent defaults ===")
result4 = agent.run(input="Say 'test4'", session_id="new-session")
print(f"Metadata: {result4.metadata}")
print(f" user_tier = '{result4.metadata['user_tier']}' (agent default)")
print("\n" + "=" * 50)
print("Resolution order: agent < session < call-site")
print("=" * 50)
@@ -0,0 +1,100 @@
"""
Metadata Resolution
=============================
Demonstrates the three-layer metadata resolution for Teams:
team.metadata < session.metadata < call-site metadata
Session-stored metadata overrides team defaults, and call-site
metadata overrides both. This works for both sync and async runs.
"""
import asyncio
import time
from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.models.openai import OpenAIResponses
from agno.session.team import TeamSession
from agno.team import Team
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
db = InMemoryDb()
# Pre-seed a session with metadata (simulates a returning user)
session = TeamSession(
session_id="demo-session",
team_id="metadata-demo-team",
metadata={
"user_tier": "premium", # Will override team's "free"
"session_pref": "dark_mode", # Session-only key
},
created_at=int(time.time()),
)
db.upsert_session(session)
# ---------------------------------------------------------------------------
# Create Members
# ---------------------------------------------------------------------------
helper_agent = Agent(name="helper", model=OpenAIResponses(id="gpt-5-mini"))
# ---------------------------------------------------------------------------
# Create Team
# ---------------------------------------------------------------------------
team = Team(
id="metadata-demo-team",
members=[helper_agent],
model=OpenAIResponses(id="gpt-5-mini"),
db=db,
metadata={
"user_tier": "free", # Will be overridden by session
"team_env": "production", # Team-only key
},
)
# ---------------------------------------------------------------------------
# Run Team
# ---------------------------------------------------------------------------
async def run_async_demo() -> None:
print("\n=== Async run with call-site override ===")
result = await team.arun(
input="Say 'async test'",
metadata={"user_tier": "vip"},
)
print(f"Metadata: {result.metadata}")
print(f" user_tier = '{result.metadata['user_tier']}' (call-site wins)")
print(f" team_env = '{result.metadata['team_env']}' (from team)")
if __name__ == "__main__":
# Session metadata overrides team metadata
print("=== Session overrides team (sync) ===")
result1 = team.run(input="Say 'test1'", session_id="demo-session")
print(f"Metadata: {result1.metadata}")
print(f" user_tier = '{result1.metadata['user_tier']}' (session wins over team)")
# Call-site metadata overrides both
print("\n=== Call-site overrides all (sync) ===")
result2 = team.run(
input="Say 'test2'",
session_id="demo-session",
metadata={"user_tier": "enterprise", "request_id": "req-123"},
)
print(f"Metadata: {result2.metadata}")
print(f" user_tier = '{result2.metadata['user_tier']}' (call-site wins)")
# Async run
asyncio.run(run_async_demo())
# New session uses team defaults
print("\n=== New session uses team defaults ===")
result4 = team.run(input="Say 'test4'", session_id="new-session")
print(f"Metadata: {result4.metadata}")
print(f" user_tier = '{result4.metadata['user_tier']}' (team default)")
print("\n" + "=" * 50)
print("Resolution order: team < session < call-site")
print("=" * 50)
@@ -0,0 +1,111 @@
"""
Metadata Resolution
=============================
Demonstrates the three-layer metadata resolution for Workflows:
workflow.metadata < session.metadata < call-site metadata
Session-stored metadata overrides workflow defaults, and call-site
metadata overrides both. This works for both sync and async runs.
"""
import asyncio
import time
from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.models.openai import OpenAIResponses
from agno.session.workflow import WorkflowSession
from agno.workflow.step import Step
from agno.workflow.workflow import Workflow
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
db = InMemoryDb()
# Pre-seed a session with metadata (simulates a returning user)
session = WorkflowSession(
session_id="demo-session",
workflow_id="metadata-demo-workflow",
metadata={
"user_tier": "premium", # Will override workflow's "free"
"session_pref": "dark_mode", # Session-only key
},
created_at=int(time.time()),
)
db.upsert_session(session)
# ---------------------------------------------------------------------------
# Create a simple agent and step
# ---------------------------------------------------------------------------
echo_agent = Agent(
name="Echo Agent",
model=OpenAIResponses(id="gpt-5-mini"),
instructions=["Echo back the input briefly."],
)
# ---------------------------------------------------------------------------
# Create Workflow
# ---------------------------------------------------------------------------
workflow = Workflow(
id="metadata-demo-workflow",
name="Metadata Demo Workflow",
steps=[Step(name="echo", agent=echo_agent)],
db=db,
metadata={
"user_tier": "free", # Will be overridden by session
"workflow_env": "production", # Workflow-only key
},
)
# ---------------------------------------------------------------------------
# Run Workflow
# ---------------------------------------------------------------------------
async def run_async_demo() -> None:
print("\n=== Async run with all three layers ===")
result = await workflow.arun(
input="async test",
session_id="demo-session",
metadata={"user_tier": "vip"},
)
print(f"Metadata: {result.metadata}")
print(f" user_tier = '{result.metadata['user_tier']}' (call-site wins)")
print(f" session_pref = '{result.metadata['session_pref']}' (from session)")
print(f" workflow_env = '{result.metadata['workflow_env']}' (from workflow)")
if __name__ == "__main__":
# Session metadata overrides workflow metadata
print("=== Session overrides workflow (sync) ===")
result1 = workflow.run(input="test1", session_id="demo-session")
print(f"Metadata: {result1.metadata}")
print(
f" user_tier = '{result1.metadata['user_tier']}' (session wins over workflow)"
)
# Call-site metadata overrides both
print("\n=== Call-site overrides all (sync) ===")
result2 = workflow.run(
input="test2",
session_id="demo-session",
metadata={"user_tier": "enterprise", "request_id": "req-123"},
)
print(f"Metadata: {result2.metadata}")
print(f" user_tier = '{result2.metadata['user_tier']}' (call-site wins)")
# Async run
asyncio.run(run_async_demo())
# New session uses workflow defaults
print("\n=== New session uses workflow defaults ===")
result4 = workflow.run(input="test4", session_id="new-session")
print(f"Metadata: {result4.metadata}")
print(f" user_tier = '{result4.metadata['user_tier']}' (workflow default)")
print("\n" + "=" * 50)
print("Resolution order: workflow < session < call-site")
print("=" * 50)
+4 -2
View File
@@ -7,8 +7,10 @@ lives in a real git repository. After the write sub-agent returns,
the backend stages, commits with an LLM-summarised one-line message,
rebases onto the remote, and pushes.
Auth is PAT-based (`x-access-token:<TOKEN>@github.com/...`). The token
is registered with a `Scrubber` at construction so it never reaches a
Auth is PAT-based. The token is injected into each remote git call
through a credential helper carried in the subprocess environment, so
it never appears on the command line or in `.git/config`. It is also
registered with a `Scrubber` at construction so it never reaches a
log line — including stderr from a failed git invocation.
This cookbook is env-gated. It runs only when both
@@ -0,0 +1,221 @@
"""
Restricting Write Operations with write_tools
==============================================
Context providers expose ``write_tools`` to customize or restrict what
write operations an agent can perform. This is useful for:
- Safety: Prevent agents from deleting data
- Compliance: Limit agents to draft-only (human reviews before sending)
- Scoping: Restrict agents to specific operations
This example demonstrates:
1. Gmail: Draft-only mode (no sending)
2. Calendar: Create-only mode (no updates or deletes)
3. Database: Insert-only mode (no updates or deletes)
The ``write_tools`` parameter accepts a list of tools that replace the
default write toolkit. Pass a pre-configured toolkit with specific
operations disabled, or pass completely custom tools.
Requires: OPENAI_API_KEY + provider-specific credentials (see individual
provider cookbooks: 18_gmail.py, 19_calendar.py, 04_database_read_write.py)
"""
from __future__ import annotations
import asyncio
from agno.agent import Agent
from agno.context.calendar import GoogleCalendarContextProvider
from agno.context.database import DatabaseContextProvider
from agno.context.gmail import GmailContextProvider
from agno.models.openai import OpenAIResponses
from agno.tools.google.calendar import GoogleCalendarTools
from agno.tools.google.gmail import GmailTools
from agno.tools.postgres import PostgresTools
# ---------------------------------------------------------------------------
# Example 1: Gmail - Draft Only (No Sending)
# ---------------------------------------------------------------------------
# Agents can create and manage drafts but cannot send emails.
# A human reviews drafts before manually sending.
async def demo_gmail_draft_only():
print("\n" + "=" * 60)
print("DEMO 1: Gmail Draft-Only Mode")
print("=" * 60)
draft_only_tools = GmailTools(
send_email=False,
send_email_reply=False,
create_draft_email=True,
get_draft=True,
list_drafts=True,
search_emails=True,
get_thread=True,
)
gmail = GmailContextProvider(
model=OpenAIResponses(id="gpt-5.4-mini"),
read=True,
write=True,
write_tools=[draft_only_tools],
)
agent = Agent(
model=OpenAIResponses(id="gpt-5.4"),
tools=gmail.get_tools(),
instructions=gmail.instructions(),
markdown=True,
)
print(f"\nProvider status: {gmail.status()}")
print("\n--- Agent can draft but NOT send ---\n")
await agent.aprint_response(
"Find recent emails about meetings. Draft a polite follow-up "
"asking for an update on any action items. Save as draft only.",
stream=True,
)
# ---------------------------------------------------------------------------
# Example 2: Calendar - Create Only (No Deletes)
# ---------------------------------------------------------------------------
# Agents can create events but cannot update or delete existing ones.
# Prevents accidental deletion of important meetings.
async def demo_calendar_create_only():
print("\n" + "=" * 60)
print("DEMO 2: Calendar Create-Only Mode")
print("=" * 60)
create_only_tools = GoogleCalendarTools(
create_event=True,
update_event=False,
delete_event=False,
search_events=True,
get_event=True,
list_calendars=True,
)
calendar = GoogleCalendarContextProvider(
model=OpenAIResponses(id="gpt-5.4-mini"),
read=True,
write=True,
write_tools=[create_only_tools],
)
agent = Agent(
model=OpenAIResponses(id="gpt-5.4"),
tools=calendar.get_tools(),
instructions=calendar.instructions(),
markdown=True,
)
print(f"\nProvider status: {calendar.status()}")
print("\n--- Agent can create but NOT delete events ---\n")
await agent.aprint_response(
"Check my calendar for next week. If there's no 1:1 meeting "
"scheduled, create a 30-minute placeholder on Tuesday at 2pm.",
stream=True,
)
# ---------------------------------------------------------------------------
# Example 3: Database - Insert Only (No Updates/Deletes)
# ---------------------------------------------------------------------------
# Agents can insert new records but cannot modify or delete existing ones.
# Useful for audit logs, append-only data patterns.
async def demo_database_insert_only():
print("\n" + "=" * 60)
print("DEMO 3: Database Insert-Only Mode")
print("=" * 60)
insert_only_tools = PostgresTools(
db_url="postgresql://user:pass@localhost:5432/mydb",
enable_run_query=True,
enable_insert_row=True,
enable_update_row=False,
enable_delete_row=False,
enable_list_tables=True,
enable_describe_table=True,
)
database = DatabaseContextProvider(
db_url="postgresql://user:pass@localhost:5432/mydb",
model=OpenAIResponses(id="gpt-5.4-mini"),
read=True,
write=True,
write_tools=[insert_only_tools],
)
agent = Agent(
model=OpenAIResponses(id="gpt-5.4"),
tools=database.get_tools(),
instructions=database.instructions(),
markdown=True,
)
print(f"\nProvider status: {database.status()}")
print("\n--- Agent can insert but NOT update/delete ---\n")
await agent.aprint_response(
"Add a new entry to the audit_log table recording that "
"the daily report was generated at the current timestamp.",
stream=True,
)
# ---------------------------------------------------------------------------
# Example 4: Using query_timeout for Safety
# ---------------------------------------------------------------------------
# Combine write_tools with query_timeout to add time bounds.
async def demo_with_timeout():
print("\n" + "=" * 60)
print("DEMO 4: Restricted Tools + Timeout")
print("=" * 60)
draft_only_tools = GmailTools(
send_email=False,
send_email_reply=False,
create_draft_email=True,
)
gmail = GmailContextProvider(
model=OpenAIResponses(id="gpt-5.4-mini"),
read=True,
write=True,
write_tools=[draft_only_tools],
query_timeout=30.0,
)
print("\n--- Draft-only + 30s timeout ---\n")
print("Gmail provider configured with:")
print(" - write_tools: draft-only (no send)")
print(" - query_timeout: 30s")
print(" - Tools available:", [t.name for t in gmail.get_tools()])
# ---------------------------------------------------------------------------
# Run Demos
# ---------------------------------------------------------------------------
async def main():
print("NOTE: These demos require provider credentials to be configured.")
print("See individual provider cookbooks for setup instructions.")
print("\nRunning demo_with_timeout (no credentials needed for setup check)...")
await demo_with_timeout()
if __name__ == "__main__":
asyncio.run(main())
+36 -7
View File
@@ -1362,11 +1362,16 @@ def run_dispatch(
files=file_artifacts,
)
# Read existing session and update metadata BEFORE resolving run options,
# so that session-stored metadata is visible to resolve_run_options.
# Read the existing session so session-stored metadata is visible to
# resolve_run_options via session_metadata.
from copy import deepcopy
from agno.agent._storage import read_or_create_session, update_metadata
agent_session = read_or_create_session(agent, session_id=session_id, user_id=user_id)
# Snapshot BEFORE update_metadata merges agent.metadata into the session dict,
# so the session layer keeps the session's own values (agent < session < call-site).
session_metadata = deepcopy(agent_session.metadata)
update_metadata(agent, session=agent_session)
# Resolve all run options centrally
@@ -1381,6 +1386,7 @@ def run_dispatch(
dependencies=dependencies,
knowledge_filters=knowledge_filters,
metadata=metadata,
session_metadata=session_metadata,
output_schema=output_schema,
)
@@ -2867,18 +2873,25 @@ def arun_dispatch( # type: ignore
files=file_artifacts,
)
# Read existing session and update metadata BEFORE resolving run options,
# so that session-stored metadata is visible to resolve_run_options.
# Read the existing session so session-stored metadata is visible to
# resolve_run_options via session_metadata.
# Note: arun_dispatch is NOT async, so we can only pre-read with a sync DB.
# For async DB, _arun/_arun_stream will handle the session read themselves.
# For async DB, _arun/_arun_stream read the session AFTER options are resolved,
# so session metadata does not reach this run's resolved options there.
from copy import deepcopy
from agno.agent._init import has_async_db
from agno.agent._storage import update_metadata
_pre_session: Optional[AgentSession] = None
_session_metadata: Optional[Dict[str, Any]] = None
if not has_async_db(agent):
from agno.agent._storage import read_or_create_session
_pre_session = read_or_create_session(agent, session_id=session_id, user_id=user_id)
# Snapshot BEFORE update_metadata merges agent.metadata into the session dict,
# so the session layer keeps the session's own values (agent < session < call-site).
_session_metadata = deepcopy(_pre_session.metadata)
update_metadata(agent, session=_pre_session)
# Resolve all run options centrally
@@ -2893,6 +2906,7 @@ def arun_dispatch( # type: ignore
dependencies=dependencies,
knowledge_filters=knowledge_filters,
metadata=metadata,
session_metadata=_session_metadata,
output_schema=output_schema,
)
@@ -3421,7 +3435,12 @@ def continue_run_dispatch(
agent.initialize_agent(debug_mode=debug_mode)
# Read existing session from storage
from copy import deepcopy
agent_session = read_or_create_session(agent, session_id=session_id, user_id=user_id)
# Snapshot BEFORE update_metadata merges agent.metadata into the session dict,
# so the session layer keeps the session's own values (agent < session < call-site).
session_metadata = deepcopy(agent_session.metadata)
update_metadata(agent, session=agent_session)
# Fall back to the owner the run paused with, so the resume retrieves under the same scope
@@ -3455,6 +3474,7 @@ def continue_run_dispatch(
dependencies=dependencies,
knowledge_filters=knowledge_filters,
metadata=metadata,
session_metadata=session_metadata,
)
# Initialize run context
@@ -4299,16 +4319,24 @@ def acontinue_run_dispatch( # type: ignore
# Initialize the Agent
agent.initialize_agent(debug_mode=debug_mode)
# Read existing session and update metadata BEFORE resolving run options,
# so that session-stored metadata is visible to resolve_run_options.
# Pre-read the session so session-stored metadata is visible to
# resolve_run_options via session_metadata. Only possible with a sync DB:
# with an async DB the session is read inside _arun AFTER options are
# resolved, so session metadata does not reach this run's resolved options.
from agno.agent._init import has_async_db
_session_state: Dict[str, Any] = {}
_pre_session: Optional[AgentSession] = None
_session_metadata: Optional[Dict[str, Any]] = None
if not has_async_db(agent):
from copy import deepcopy
from agno.agent._storage import load_session_state, read_or_create_session, update_metadata
_pre_session = read_or_create_session(agent, session_id=session_id, user_id=user_id)
# Snapshot BEFORE update_metadata merges agent.metadata into the session dict,
# so the session layer keeps the session's own values (agent < session < call-site).
_session_metadata = deepcopy(_pre_session.metadata)
update_metadata(agent, session=_pre_session)
_session_state = load_session_state(agent, session=_pre_session, session_state={})
@@ -4326,6 +4354,7 @@ def acontinue_run_dispatch( # type: ignore
dependencies=dependencies,
knowledge_filters=knowledge_filters,
metadata=metadata,
session_metadata=_session_metadata,
)
# Prepare arguments for the model
+18 -10
View File
@@ -20,8 +20,8 @@ class ResolvedRunOptions:
All values are fully resolved (call-site > agent default > fallback)
at construction time. Two fields merge instead of replacing: ``metadata``
(agent-level values win on conflicting keys) and ``dependencies``
(call-site values win on conflicting keys).
(agent < session < call-site, later layers win on conflicting keys) and
``dependencies`` (call-site values win on conflicting keys).
"""
stream: bool
@@ -88,12 +88,19 @@ def resolve_run_options(
dependencies: Optional[Dict[str, Any]] = None,
knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
metadata: Optional[Dict[str, Any]] = None,
session_metadata: Optional[Dict[str, Any]] = None,
output_schema: Optional[Union[Type[BaseModel], Dict[str, Any]]] = None,
) -> ResolvedRunOptions:
"""Resolve all run options from call-site values and agent defaults.
Reads from ``agent`` but does not mutate it.
``session_metadata`` is the session-stored metadata read by the dispatch
function; it sits between agent defaults and call-site values in the
metadata merge (agent < session < call-site).
"""
from copy import deepcopy
from agno.agent._utils import get_effective_filters
from agno.utils.merge_dict import merge_dictionaries
@@ -151,15 +158,16 @@ def resolve_run_options(
if agent.knowledge_filters or knowledge_filters:
resolved_filters = get_effective_filters(agent, knowledge_filters=knowledge_filters)
# metadata: merge call-site + agent.metadata (agent values take precedence)
# metadata: layered merge, call-site wins (agent < session < call-site),
# matching how dependencies resolve. Each layer is deep-copied before it is
# merged, so no nested dict in the result aliases a source dict
# (merge_dictionaries recurses in place).
resolved_metadata: Optional[Dict[str, Any]] = None
if metadata is not None and agent.metadata is not None:
resolved_metadata = metadata.copy()
merge_dictionaries(resolved_metadata, agent.metadata)
elif metadata is not None:
resolved_metadata = metadata.copy()
elif agent.metadata is not None:
resolved_metadata = agent.metadata.copy()
for layer in (agent.metadata, session_metadata, metadata):
if layer is not None:
if resolved_metadata is None:
resolved_metadata = {}
merge_dictionaries(resolved_metadata, deepcopy(layer))
# output_schema: call-site > agent.output_schema
resolved_output_schema = output_schema if output_schema is not None else agent.output_schema
+22 -15
View File
@@ -577,15 +577,20 @@ def load_session_state(agent: Agent, session: AgentSession, session_state: Dict[
def update_metadata(agent: Agent, session: AgentSession):
"""Update the extra_data in the session"""
# Read metadata from the database
if session.metadata is not None:
# If metadata is set in the agent, update the database metadata with the agent's metadata
if agent.metadata is not None:
# Updates agent's session metadata in place
merge_dictionaries(session.metadata, agent.metadata)
# Update the current metadata with the metadata from the database which is updated in place
agent.metadata = session.metadata
"""Merge the agent's metadata into the session's metadata.
Agent metadata provides defaults; the session's own stored values win on
conflict, matching resolve_run_options (agent < session), so a value set on
the session is not overwritten by an agent default and persists across runs.
Only the session is updated; the shared Agent instance is never mutated.
"""
if session.metadata is not None and agent.metadata is not None:
from copy import deepcopy
merged = deepcopy(agent.metadata)
merge_dictionaries(merged, session.metadata)
session.metadata.clear()
session.metadata.update(merged)
def get_session_metrics_internal(agent: Agent, session: AgentSession) -> SessionMetrics:
@@ -650,10 +655,10 @@ def read_or_create_session(
if agent_session is None:
# Creating new session if none found
log_debug(f"Creating new AgentSession: {session_id}")
from copy import deepcopy
session_data = {}
if agent.session_state is not None:
from copy import deepcopy
session_data["session_state"] = deepcopy(agent.session_state)
agent_session = AgentSession(
session_id=session_id,
@@ -661,7 +666,8 @@ def read_or_create_session(
user_id=user_id,
agent_data=get_agent_data(agent),
session_data=session_data,
metadata=agent.metadata,
# Copy so the session record never aliases the shared Agent's dict
metadata=deepcopy(agent.metadata),
created_at=int(time()),
)
if agent.introduction is not None:
@@ -721,10 +727,10 @@ async def aread_or_create_session(
if agent_session is None:
# Creating new session if none found
log_debug(f"Creating new AgentSession: {session_id}")
from copy import deepcopy
session_data = {}
if agent.session_state is not None:
from copy import deepcopy
session_data["session_state"] = deepcopy(agent.session_state)
agent_session = AgentSession(
session_id=session_id,
@@ -732,7 +738,8 @@ async def aread_or_create_session(
user_id=user_id,
agent_data=get_agent_data(agent),
session_data=session_data,
metadata=agent.metadata,
# Copy so the session record never aliases the shared Agent's dict
metadata=deepcopy(agent.metadata),
created_at=int(time()),
)
if agent.introduction is not None:
+22 -2
View File
@@ -21,6 +21,9 @@ tools; writes get CRUD plus lookup tools.
2. OAuth (interactive, for personal Calendar):
- Set ``GOOGLE_CLIENT_ID``, ``GOOGLE_CLIENT_SECRET``, ``GOOGLE_PROJECT_ID``
- Opens browser on first use, caches token to ``calendar_token.json``
- On headless servers pass ``auth=AuthConfig(interactive=False)`` (or set
``GOOGLE_OAUTH_NONINTERACTIVE=1``): expired credentials then raise a clear
error instead of blocking on a browser that never opens
"""
from __future__ import annotations
@@ -69,7 +72,12 @@ You manage Google Calendar — searching, reading, and modifying events.
class GoogleCalendarContextProvider(ContextProvider):
"""Google Calendar context for agents via service account or OAuth."""
"""Google Calendar context for agents via service account or OAuth.
``write_tools`` swaps the write sub-agent's toolset (default: a
write-scoped ``GoogleCalendarTools``). ``mode=ContextMode.tools`` is
a read-only surface and deliberately ignores ``write_tools``.
"""
def __init__(
self,
@@ -87,8 +95,10 @@ class GoogleCalendarContextProvider(ContextProvider):
name: str = "Calendar",
read_instructions: str | None = None,
write_instructions: str | None = None,
write_tools: list | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
read: bool = True,
write: bool = False,
stream_sub_agent_events: bool = True,
@@ -98,6 +108,7 @@ class GoogleCalendarContextProvider(ContextProvider):
name=name,
mode=mode,
model=model,
query_timeout=query_timeout,
read=read,
write=write,
stream_sub_agent_events=stream_sub_agent_events,
@@ -105,6 +116,14 @@ class GoogleCalendarContextProvider(ContextProvider):
# Store auth config for toolkit creation
self._auth = auth
self.write_tools = write_tools
if write_tools is not None and not write:
from agno.utils.log import log_warning
log_warning(
f"{type(self).__name__}: write_tools was provided but write=False, so the update tool is not "
"exposed and the injected toolset is never used. Pass write=True to enable it."
)
self._sa_path = service_account_path or getenv("GOOGLE_SERVICE_ACCOUNT_FILE")
self._credentials_path = credentials_path
@@ -192,12 +211,13 @@ class GoogleCalendarContextProvider(ContextProvider):
def _ensure_write_agent(self) -> Agent:
if self._write_agent is None:
tools = self.write_tools if self.write_tools is not None else [self._ensure_write_toolkit()]
self._write_agent = Agent(
id=f"{self.id}_write",
name=f"{self.name} (write)",
model=self.model,
instructions=self._write_instructions,
tools=[self._ensure_write_toolkit()],
tools=tools,
markdown=True,
)
return self._write_agent
+16 -2
View File
@@ -34,7 +34,12 @@ if TYPE_CHECKING:
class DatabaseContextProvider(ContextProvider):
"""Read + write access to a SQL schema via two tools."""
"""Read + write access to a SQL schema via two tools.
``write_tools`` swaps the write sub-agent's toolset (default:
``SQLTools`` bound to the writable engine). ``mode=ContextMode.tools``
is a read-only surface and deliberately ignores ``write_tools``.
"""
def __init__(
self,
@@ -46,8 +51,10 @@ class DatabaseContextProvider(ContextProvider):
schema: str | None = None,
read_instructions: str | None = None,
write_instructions: str | None = None,
write_tools: list | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
read: bool = True,
write: bool = True,
stream_sub_agent_events: bool = True,
@@ -57,6 +64,7 @@ class DatabaseContextProvider(ContextProvider):
name=name,
mode=mode,
model=model,
query_timeout=query_timeout,
read=read,
write=write,
stream_sub_agent_events=stream_sub_agent_events,
@@ -64,6 +72,7 @@ class DatabaseContextProvider(ContextProvider):
self.sql_engine = sql_engine
self.readonly_engine = readonly_engine
self.schema = schema
self.write_tools = write_tools
self.read_instructions_text = read_instructions if read_instructions is not None else DEFAULT_READ_INSTRUCTIONS
self.write_instructions_text = (
write_instructions if write_instructions is not None else DEFAULT_WRITE_INSTRUCTIONS
@@ -172,12 +181,17 @@ class DatabaseContextProvider(ContextProvider):
def _build_write_agent(self) -> Agent:
schema_label = self.schema or "database"
tools = (
self.write_tools
if self.write_tools is not None
else [SQLTools(db_engine=self.sql_engine, schema=self.schema)]
)
return Agent(
id=f"{self.id}-write",
name=f"{self.name} Write",
model=self.model,
instructions=self.write_instructions_text.replace("{schema}", schema_label),
tools=[SQLTools(db_engine=self.sql_engine, schema=self.schema)],
tools=tools,
markdown=True,
)
+9 -1
View File
@@ -35,10 +35,18 @@ class FilesystemContextProvider(ContextProvider):
instructions: str | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
exclude_patterns: list[str] | None = None,
stream_sub_agent_events: bool = True,
) -> None:
super().__init__(id=id, name=name, mode=mode, model=model, stream_sub_agent_events=stream_sub_agent_events)
super().__init__(
id=id,
name=name,
mode=mode,
model=model,
stream_sub_agent_events=stream_sub_agent_events,
query_timeout=query_timeout,
)
self.root = Path(root).expanduser().resolve()
self.instructions_text = instructions if instructions is not None else DEFAULT_FS_INSTRUCTIONS
self.exclude_patterns = exclude_patterns
+12 -1
View File
@@ -20,6 +20,9 @@ by default; uploads/downloads are disabled.
- Set ``GOOGLE_CLIENT_ID``, ``GOOGLE_CLIENT_SECRET``, ``GOOGLE_PROJECT_ID``
- Or pass ``credentials_path`` / ``token_path`` directly
- Opens browser on first use, caches token to ``gdrive_token.json``
- On headless servers set ``GOOGLE_OAUTH_NONINTERACTIVE=1``: expired
credentials then raise a clear error instead of blocking on a browser
that never opens
**Search scope (Shared Drive support):**
@@ -72,9 +75,17 @@ class GoogleDriveContextProvider(ContextProvider):
instructions: str | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
stream_sub_agent_events: bool = True,
) -> None:
super().__init__(id=id, name=name, mode=mode, model=model, stream_sub_agent_events=stream_sub_agent_events)
super().__init__(
id=id,
name=name,
mode=mode,
model=model,
stream_sub_agent_events=stream_sub_agent_events,
query_timeout=query_timeout,
)
# Store params — toolkit handles actual auth
self._sa_path = service_account_path or getenv("GOOGLE_SERVICE_ACCOUNT_FILE")
+22 -2
View File
@@ -21,6 +21,9 @@ message tools; writes get compose plus lookup tools.
2. OAuth (interactive, for personal Gmail):
- Set ``GOOGLE_CLIENT_ID``, ``GOOGLE_CLIENT_SECRET``, ``GOOGLE_PROJECT_ID``
- Opens browser on first use, caches token to ``gmail_token.json``
- On headless servers pass ``auth=AuthConfig(interactive=False)`` (or set
``GOOGLE_OAUTH_NONINTERACTIVE=1``): expired credentials then raise a clear
error instead of blocking on a browser that never opens
"""
from __future__ import annotations
@@ -77,7 +80,12 @@ You manage Gmail — searching, reading, and composing emails.
class GmailContextProvider(ContextProvider):
"""Gmail context for agents via service account or OAuth."""
"""Gmail context for agents via service account or OAuth.
``write_tools`` swaps the write sub-agent's toolset (default: a
write-scoped ``GmailTools``). ``mode=ContextMode.tools`` is a
read-only surface and deliberately ignores ``write_tools``.
"""
def __init__(
self,
@@ -94,8 +102,10 @@ class GmailContextProvider(ContextProvider):
name: str = "Gmail",
read_instructions: str | None = None,
write_instructions: str | None = None,
write_tools: list | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
read: bool = True,
write: bool = False,
stream_sub_agent_events: bool = True,
@@ -105,6 +115,7 @@ class GmailContextProvider(ContextProvider):
name=name,
mode=mode,
model=model,
query_timeout=query_timeout,
read=read,
write=write,
stream_sub_agent_events=stream_sub_agent_events,
@@ -112,6 +123,14 @@ class GmailContextProvider(ContextProvider):
# Store auth config for toolkit creation
self._auth = auth
self.write_tools = write_tools
if write_tools is not None and not write:
from agno.utils.log import log_warning
log_warning(
f"{type(self).__name__}: write_tools was provided but write=False, so the update tool is not "
"exposed and the injected toolset is never used. Pass write=True to enable it."
)
# Resolve auth at init — fail fast if misconfigured
self._sa_path = service_account_path or getenv("GOOGLE_SERVICE_ACCOUNT_FILE")
@@ -207,12 +226,13 @@ class GmailContextProvider(ContextProvider):
def _ensure_write_agent(self) -> Agent:
if self._write_agent is None:
tools = self.write_tools if self.write_tools is not None else [self._ensure_write_toolkit()]
self._write_agent = Agent(
id=f"{self.id}_write",
name=f"{self.name} (write)",
model=self.model,
instructions=self._write_instructions,
tools=[self._ensure_write_toolkit()],
tools=tools,
markdown=True,
)
return self._write_agent
+7 -3
View File
@@ -69,6 +69,7 @@ class MCPContextProvider(ContextProvider):
base_instructions: str | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
stream_sub_agent_events: bool = True,
) -> None:
resolved_id = id or f"mcp_{_sanitize_id(server_name)}"
@@ -77,6 +78,7 @@ class MCPContextProvider(ContextProvider):
name=name or server_name,
mode=mode,
model=model,
query_timeout=query_timeout,
stream_sub_agent_events=stream_sub_agent_events,
)
self.server_name = server_name
@@ -258,9 +260,11 @@ class MCPContextProvider(ContextProvider):
self._tools = self._build_tools_instance()
try:
await self._tools._connect()
except Exception:
# Reset so the next attempt gets a fresh toolkit — a failed
# connect can leave MCPTools in a partially-initialized state.
except BaseException:
# Reset so the next attempt gets a fresh toolkit — a failed OR
# cancelled connect (e.g. a query_timeout deadline firing mid-
# handshake) can leave MCPTools in a partially-initialized state.
# Catch BaseException so CancelledError triggers the reset too.
self._tools = None
self._tool_descriptions = []
raise
+94 -25
View File
@@ -24,6 +24,9 @@ providers inherit a clean failure that `_update_tool()` surfaces as "<name> is r
`model` swaps the model used by the internal sub-agent. For full
customization, subclass and override `_build_agent()`.
`query_timeout` puts one wall-clock deadline on each `query_<id>` tool
call; see the `ContextProvider` docstring for its exact scope.
`instructions()` returns mode-aware usage guidance. The wiring layer
chooses how to surface it: inline in the system prompt, or via an
on-demand `learn_context(id)` meta-tool.
@@ -31,8 +34,10 @@ on-demand `learn_context(id)` meta-tool.
from __future__ import annotations
import asyncio
import json
import re
import sys
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING
@@ -75,7 +80,17 @@ class Answer:
class ContextProvider(ABC):
"""Base class for every context provider."""
"""Base class for every context provider.
``query_timeout`` puts one wall-clock deadline (in seconds) on every
``query_<id>`` tool call, covering sub-agent acquisition and answer
streaming; on expiry the tool yields an ``{"error": ...}`` chunk
instead of hanging the calling agent's run. It bounds the tool
surface only: programmatic ``query()`` / ``aquery()`` calls and the
raw backend tools exposed by ``mode=ContextMode.tools`` stay
unbounded. The write surface (``update_<id>``) is never bounded.
Requires Python 3.11+ and a positive value when set.
"""
def __init__(
self,
@@ -84,6 +99,7 @@ class ContextProvider(ABC):
name: str | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
read: bool = True,
write: bool = True,
query_tool_name: str | None = None,
@@ -95,10 +111,20 @@ class ContextProvider(ABC):
f"{type(self).__name__}: at least one of `read` or `write` must be True "
"(a provider that exposes neither tool is meaningless)"
)
if query_timeout is not None:
if sys.version_info < (3, 11):
raise RuntimeError(
f"{type(self).__name__}: query_timeout requires Python 3.11+ (uses asyncio.timeout_at)"
)
if query_timeout <= 0:
raise ValueError(f"{type(self).__name__}: query_timeout must be positive (got {query_timeout})")
self.id = id
self.name = name or id
self.mode = mode
self.model = model
# Wall-clock budget (seconds) for each query-tool call; see the
# class docstring for scope. None = unbounded.
self.query_timeout = query_timeout
# Per-direction toggles for the default surface. `read=False`
# drops `query_<id>`; `write=False` drops `update_<id>`. Lets
# callers expose an asymmetric surface (e.g. read-only voice
@@ -276,33 +302,76 @@ class ContextProvider(ABC):
@tool(name=self.query_tool_name)
async def _query(question: str, run_context: RunContext | None = None):
try:
agent = await provider._aget_query_agent(run_context)
except Exception as exc:
yield json.dumps({"error": f"{type(exc).__name__}: {exc}"})
return
if agent is None:
try:
answer = await provider.aquery(question, run_context=run_context)
except Exception as exc:
yield json.dumps({"error": f"{type(exc).__name__}: {exc}"})
return
yield json.dumps(serialize_answer(answer))
return
try:
if provider.stream_sub_agent_events:
async for chunk in provider._arun_sub_agent_stream(agent, question, run_context):
yield chunk
else:
answer = await provider._arun_sub_agent(agent, question, run_context)
yield json.dumps(serialize_answer(answer))
except Exception as exc:
yield json.dumps({"error": f"{type(exc).__name__}: {exc}"})
chunks = provider._query_chunks(question, run_context)
if provider.query_timeout is not None:
chunks = provider._bounded_stream(chunks, provider.query_timeout)
async for chunk in chunks:
yield chunk
return _query
async def _query_chunks(self, question: str, run_context: RunContext | None):
"""The query pipeline: agent acquisition, aquery fallback, streaming."""
try:
agent = await self._aget_query_agent(run_context)
except Exception as exc:
yield json.dumps({"error": f"{type(exc).__name__}: {exc}"})
return
if agent is None:
try:
answer = await self.aquery(question, run_context=run_context)
except Exception as exc:
yield json.dumps({"error": f"{type(exc).__name__}: {exc}"})
return
yield json.dumps(serialize_answer(answer))
return
try:
if self.stream_sub_agent_events:
async for chunk in self._arun_sub_agent_stream(agent, question, run_context):
yield chunk
else:
answer = await self._arun_sub_agent(agent, question, run_context)
yield json.dumps(serialize_answer(answer))
except Exception as exc:
yield json.dumps({"error": f"{type(exc).__name__}: {exc}"})
async def _bounded_stream(self, stream, timeout: float):
"""Yield ``stream``'s chunks under one wall-clock deadline.
The deadline is computed once at entry, so it covers agent
acquisition, a hanging ``aquery``, inter-chunk stalls, and
steady streams whose total time exceeds the budget alike. Each
``anext`` runs under its own ``asyncio.timeout_at(deadline)``
scope — a single scope held across a ``yield`` would cancel the
consuming task while this generator is suspended. On expiry the
underlying generator's cleanup is itself bounded so a hanging
``aclose`` cannot starve the timed-out error chunk.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while True:
scope = asyncio.timeout_at(deadline) # type: ignore[attr-defined]
try:
async with scope:
# The anext() builtin is 3.10+; the repo lints at py39.
chunk = await stream.__anext__()
except StopAsyncIteration:
return
except TimeoutError:
if not scope.expired():
# Raised by the provider itself, not our deadline.
raise
try:
async with asyncio.timeout(1): # type: ignore[attr-defined]
await stream.aclose()
except Exception:
pass
yield json.dumps({"error": f"{self.name} timed out after {timeout}s"})
return
yield chunk
def _update_tool(self):
provider = self
+16 -2
View File
@@ -39,7 +39,14 @@ if TYPE_CHECKING:
class SlackContextProvider(ContextProvider):
"""Read + write access to a Slack workspace via two tools."""
"""Read + write access to a Slack workspace via two tools.
``write_tools`` swaps the write sub-agent's toolset (default: a
posting-scoped ``SlackTools``). The default ``write_instructions``
name the default tools, so a custom ``write_tools`` usually needs a
matching ``write_instructions`` override. ``mode=ContextMode.tools``
is a read-only surface and deliberately ignores ``write_tools``.
"""
def __init__(
self,
@@ -50,9 +57,11 @@ class SlackContextProvider(ContextProvider):
name: str = "Slack",
read_instructions: str | None = None,
write_instructions: str | None = None,
write_tools: list | None = None,
enable_media_tools: bool = False,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
read: bool = True,
write: bool = True,
stream_sub_agent_events: bool = True,
@@ -62,6 +71,7 @@ class SlackContextProvider(ContextProvider):
name=name,
mode=mode,
model=model,
query_timeout=query_timeout,
read=read,
write=write,
stream_sub_agent_events=stream_sub_agent_events,
@@ -81,6 +91,9 @@ class SlackContextProvider(ContextProvider):
self.write_instructions_text = (
write_instructions if write_instructions is not None else DEFAULT_SLACK_WRITE_INSTRUCTIONS
)
# Injected write toolset (distinct from _write_tools, the lazy
# SlackTools cache the default path builds).
self.write_tools = write_tools
self.enable_media_tools = enable_media_tools
self._bot_read_tools: SlackTools | None = None
self._assisted_read_tools: SlackTools | None = None
@@ -282,12 +295,13 @@ class SlackContextProvider(ContextProvider):
def _ensure_write_agent(self) -> Agent:
if self._write_agent is None:
tools = self.write_tools if self.write_tools is not None else [self._ensure_write_tools()]
self._write_agent = Agent(
id=f"{self.id}-write",
name=f"{self.name} Write",
model=self.model,
instructions=self.write_instructions_text,
tools=[self._ensure_write_tools()],
tools=tools,
markdown=True,
)
return self._write_agent
+9 -1
View File
@@ -34,9 +34,17 @@ class WebContextProvider(ContextProvider):
instructions: str | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
stream_sub_agent_events: bool = True,
) -> None:
super().__init__(id=id, name=name, mode=mode, model=model, stream_sub_agent_events=stream_sub_agent_events)
super().__init__(
id=id,
name=name,
mode=mode,
model=model,
stream_sub_agent_events=stream_sub_agent_events,
query_timeout=query_timeout,
)
self.backend = backend
self.instructions_text = instructions if instructions is not None else DEFAULT_WEB_INSTRUCTIONS
self._agent: Agent | None = None
+100 -14
View File
@@ -205,6 +205,22 @@ class FileSystemBackend(WikiBackend):
return Status(ok=True, detail=str(self.path))
# Private env var carrying the PAT into git subprocesses. Consumed only
# by _GIT_CREDENTIAL_HELPER below; git itself never reads it.
_GIT_TOKEN_ENV_VAR = "AGNO_GIT_TOKEN"
# Inline credential helper: answers the `get` action with the
# x-access-token identity, reading the PAT from the environment so it
# stays off argv and out of every config file. A helper MUST emit both
# username and password — the bare remote URL carries no username, and
# GIT_TERMINAL_PROMPT=0 turns a missing field into an immediate failure.
# Runs under `sh` (present on the Git for Windows stack too); exits 0
# for the store/erase actions.
_GIT_CREDENTIAL_HELPER = (
'!f() { if [ "$1" = get ]; then printf \'username=x-access-token\\npassword=%s\\n\' "$AGNO_GIT_TOKEN"; fi; }; f'
)
class GitBackend(WikiBackend):
"""Wiki backend backed by a git remote.
@@ -214,11 +230,25 @@ class GitBackend(WikiBackend):
one-liner, rebases onto the remote, and pushes.
PAT auth is the only auth supported today: pass ``github_token`` (or
let the caller pull it from the environment). The token is
embedded in ``self._authenticated_url`` once at construction; never
log it directly. The backend's ``Scrubber`` is wired into every
let the caller pull it from the environment). The token is injected
into each remote-facing git call through an ephemeral credential
helper carried in the subprocess environment (``_git_env``): it is
never placed on the command line and never written to
``.git/config`` — ``origin`` stays the bare ``repo_url``, and
``setup`` rewrites token-bearing remotes left behind by older
versions. The backend's ``Scrubber`` is wired into every
``git_ops.run`` call so token leakage from git's own stderr is
blocked at the source.
Requires git >= 2.31: credential injection rides on the
``GIT_CONFIG_COUNT``/``GIT_CONFIG_KEY_n``/``GIT_CONFIG_VALUE_n``
environment entries, which older git ignores — remote operations
then fail with ``GitError`` ("could not read Username ... terminal
prompts disabled").
``file://`` remotes are accepted for local mirrors and tests. Git
does not consult credential helpers for local transports, so the
token is unused there.
"""
def __init__(
@@ -243,10 +273,17 @@ class GitBackend(WikiBackend):
self.author_name: str = author_name
self.author_email: str = author_email
self._authenticated_url: str = build_authenticated_url(repo_url, github_token)
self._scrubber: Scrubber = Scrubber()
self._scrubber.add(github_token)
self._scrubber.add(self._authenticated_url)
# The x-access-token URL form exists only for scrubbing: git can
# echo a credential-bearing URL in stderr, and clones made by
# older versions may still carry one in `.git/config`. Auth
# itself is env-injected per call (see _git_env). file://
# remotes (local mirrors, tests) have no https form — skip.
self._authenticated_url: str | None = None
if not repo_url.startswith("file://"):
self._authenticated_url = build_authenticated_url(repo_url, github_token)
self._scrubber.add(self._authenticated_url)
resolved = Path(local_path).expanduser().resolve() if local_path else _default_clone_path(repo_url)
super().__init__(path=resolved)
@@ -293,6 +330,7 @@ class GitBackend(WikiBackend):
["pull", "--rebase", "origin", self.branch],
cwd=self.path,
scrubber=self._scrubber,
env=self._git_env(),
)
async def commit_after_write(self, *, model=None) -> CommitSummary | None: # noqa: ANN001
@@ -317,6 +355,7 @@ class GitBackend(WikiBackend):
["push", "origin", self.branch],
cwd=self.path,
scrubber=self._scrubber,
env=self._git_env(),
)
except GitError as exc:
log_debug(f"GitBackend: idle push skipped: {exc}")
@@ -350,6 +389,7 @@ class GitBackend(WikiBackend):
["pull", "--rebase", "origin", self.branch],
cwd=self.path,
scrubber=self._scrubber,
env=self._git_env(),
)
except GitError as exc:
log_error(f"GitBackend rebase failed: {exc.stderr}")
@@ -368,6 +408,7 @@ class GitBackend(WikiBackend):
["push", "origin", self.branch],
cwd=self.path,
scrubber=self._scrubber,
env=self._git_env(),
)
return CommitSummary(sha=sha, message=message, files_changed=files_changed)
@@ -389,26 +430,67 @@ class GitBackend(WikiBackend):
# Internals
# -----------------------------------------------------------------
def _git_env(self) -> dict[str, str]:
"""Environment for remote-facing git calls: ephemeral credential injection.
``GIT_CONFIG_{COUNT,KEY_n,VALUE_n}`` feed git one-shot config
entries that exist only for that subprocess:
- entry 0 resets ``credential.helper`` so system/global helpers
(e.g. osxkeychain) cannot answer with a different identity;
- entry 1 installs the inline helper that emits the
``x-access-token`` username plus the PAT from the private env var;
- entry 2 sets ``credential.useHttpPath`` so lookups are scoped
to the full repo path;
- entries 3-4 pin ``repo_url`` to itself via an identity
``url.<repo_url>.insteadOf`` / ``pushInsteadOf``. Git applies the
longest-matching rewrite, so this full-URL identity rule outranks
any caller ``url.<prefix>.insteadOf`` (e.g. the common
``url."[email protected]:".insteadOf = https://github.com/``) whose
prefix would otherwise reroute the operation to a different
transport/identity and skip the injected credential helper.
Masks any caller-set ``GIT_CONFIG_*`` entries for these calls.
"""
env = os.environ.copy()
env["GIT_CONFIG_COUNT"] = "5"
env["GIT_CONFIG_KEY_0"] = "credential.helper"
env["GIT_CONFIG_VALUE_0"] = ""
env["GIT_CONFIG_KEY_1"] = "credential.helper"
env["GIT_CONFIG_VALUE_1"] = _GIT_CREDENTIAL_HELPER
env["GIT_CONFIG_KEY_2"] = "credential.useHttpPath"
env["GIT_CONFIG_VALUE_2"] = "true"
env["GIT_CONFIG_KEY_3"] = f"url.{self.repo_url}.insteadOf"
env["GIT_CONFIG_VALUE_3"] = self.repo_url
env["GIT_CONFIG_KEY_4"] = f"url.{self.repo_url}.pushInsteadOf"
env["GIT_CONFIG_VALUE_4"] = self.repo_url
env[_GIT_TOKEN_ENV_VAR] = self.github_token
env["GIT_TERMINAL_PROMPT"] = "0"
return env
async def _clone(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
# Clone the bare URL; the PAT rides in through the ephemeral
# credential helper in the environment, so it never appears on
# argv (ps-visible) and never lands in `.git/config`.
await git_run(
[
"clone",
"--branch",
self.branch,
"--single-branch",
self._authenticated_url,
self.repo_url,
str(self.path),
],
cwd=self.path.parent,
scrubber=self._scrubber,
env=self._git_env(),
)
# `git clone` with an authenticated URL persists the credential
# in `.git/config`. Rewrite the remote to the bare URL so the
# token isn't sitting on disk; we re-inject it on each
# push/pull via the `origin` URL we set below.
# Normalise origin to the bare URL in case pre-existing state
# (template checkout, interrupted earlier run) left a
# credential-bearing remote behind.
await git_run(
["remote", "set-url", "origin", self._authenticated_url],
["remote", "set-url", "origin", self.repo_url],
cwd=self.path,
scrubber=self._scrubber,
)
@@ -419,9 +501,12 @@ class GitBackend(WikiBackend):
Returns True if the path was wiped (so the caller should re-clone),
False if the clone passed validation and was kept in place.
"""
# Read the raw configured URL, not `remote get-url` (which applies any
# caller url.<base>.insteadOf rewrite): validation must compare what is
# actually stored in .git/config against repo_url.
existing_remote = (
await git_run(
["remote", "get-url", "origin"],
["config", "--get", "remote.origin.url"],
cwd=self.path,
scrubber=self._scrubber,
check=False,
@@ -455,9 +540,10 @@ class GitBackend(WikiBackend):
await self._wipe_path()
return True
# Refresh credentials in case the PAT was rotated.
# Normalise origin to the bare URL. Migrates clones created by
# older versions that persisted the token-bearing URL on disk.
await git_run(
["remote", "set-url", "origin", self._authenticated_url],
["remote", "set-url", "origin", self.repo_url],
cwd=self.path,
scrubber=self._scrubber,
)
+17 -2
View File
@@ -39,7 +39,16 @@ if TYPE_CHECKING:
class WikiContextProvider(ContextProvider):
"""Read + write access to a directory of markdown files via two tools."""
"""Read + write access to a directory of markdown files via two tools.
``write_tools`` swaps the write sub-agent's workspace toolset
(default: a read + write ``Workspace`` over the backend path); the
``web`` backend's tools are still appended when wired. The default
``write_instructions`` name the default workspace tools, so a custom
``write_tools`` usually needs a matching ``write_instructions``
override. ``mode=ContextMode.tools`` is a read-only surface and
deliberately ignores ``write_tools``.
"""
def __init__(
self,
@@ -49,8 +58,10 @@ class WikiContextProvider(ContextProvider):
name: str | None = None,
read_instructions: str | None = None,
write_instructions: str | None = None,
write_tools: list | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
read: bool = True,
write: bool = True,
web: ContextBackend | None = None,
@@ -61,6 +72,7 @@ class WikiContextProvider(ContextProvider):
name=name,
mode=mode,
model=model,
query_timeout=query_timeout,
read=read,
write=write,
stream_sub_agent_events=stream_sub_agent_events,
@@ -74,6 +86,7 @@ class WikiContextProvider(ContextProvider):
# the wiki on purpose: a "what does the wiki say" query
# should answer from the wiki, not silently consult the web.
self.web: ContextBackend | None = web
self.write_tools = write_tools
self.read_instructions_text = (
read_instructions if read_instructions is not None else DEFAULT_WIKI_READ_INSTRUCTIONS
)
@@ -278,7 +291,9 @@ class WikiContextProvider(ContextProvider):
def _ensure_write_agent(self) -> Agent:
if self._write_agent is None:
tools: list = [self._build_write_tools()]
# Copy the injected list so appending web tools below can't
# mutate the caller's list.
tools: list = list(self.write_tools) if self.write_tools is not None else [self._build_write_tools()]
if self.web is not None:
# Append the web backend's tools so the same sub-agent
# can fetch a URL or run a web search before writing.
+9 -1
View File
@@ -37,13 +37,21 @@ class WorkspaceContextProvider(ContextProvider):
instructions: str | None = None,
mode: ContextMode = ContextMode.default,
model: Model | None = None,
query_timeout: float | None = None,
exclude_patterns: list[str] | None = None,
allow_paths: list[str] | None = None,
max_file_lines: int = 100_000,
max_file_length: int = 10_000_000,
stream_sub_agent_events: bool = True,
) -> None:
super().__init__(id=id, name=name, mode=mode, model=model, stream_sub_agent_events=stream_sub_agent_events)
super().__init__(
id=id,
name=name,
mode=mode,
model=model,
stream_sub_agent_events=stream_sub_agent_events,
query_timeout=query_timeout,
)
self.root = Path(root).expanduser().resolve() if root is not None else Path.cwd().resolve()
self.instructions_text = instructions if instructions is not None else DEFAULT_WORKSPACE_INSTRUCTIONS
self.exclude_patterns = _validate_exclude_patterns(exclude_patterns)
+18
View File
@@ -1781,9 +1781,18 @@ class BaseDb(ABC):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
"""List schedules with optional filtering.
Args:
enabled: Optional filter on the enabled flag.
limit: Page size.
page: 1-indexed page number.
raise_on_error: When True, re-raise DB failures and raise when the
schedules table is unavailable (database error or table never
created), instead of returning ([], 0).
Returns:
Tuple of (schedules, total_count)
"""
@@ -3160,9 +3169,18 @@ class AsyncBaseDb(ABC):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
"""List schedules with optional filtering.
Args:
enabled: Optional filter on the enabled flag.
limit: Page size.
page: 1-indexed page number.
raise_on_error: When True, re-raise DB failures and raise when the
schedules table is unavailable (database error or table never
created), instead of returning ([], 0).
Returns:
Tuple of (schedules, total_count)
"""
+8 -1
View File
@@ -3132,10 +3132,13 @@ class AsyncMongoDb(AsyncBaseDb):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
try:
collection = await self._get_collection(table_type="schedules")
if collection is None:
if raise_on_error:
raise RuntimeError("schedules table unavailable (database error or table never created)")
return [], 0
query: Dict[str, Any] = {}
@@ -3146,13 +3149,17 @@ class AsyncMongoDb(AsyncBaseDb):
total_count = await collection.count_documents(query)
offset = (page - 1) * limit
cursor = collection.find(query).sort([("created_at", -1)]).skip(offset).limit(limit)
# id is a unique tiebreaker so skip/limit pages do not overlap or skip
# rows when many schedules share a created_at second.
cursor = collection.find(query).sort([("created_at", -1), ("id", -1)]).skip(offset).limit(limit)
schedules = await cursor.to_list(length=None)
for schedule in schedules:
schedule.pop("_id", None)
return schedules, total_count
except Exception as e:
log_debug(f"Error listing schedules: {e}")
if raise_on_error:
raise
return [], 0
async def create_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
+8 -1
View File
@@ -2996,10 +2996,13 @@ class MongoDb(BaseDb):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
try:
collection = self._get_collection(table_type="schedules")
if collection is None:
if raise_on_error:
raise RuntimeError("schedules table unavailable (database error or table never created)")
return [], 0
query: Dict[str, Any] = {}
@@ -3011,13 +3014,17 @@ class MongoDb(BaseDb):
total_count = collection.count_documents(query)
offset = (page - 1) * limit
cursor = collection.find(query).sort([("created_at", -1)]).skip(offset).limit(limit)
# id is a unique tiebreaker so skip/limit pages do not overlap or skip
# rows when many schedules share a created_at second.
cursor = collection.find(query).sort([("created_at", -1), ("id", -1)]).skip(offset).limit(limit)
schedules = list(cursor)
for schedule in schedules:
schedule.pop("_id", None)
return schedules, total_count
except Exception as e:
log_debug(f"Error listing schedules: {e}")
if raise_on_error:
raise
return [], 0
def create_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
+10 -2
View File
@@ -4212,10 +4212,15 @@ class AsyncPostgresDb(AsyncBaseDb):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
try:
table = await self._get_table(table_type="schedules")
if table is None:
# _get_table also returns None on connection errors (ais_table_available
# swallows them), so strict callers must not see this as an empty catalog
if raise_on_error:
raise RuntimeError("schedules table unavailable (database error or table never created)")
return [], 0
async with self.async_session_factory() as sess:
# Build base query with filters
@@ -4233,12 +4238,15 @@ class AsyncPostgresDb(AsyncBaseDb):
# Calculate offset from page
offset = (page - 1) * limit
# Get paginated results
stmt = base_query.order_by(table.c.created_at.desc()).limit(limit).offset(offset)
# Get paginated results (id is a unique tiebreaker so offset pages do not overlap
# or skip rows when many schedules share a created_at second)
stmt = base_query.order_by(table.c.created_at.desc(), table.c.id.desc()).limit(limit).offset(offset)
result = await sess.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()], total_count
except Exception as e:
log_debug(f"Error listing schedules: {e}")
if raise_on_error:
raise
return [], 0
async def create_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
+10 -2
View File
@@ -6879,10 +6879,15 @@ class PostgresDb(BaseDb):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
try:
table = self._get_table(table_type="schedules")
if table is None:
# _get_table also returns None on connection errors (is_table_available
# swallows them), so strict callers must not see this as an empty catalog
if raise_on_error:
raise RuntimeError("schedules table unavailable (database error or table never created)")
return [], 0
with self.Session() as sess:
# Build base query with filters
@@ -6899,12 +6904,15 @@ class PostgresDb(BaseDb):
# Calculate offset from page
offset = (page - 1) * limit
# Get paginated results
stmt = base_query.order_by(table.c.created_at.desc()).limit(limit).offset(offset)
# Get paginated results (id is a unique tiebreaker so offset pages do not overlap
# or skip rows when many schedules share a created_at second)
stmt = base_query.order_by(table.c.created_at.desc(), table.c.id.desc()).limit(limit).offset(offset)
results = sess.execute(stmt).fetchall()
return [dict(row._mapping) for row in results], total_count
except Exception as e:
log_debug(f"Error listing schedules: {e}")
if raise_on_error:
raise
return [], 0
def create_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
+10 -2
View File
@@ -4360,10 +4360,15 @@ class AsyncSqliteDb(AsyncBaseDb):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
try:
table = await self._get_table(table_type="schedules")
if table is None:
# _get_table also returns None on connection errors (ais_table_available
# swallows them), so strict callers must not see this as an empty catalog
if raise_on_error:
raise RuntimeError("schedules table unavailable (database error or table never created)")
return [], 0
async with self.async_session_factory() as sess:
# Build base query with filters
@@ -4381,12 +4386,15 @@ class AsyncSqliteDb(AsyncBaseDb):
# Calculate offset from page
offset = (page - 1) * limit
# Get paginated results
stmt = base_query.order_by(table.c.created_at.desc()).limit(limit).offset(offset)
# Get paginated results (id is a unique tiebreaker so offset pages do not overlap
# or skip rows when many schedules share a created_at second)
stmt = base_query.order_by(table.c.created_at.desc(), table.c.id.desc()).limit(limit).offset(offset)
result = await sess.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()], total_count
except Exception as e:
log_debug(f"Error listing schedules: {e}")
if raise_on_error:
raise
return [], 0
async def create_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
+10 -2
View File
@@ -6729,10 +6729,15 @@ class SqliteDb(BaseDb):
limit: int = 100,
page: int = 1,
user_id: Optional[str] = None,
raise_on_error: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
try:
table = self._get_table(table_type="schedules")
if table is None:
# _get_table also returns None on connection errors (is_table_available
# swallows them), so strict callers must not see this as an empty catalog
if raise_on_error:
raise RuntimeError("schedules table unavailable (database error or table never created)")
return [], 0
with self.Session() as sess:
# Build base query with filters
@@ -6749,12 +6754,15 @@ class SqliteDb(BaseDb):
# Calculate offset from page
offset = (page - 1) * limit
# Get paginated results
stmt = base_query.order_by(table.c.created_at.desc()).limit(limit).offset(offset)
# Get paginated results (id is a unique tiebreaker so offset pages do not overlap
# or skip rows when many schedules share a created_at second)
stmt = base_query.order_by(table.c.created_at.desc(), table.c.id.desc()).limit(limit).offset(offset)
results = sess.execute(stmt).fetchall()
return [dict(row._mapping) for row in results], total_count
except Exception as e:
log_debug(f"Error listing schedules: {e}")
if raise_on_error:
raise
return [], 0
def create_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
+33 -9
View File
@@ -50,14 +50,35 @@ from agno.utils.serialize import json_serializer
MAX_MESSAGE_BLOCKS = 50
# Formats tool arg values for display in HITL approval cards; strings pass through, others JSON-encode
# Untrusted values are embedded in Slack inline code spans: a backtick closes the span,
# a newline exits it (inline code does not span lines), and <...> becomes a Slack control
# sequence once outside the span. Swap each for an inert lookalike / visible escape.
_CODE_SPAN_INERT = str.maketrans(
{
"`": "ˋ", # modifier letter grave accent
"<": "‹", # single left-pointing angle quotation mark
">": "›", # single right-pointing angle quotation mark
"\r": "\\r",
"\n": "\\n",
}
)
def inert_code_span_text(text: str) -> str:
return text.translate(_CODE_SPAN_INERT)
# Formats tool arg values for display in HITL approval cards; strings pass through, others
# JSON-encode. Output is always inerted so it cannot break out of the surrounding code span.
def render_arg_value(value: Any) -> str:
if isinstance(value, str):
return value
try:
return json.dumps(value, default=json_serializer)
except (TypeError, ValueError):
return str(value)
rendered = value
else:
try:
rendered = json.dumps(value, default=json_serializer)
except (TypeError, ValueError):
rendered = str(value)
return inert_code_span_text(rendered)
# --- Type detection helpers ---
@@ -185,8 +206,9 @@ def _build_confirmation_card(requirement: RunRequirement, run_id: str = "", awai
name = tool_name(requirement)
args = tool_args(requirement)
# Format args as bullet points in body (not subtitle which truncates)
body_lines = [f"• {k}: `{render_arg_value(v)}`" for k, v in (args or {}).items()]
# Format args as bullet points in body (not subtitle which truncates). Arg keys are
# model-derived and sit outside the code span, so inert them too.
body_lines = [f"• {inert_code_span_text(str(k))}: `{render_arg_value(v)}`" for k, v in (args or {}).items()]
body_text = "\n".join(body_lines) if body_lines else "_(no arguments)_"
# Slack Block Kit section text has ~200 char limit; truncate to prevent silent card rejection
body_text = truncate(body_text, 200)
@@ -566,7 +588,9 @@ def response_blocks(
action_id = element.get("action_id", "")
submitted = (state_values.get(block_id) or {}).get(action_id) or {}
value = _extract_input_value(element, submitted)
submissions.append(f"• {label}: `{value}`")
# Labels round-trip through Slack from the input schema (may be model-derived)
# and sit outside the code span, so inert them too.
submissions.append(f"• {inert_code_span_text(label)}: `{inert_code_span_text(value)}`")
if not submissions:
return preserved
@@ -20,6 +20,7 @@ from agno.os.interfaces.slack.helpers import (
resolve_slack_user,
send_slack_message_async,
should_respond,
slack_delivery_kwargs,
strip_bot_mention,
upload_response_media_async,
)
@@ -86,6 +87,9 @@ class SlackEventHandler:
task_display_mode: str
buffer_size: int
suggested_prompts: Optional[List[Dict[str, str]]] = None
unfurl_links: bool = True
unfurl_media: bool = True
markdown: bool = True
def _client(self) -> AsyncWebClient:
return AsyncWebClient(token=self.slack_tools.token, ssl=self.ssl)
@@ -214,6 +218,9 @@ class SlackEventHandler:
channel=ctx.channel_id,
message=message,
thread_ts=ctx.thread_id,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
async def handle_non_streaming(self, data: dict) -> None:
@@ -250,11 +257,25 @@ class SlackEventHandler:
rc = str(response.reasoning_content)
formatted = "*Reasoning:*\n> " + rc.replace("\n", "\n> ")
await send_slack_message_async(
client, channel=ctx.channel_id, message=formatted, thread_ts=ctx.thread_id
client,
channel=ctx.channel_id,
message=formatted,
thread_ts=ctx.thread_id,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
content = str(response.content) if response.content else ""
await send_slack_message_async(client, channel=ctx.channel_id, message=content, thread_ts=ctx.thread_id)
await send_slack_message_async(
client,
channel=ctx.channel_id,
message=content,
thread_ts=ctx.thread_id,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
await upload_response_media_async(client, response, ctx.channel_id, ctx.thread_id)
except Exception as e:
@@ -273,7 +294,15 @@ class SlackEventHandler:
content = str(response.content) if response.content else ""
if content:
await send_slack_message_async(client, channel=ctx.channel_id, message=content, thread_ts=ctx.thread_id)
await send_slack_message_async(
client,
channel=ctx.channel_id,
message=content,
thread_ts=ctx.thread_id,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
pause_labels = [PAUSE_LABELS[r.pause_type].format(tool=tool_name(r)) for r in requirements]
awaiting_ts = None
@@ -283,14 +312,23 @@ class SlackEventHandler:
channel=ctx.channel_id,
thread_ts=ctx.thread_id,
text="\n".join(pause_labels),
mrkdwn=True,
**slack_delivery_kwargs(self.unfurl_links, self.unfurl_media, self.markdown),
)
awaiting_ts = awaiting_resp.get("ts")
except Exception as exc:
log_error(f"[HITL] Non-streaming awaiting indicator failed: {exc}")
try:
await post_pause_card(client, response, ctx.channel_id, ctx.thread_id, awaiting_ts)
await post_pause_card(
client,
response,
ctx.channel_id,
ctx.thread_id,
awaiting_ts,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
except Exception as exc:
log_error(f"[HITL] Non-streaming pause card failed: {exc}")
@@ -429,9 +467,21 @@ class SlackEventHandler:
channel=ctx.channel_id,
thread_ts=ctx.thread_id,
requirements=requirements,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
try:
await post_pause_card(client, state.paused_event, ctx.channel_id, ctx.thread_id, awaiting_ts)
await post_pause_card(
client,
state.paused_event,
ctx.channel_id,
ctx.thread_id,
awaiting_ts,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
except Exception as exc:
log_error(f"[HITL] Failed to post Card block (pause): {exc}")
+28 -3
View File
@@ -290,8 +290,29 @@ async def open_chat_stream(
)
def slack_delivery_kwargs(unfurl_links: bool, unfurl_media: bool, mrkdwn: bool) -> Dict[str, Any]:
# mrkdwn=False means plaintext delivery: parse="none" stops Slack from
# linkifying bare URLs and link_names=False stops bare @name expansion. Note
# this does NOT neutralize control sequences already encoded as `<!channel>`
# / `<@U123>` — only parse="full" escapes those. Card bodies inert those
# characters directly (see builders.inert_code_span_text); the plain message
# path relies on the model not emitting pre-encoded sequences.
kwargs: Dict[str, Any] = {"unfurl_links": unfurl_links, "unfurl_media": unfurl_media, "mrkdwn": mrkdwn}
if not mrkdwn:
kwargs["parse"] = "none"
kwargs["link_names"] = False
return kwargs
async def send_slack_message_async(
async_client: Any, channel: str, thread_ts: str, message: str, italics: bool = False
async_client: Any,
channel: str,
thread_ts: str,
message: str,
italics: bool = False,
unfurl_links: bool = True,
unfurl_media: bool = True,
mrkdwn: bool = True,
) -> None:
if not message or not message.strip():
return
@@ -301,13 +322,17 @@ async def send_slack_message_async(
return "\n".join([f"_{line}_" for line in text.split("\n")])
return text
delivery = slack_delivery_kwargs(unfurl_links, unfurl_media, mrkdwn)
# Under Slack's 40K char limit with margin for batch prefix overhead
max_len = 39900
if len(message) <= max_len:
await async_client.chat_postMessage(channel=channel, text=_format(message), thread_ts=thread_ts)
await async_client.chat_postMessage(channel=channel, text=_format(message), thread_ts=thread_ts, **delivery)
return
message_batches = [message[i : i + max_len] for i in range(0, len(message), max_len)]
for i, batch in enumerate(message_batches, 1):
batch_message = f"[{i}/{len(message_batches)}] {batch}"
await async_client.chat_postMessage(channel=channel, text=_format(batch_message), thread_ts=thread_ts)
await async_client.chat_postMessage(
channel=channel, text=_format(batch_message), thread_ts=thread_ts, **delivery
)
+14 -1
View File
@@ -46,6 +46,9 @@ class HITLHandler:
entity_type: Literal["agent", "team", "workflow"]
task_display_mode: str
buffer_size: int
unfurl_links: bool = True
unfurl_media: bool = True
markdown: bool = True
def _client(self) -> AsyncWebClient:
return AsyncWebClient(token=self.slack_tools.token, ssl=self.ssl)
@@ -220,10 +223,20 @@ class HITLHandler:
thread_ts=ctx.thread_ts,
requirements=requirements,
log_prefix="re-",
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
try:
await post_pause_card(
self._client(), state.paused_event, ctx.channel, ctx.thread_ts, new_awaiting_ts
self._client(),
state.paused_event,
ctx.channel,
ctx.thread_ts,
new_awaiting_ts,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
mrkdwn=self.markdown,
)
except Exception as exc:
log_error(f"[HITL] Failed to post Card block (re-pause): {exc}")
+16 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, List, Optional
from agno.os.interfaces.slack.builders import build_pause_message
from agno.os.interfaces.slack.helpers import slack_error_code
from agno.os.interfaces.slack.helpers import slack_delivery_kwargs, slack_error_code
from agno.os.interfaces.slack.types import block_to_dict, tool_name
from agno.utils.log import log_error
@@ -30,6 +30,9 @@ async def finalize_pause(
thread_ts: str,
requirements: List["RunRequirement"],
log_prefix: str = "",
unfurl_links: bool = True,
unfurl_media: bool = True,
mrkdwn: bool = True,
) -> Optional[str]:
# 1. Stop the stream with accumulated content
stop_kwargs = {}
@@ -51,7 +54,12 @@ async def finalize_pause(
return None
try:
resp = await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text="\n".join(labels))
resp = await client.chat_postMessage(
channel=channel,
thread_ts=thread_ts,
text="\n".join(labels),
**slack_delivery_kwargs(unfurl_links, unfurl_media, mrkdwn),
)
return resp.get("ts")
except Exception as exc:
log_error(f"[HITL] awaiting indicator failed: {exc}")
@@ -64,6 +72,10 @@ async def post_pause_card(
channel: str,
thread_ts: str,
awaiting_ts: Optional[str] = None,
*,
unfurl_links: bool = True,
unfurl_media: bool = True,
mrkdwn: bool = True,
) -> Optional[str]:
run_id = getattr(paused_event, "run_id", None)
requirements = list(getattr(paused_event, "active_requirements", None) or [])
@@ -71,12 +83,14 @@ async def post_pause_card(
return None
try:
# The card blocks embed untrusted tool-arg text, so unfurl flags must apply here
blocks = build_pause_message(run_id, requirements, awaiting_ts)
resp = await client.chat_postMessage(
channel=channel,
thread_ts=thread_ts,
text="Run paused — please resolve below",
blocks=[block_to_dict(b) for b in blocks],
**slack_delivery_kwargs(unfurl_links, unfurl_media, mrkdwn),
)
return resp.get("ts")
except Exception as exc:
@@ -55,6 +55,9 @@ def attach_routes(
max_file_size: int = 1_073_741_824, # 1GB
resolve_user_identity: bool = False,
respond_to_other_apps: bool = False,
markdown: bool = True,
unfurl_links: bool = True,
unfurl_media: bool = True,
) -> APIRouter:
# Inner functions capture config via closure to keep each instance isolated
entity = agent or team or workflow
@@ -96,6 +99,9 @@ def attach_routes(
entity_type=entity_type,
task_display_mode=task_display_mode,
buffer_size=buffer_size,
unfurl_links=unfurl_links,
unfurl_media=unfurl_media,
markdown=markdown,
)
event_handler = SlackEventHandler(
slack_tools=slack_tools,
@@ -115,6 +121,9 @@ def attach_routes(
task_display_mode=task_display_mode,
buffer_size=buffer_size,
suggested_prompts=suggested_prompts,
unfurl_links=unfurl_links,
unfurl_media=unfurl_media,
markdown=markdown,
)
@router.post(
@@ -39,6 +39,9 @@ class Slack(BaseInterface):
max_file_size: int = 1_073_741_824, # 1GB
resolve_user_identity: bool = False,
respond_to_other_apps: bool = False,
markdown: bool = True,
unfurl_links: bool = True,
unfurl_media: bool = True,
):
self.agent = agent
self.team = team
@@ -58,6 +61,9 @@ class Slack(BaseInterface):
self.max_file_size = max_file_size
self.resolve_user_identity = resolve_user_identity
self.respond_to_other_apps = respond_to_other_apps
self.markdown = markdown
self.unfurl_links = unfurl_links
self.unfurl_media = unfurl_media
if not (self.agent or self.team or self.workflow):
raise ValueError("Slack requires an agent, team, or workflow")
@@ -81,6 +87,9 @@ class Slack(BaseInterface):
max_file_size=self.max_file_size,
resolve_user_identity=self.resolve_user_identity,
respond_to_other_apps=self.respond_to_other_apps,
markdown=self.markdown,
unfurl_links=self.unfurl_links,
unfurl_media=self.unfurl_media,
)
return self.router
+1 -1
View File
@@ -2201,7 +2201,7 @@ async def aprepare_queued_run(
appended = await _atomic_append_run(component, session_id, workflow_run_dict, user_id)
if appended is not None:
return
workflow_session, _ = await component._aload_or_create_session(
workflow_session, _, _ = await component._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=None
)
if await _ainsert_session_if_absent(component, workflow_session) is not None:
+1 -1
View File
@@ -184,7 +184,7 @@ async def apersist_run_transition(
# state of a run whose only write is this error transition
workflow_session = await component.aget_session(session_id=session_id)
if workflow_session is None:
workflow_session, _ = await component._aload_or_create_session(
workflow_session, _, _ = await component._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=None
)
storage_run = run_response
+96 -2
View File
@@ -225,12 +225,59 @@ class ScheduleManager:
def list(
self, enabled: Optional[bool] = None, limit: int = 100, page: int = 1, user_id: Optional[str] = None
) -> List[Schedule]:
"""List schedules. ``user_id`` scopes the listing to one owner."""
"""List a single page of schedules (DB errors yield an empty list).
Use list_all() to enumerate every schedule and surface DB errors.
``user_id`` scopes the listing to one owner.
"""
result = self._call("get_schedules", enabled=enabled, limit=limit, page=page, user_id=user_id)
# get_schedules returns (schedules_list, total_count) tuple
schedules_data = result[0] if isinstance(result, tuple) else result
return self._to_schedule_list(schedules_data)
def list_all(
self, enabled: Optional[bool] = None, user_id: Optional[str] = None, *, raise_on_error: bool = True
) -> List[Schedule]:
"""List every schedule, paging through the full catalog.
With raise_on_error=True (the default) the DB re-raises failures and
raises when the schedules table is unavailable (database error or table
never created), instead of masquerading as an empty catalog.
Db subclasses whose get_schedules does not accept raise_on_error will
raise TypeError here; that is expected for this strict API.
Args:
enabled: Optional filter on the enabled flag.
user_id: Scopes the listing to one owner.
raise_on_error: Forwarded to the DB. When False, DB errors yield
an empty or partial result, matching list().
"""
schedules: List[Schedule] = []
page = 1
page_size = 100
while True:
result = self._call(
"get_schedules",
enabled=enabled,
limit=page_size,
page=page,
user_id=user_id,
raise_on_error=raise_on_error,
)
if not isinstance(result, tuple):
# Legacy third-party Dbs may return a bare list: treat it as complete
return self._to_schedule_list(result)
# A (None, total) result means no rows; normalize so the short-page
# check below matches list()'s tolerance instead of raising on len(None).
rows = result[0] or []
schedules.extend(self._to_schedule_list(rows))
# Stop on a short page; totals can shift mid-sweep, so total_count is
# not reconciled against the row count
if len(rows) < page_size:
return schedules
page += 1
def get(self, schedule_id: str, user_id: Optional[str] = None) -> Optional[Schedule]:
"""Get a schedule by ID."""
return self._to_schedule(self._call("get_schedule", schedule_id, user_id=user_id))
@@ -392,12 +439,59 @@ class ScheduleManager:
async def alist(
self, enabled: Optional[bool] = None, limit: int = 100, page: int = 1, user_id: Optional[str] = None
) -> List[Schedule]:
"""Async list schedules. ``user_id`` scopes the listing to one owner."""
"""Async list a single page of schedules (DB errors yield an empty list).
Use alist_all() to enumerate every schedule and surface DB errors.
``user_id`` scopes the listing to one owner.
"""
result = await self._acall("get_schedules", enabled=enabled, limit=limit, page=page, user_id=user_id)
# get_schedules returns (schedules_list, total_count) tuple
schedules_data = result[0] if isinstance(result, tuple) else result
return self._to_schedule_list(schedules_data)
async def alist_all(
self, enabled: Optional[bool] = None, user_id: Optional[str] = None, *, raise_on_error: bool = True
) -> List[Schedule]:
"""Async list every schedule, paging through the full catalog.
With raise_on_error=True (the default) the DB re-raises failures and
raises when the schedules table is unavailable (database error or table
never created), instead of masquerading as an empty catalog.
Db subclasses whose get_schedules does not accept raise_on_error will
raise TypeError here; that is expected for this strict API.
Args:
enabled: Optional filter on the enabled flag.
user_id: Scopes the listing to one owner.
raise_on_error: Forwarded to the DB. When False, DB errors yield
an empty or partial result, matching alist().
"""
schedules: List[Schedule] = []
page = 1
page_size = 100
while True:
result = await self._acall(
"get_schedules",
enabled=enabled,
limit=page_size,
page=page,
user_id=user_id,
raise_on_error=raise_on_error,
)
if not isinstance(result, tuple):
# Legacy third-party Dbs may return a bare list: treat it as complete
return self._to_schedule_list(result)
# A (None, total) result means no rows; normalize so the short-page
# check below matches list()'s tolerance instead of raising on len(None).
rows = result[0] or []
schedules.extend(self._to_schedule_list(rows))
# Stop on a short page; totals can shift mid-sweep, so total_count is
# not reconciled against the row count
if len(rows) < page_size:
return schedules
page += 1
async def aget(self, schedule_id: str, user_id: Optional[str] = None) -> Optional[Schedule]:
"""Async get a schedule by ID."""
return self._to_schedule(await self._acall("get_schedule", schedule_id, user_id=user_id))
+35 -2
View File
@@ -1985,10 +1985,15 @@ def run_dispatch(
)
# Read existing session from database
from copy import deepcopy
team_session = _read_or_create_session(team, session_id=session_id, user_id=user_id)
# Snapshot BEFORE _update_metadata merges team.metadata into the session dict,
# so the session layer keeps the session's own values (team < session < call-site).
session_metadata = deepcopy(team_session.metadata)
_update_metadata(team, session=team_session)
# Resolve run options AFTER _update_metadata so session-stored metadata is visible
# Resolve run options with session-stored metadata as the middle layer
opts = resolve_run_options(
team,
stream=stream,
@@ -2000,6 +2005,7 @@ def run_dispatch(
dependencies=dependencies,
knowledge_filters=knowledge_filters,
metadata=metadata,
session_metadata=session_metadata,
output_schema=output_schema,
)
@@ -4301,7 +4307,9 @@ def arun_dispatch( # type: ignore
# Initialize Team
team.initialize_team(debug_mode=debug_mode)
# Resolve run options centrally
# Resolve run options centrally. No session pre-read happens here: the session
# is read inside _arun/_arun_stream AFTER options are resolved, so session-stored
# metadata does not reach this run's resolved options (matches the agent async-DB path).
opts = resolve_run_options(
team,
stream=stream,
@@ -7558,7 +7566,12 @@ def continue_run_dispatch(
team.initialize_team(debug_mode=debug_mode)
# Read existing session from storage
from copy import deepcopy
team_session = _read_or_create_session(team, session_id=session_id, user_id=user_id)
# Snapshot BEFORE _update_metadata merges team.metadata into the session dict,
# so the session layer keeps the session's own values (team < session < call-site).
session_metadata = deepcopy(team_session.metadata)
_update_metadata(team, session=team_session)
# Fall back to the owner the run paused with, so the resume retrieves under the same scope
@@ -7592,6 +7605,7 @@ def continue_run_dispatch(
dependencies=dependencies,
knowledge_filters=knowledge_filters,
metadata=metadata,
session_metadata=session_metadata,
)
# Initialize run context
@@ -9289,6 +9303,24 @@ def acontinue_run_dispatch( # type: ignore
# Initialize the Team
team.initialize_team(debug_mode=debug_mode)
# Pre-read the session so session-stored metadata is visible to
# resolve_run_options via session_metadata. Only possible with a sync DB:
# with an async DB the session is read inside _acontinue_run AFTER options are
# resolved, so session metadata does not reach this run's resolved options.
from agno.team._init import _has_async_db
session_metadata: Optional[Dict[str, Any]] = None
if team.db is not None and not _has_async_db(team):
from copy import deepcopy
from agno.team._storage import _read_or_create_session, _update_metadata
_pre_session = _read_or_create_session(team, session_id=session_id_resolved, user_id=user_id)
# Snapshot BEFORE _update_metadata merges team.metadata into the session dict,
# so the session layer keeps the session's own values (team < session < call-site).
session_metadata = deepcopy(_pre_session.metadata)
_update_metadata(team, session=_pre_session)
# Resolve run options
opts = resolve_run_options(
team,
@@ -9298,6 +9330,7 @@ def acontinue_run_dispatch( # type: ignore
dependencies=dependencies,
knowledge_filters=knowledge_filters,
metadata=metadata,
session_metadata=session_metadata,
)
# Initialize run context
+18 -10
View File
@@ -20,8 +20,8 @@ class ResolvedRunOptions:
All values are fully resolved (call-site > team default > fallback)
at construction time. Two fields merge instead of replacing: ``metadata``
(team-level values win on conflicting keys) and ``dependencies``
(call-site values win on conflicting keys).
(team < session < call-site, later layers win on conflicting keys) and
``dependencies`` (call-site values win on conflicting keys).
"""
stream: bool
@@ -86,12 +86,19 @@ def resolve_run_options(
dependencies: Optional[Dict[str, Any]] = None,
knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
metadata: Optional[Dict[str, Any]] = None,
session_metadata: Optional[Dict[str, Any]] = None,
output_schema: Optional[Union[Type[BaseModel], Dict[str, Any]]] = None,
) -> ResolvedRunOptions:
"""Resolve all run options from call-site values and team defaults.
Reads from ``team`` but does not mutate it.
``session_metadata`` is the session-stored metadata read by the dispatch
function; it sits between team defaults and call-site values in the
metadata merge (team < session < call-site).
"""
from copy import deepcopy
from agno.team._utils import _get_effective_filters
from agno.utils.merge_dict import merge_dictionaries
@@ -147,15 +154,16 @@ def resolve_run_options(
if team.knowledge_filters or knowledge_filters:
resolved_filters = _get_effective_filters(team, knowledge_filters=knowledge_filters)
# metadata: merge call-site + team.metadata (team values take precedence)
# metadata: layered merge, call-site wins (team < session < call-site),
# matching how dependencies resolve. Each layer is deep-copied before it is
# merged, so no nested dict in the result aliases a source dict
# (merge_dictionaries recurses in place).
resolved_metadata: Optional[Dict[str, Any]] = None
if metadata is not None and team.metadata is not None:
resolved_metadata = metadata.copy()
merge_dictionaries(resolved_metadata, team.metadata)
elif metadata is not None:
resolved_metadata = metadata.copy()
elif team.metadata is not None:
resolved_metadata = team.metadata.copy()
for layer in (team.metadata, session_metadata, metadata):
if layer is not None:
if resolved_metadata is None:
resolved_metadata = {}
merge_dictionaries(resolved_metadata, deepcopy(layer))
# output_schema: call-site > team.output_schema
resolved_output_schema = output_schema if output_schema is not None else team.output_schema
+21 -15
View File
@@ -337,10 +337,10 @@ def _read_or_create_session(team: "Team", session_id: str, user_id: Optional[str
# Create new session if none found
if team_session is None:
log_debug(f"Creating new TeamSession: {session_id}")
from copy import deepcopy
session_data = {}
if team.session_state is not None:
from copy import deepcopy
session_data["session_state"] = deepcopy(team.session_state)
team_session = TeamSession(
session_id=session_id,
@@ -348,7 +348,8 @@ def _read_or_create_session(team: "Team", session_id: str, user_id: Optional[str
user_id=user_id,
team_data=get_team_data(team),
session_data=session_data,
metadata=team.metadata,
# Copy so the session record never aliases the shared Team's dict
metadata=deepcopy(team.metadata),
created_at=int(time()),
)
if team.introduction is not None:
@@ -410,10 +411,10 @@ async def _aread_or_create_session(team: "Team", session_id: str, user_id: Optio
# Create new session if none found
if team_session is None:
log_debug(f"Creating new TeamSession: {session_id}")
from copy import deepcopy
session_data = {}
if team.session_state is not None:
from copy import deepcopy
session_data["session_state"] = deepcopy(team.session_state)
team_session = TeamSession(
session_id=session_id,
@@ -421,7 +422,8 @@ async def _aread_or_create_session(team: "Team", session_id: str, user_id: Optio
user_id=user_id,
team_data=get_team_data(team),
session_data=session_data,
metadata=team.metadata,
# Copy so the session record never aliases the shared Team's dict
metadata=deepcopy(team.metadata),
created_at=int(time()),
)
if team.introduction is not None:
@@ -488,16 +490,20 @@ def _load_session_state(team: "Team", session: TeamSession, session_state: Dict[
def _update_metadata(team: "Team", session: TeamSession):
"""Update the extra_data in the session"""
"""Merge the team's metadata into the session's metadata.
# Read metadata from the database
if session.metadata is not None:
# If metadata is set in the agent, update the database metadata with the agent's metadata
if team.metadata is not None:
# Updates agent's session metadata in place
merge_dictionaries(session.metadata, team.metadata)
# Update the current metadata with the metadata from the database which is updated in place
team.metadata = session.metadata
Team metadata provides defaults; the session's own stored values win on
conflict, matching resolve_run_options (team < session), so a value set on
the session is not overwritten by a team default and persists across runs.
Only the session is updated; the shared Team instance is never mutated.
"""
if session.metadata is not None and team.metadata is not None:
from copy import deepcopy
merged = deepcopy(team.metadata)
merge_dictionaries(merged, session.metadata)
session.metadata.clear()
session.metadata.update(merged)
def to_dict(team: "Team") -> Dict[str, Any]:
@@ -33,6 +33,13 @@ class AuthConfig:
# --- HTTP timeout for API calls ---
http_timeout: Optional[float] = None
# --- Interactive OAuth fallback ---
# When False, _resolve_creds raises instead of opening a local browser flow
# (headless servers). GOOGLE_OAUTH_NONINTERACTIVE=1 flips the default.
interactive: bool = field(
default_factory=lambda: (getenv("GOOGLE_OAUTH_NONINTERACTIVE") or "false").lower() not in ("true", "1", "yes")
)
# --- Scope aggregation (internal) ---
_scopes: Set[str] = field(default_factory=set, repr=False)
_creds: Any = field(default=None, repr=False)
+7
View File
@@ -273,6 +273,13 @@ class GoogleToolkit(Toolkit):
return creds
# 6. Interactive OAuth (local only) — uses AGGREGATED scopes
# Raise (never return None): the @authenticate decorator only converts
# exceptions into its auth-failed JSON.
if not self._auth.interactive:
raise RuntimeError(
"Google credentials require interactive OAuth but interactive login is disabled "
"(interactive=False). Re-authenticate offline or use the GoogleAuth toolkit to mint a consent URL."
)
client_id = self._auth.client_id
client_secret = self._auth.client_secret
+3
View File
@@ -11,6 +11,9 @@ Required Setup:
- GOOGLE_CLIENT_SECRET
- GOOGLE_PROJECT_ID
4. First run opens a browser for consent; token is cached in token.json
5. On headless servers pass auth=AuthConfig(interactive=False) (or set
GOOGLE_OAUTH_NONINTERACTIVE=1): expired credentials then raise a clear error
instead of blocking on a browser that never opens
**Option B — Service Account (headless, for servers):**
1. Create a service account in Google Cloud Console
+2
View File
@@ -41,6 +41,8 @@ How to Get These Credentials:
Note: The first time you run the application, it will open a browser window for OAuth authentication.
A token.json file will be created to store the authentication credentials for future use.
On headless servers pass auth=AuthConfig(interactive=False) (or set GOOGLE_OAUTH_NONINTERACTIVE=1):
expired credentials then raise a clear error instead of blocking on a browser that never opens.
Service Account Authentication (Alternative):
---------------------------------------------
+2
View File
@@ -42,6 +42,8 @@ Alternatively, follow the instructions in the Google Sheets API Quickstart guide
Note: The first time you run the application, it will open a browser window for OAuth authentication.
A token.json file will be created to store the authentication credentials for future use.
On headless servers pass auth=AuthConfig(interactive=False) (or set GOOGLE_OAUTH_NONINTERACTIVE=1):
expired credentials then raise a clear error instead of blocking on a browser that never opens.
"""
import json
+90 -35
View File
@@ -745,13 +745,20 @@ class Workflow:
*,
dependencies: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
session_metadata: Optional[Dict[str, Any]] = None,
add_dependencies_to_context: Optional[bool] = None,
add_session_state_to_context: Optional[bool] = None,
) -> Dict[str, Any]:
"""Resolve run-level params: call-site > self.<field> > None.
``session_metadata`` is the session-stored metadata read by the dispatch
function; it sits between workflow defaults and call-site values in the
metadata merge (self < session < call-site).
Returns a dict of resolved values ready to pass to RunContext().
"""
from copy import deepcopy
from agno.utils.merge_dict import merge_dictionaries
# dependencies: merge run level with self.dependencies (run level wins on conflicts)
@@ -764,15 +771,18 @@ class Workflow:
elif self.dependencies is not None:
resolved_dependencies = self.dependencies.copy()
# metadata: merge call-site with self.metadata (self wins on conflicts)
# metadata: layered merge, call-site wins (self < session < call-site),
# matching how Agent resolves. Each layer is deep-copied before it is
# merged, so no nested dict in the result aliases a source dict — in
# particular self.metadata, whose nested dicts must never be mutated by
# an in-run write to run_context.metadata (merge_dictionaries recurses
# in place).
resolved_metadata: Optional[Dict[str, Any]] = None
if metadata is not None and self.metadata is not None:
resolved_metadata = metadata.copy()
merge_dictionaries(resolved_metadata, self.metadata)
elif metadata is not None:
resolved_metadata = metadata.copy()
elif self.metadata is not None:
resolved_metadata = self.metadata.copy()
for layer in (self.metadata, session_metadata, metadata):
if layer is not None:
if resolved_metadata is None:
resolved_metadata = {}
merge_dictionaries(resolved_metadata, deepcopy(layer))
return {
"dependencies": resolved_dependencies,
@@ -1632,10 +1642,10 @@ class Workflow:
if workflow_session is None:
# Creating new session if none found
log_debug(f"Creating new WorkflowSession: {session_id}")
from copy import deepcopy
session_data = {}
if self.session_state is not None:
from copy import deepcopy
session_data["session_state"] = deepcopy(self.session_state)
workflow_session = WorkflowSession(
session_id=session_id,
@@ -1643,7 +1653,8 @@ class Workflow:
user_id=user_id,
workflow_data=self._get_workflow_data(),
session_data=session_data,
metadata=self.metadata,
# Copy so the session record never aliases the shared Workflow's dict
metadata=deepcopy(self.metadata),
created_at=int(time()),
)
@@ -1675,10 +1686,10 @@ class Workflow:
if workflow_session is None:
# Creating new session if none found
log_debug(f"Creating new WorkflowSession: {session_id}")
from copy import deepcopy
session_data = {}
if self.session_state is not None:
from copy import deepcopy
session_data["session_state"] = deepcopy(self.session_state)
workflow_session = WorkflowSession(
session_id=session_id,
@@ -1686,7 +1697,8 @@ class Workflow:
user_id=user_id,
workflow_data=self._get_workflow_data(),
session_data=session_data,
metadata=self.metadata,
# Copy so the session record never aliases the shared Workflow's dict
metadata=deepcopy(self.metadata),
created_at=int(time()),
)
@@ -2336,17 +2348,23 @@ class Workflow:
log_debug(f"Failed to mark run as completed in buffer: {buffer_err}")
def _update_metadata(self, session: WorkflowSession):
"""Update the extra_data in the session"""
from agno.utils.merge_dict import merge_dictionaries
"""Merge the workflow's metadata into the session's metadata.
# Read metadata from the database
if session.metadata is not None:
# If metadata is set in the workflow, update the database metadata with the workflow's metadata
if self.metadata is not None:
# Updates workflow's session metadata in place
merge_dictionaries(session.metadata, self.metadata)
# Update the current metadata with the metadata from the database which is updated in place
self.metadata = session.metadata
Workflow metadata provides defaults; the session's own stored values win
on conflict, matching _resolve_run_params (self < session), so a value
set on the session is not overwritten by a workflow default and persists
across runs. Only the session is updated; the shared Workflow instance
is never mutated.
"""
if session.metadata is not None and self.metadata is not None:
from copy import deepcopy
from agno.utils.merge_dict import merge_dictionaries
merged = deepcopy(self.metadata)
merge_dictionaries(merged, session.metadata)
session.metadata.clear()
session.metadata.update(merged)
def _load_session_state(self, session: WorkflowSession, session_state: Dict[str, Any]):
"""Load and return the stored session_state from the database, optionally merging it with the given one"""
@@ -3777,24 +3795,31 @@ class Workflow:
async def _aload_or_create_session(
self, session_id: str, user_id: Optional[str], session_state: Optional[Dict[str, Any]]
) -> Tuple[WorkflowSession, Dict[str, Any]]:
) -> Tuple[WorkflowSession, Dict[str, Any], Optional[Dict[str, Any]]]:
"""Load or create session from database, update metadata, and prepare session state.
Returns:
Tuple of (workflow_session, prepared_session_state)
Tuple of (workflow_session, prepared_session_state, session_metadata),
where session_metadata is a snapshot of the session-stored metadata
taken before _update_metadata merges self.metadata into the session.
"""
from copy import deepcopy
# Read existing session from database
if self._has_async_db():
workflow_session = await self.aread_or_create_session(session_id=session_id, user_id=user_id)
else:
workflow_session = self.read_or_create_session(session_id=session_id, user_id=user_id)
# Snapshot BEFORE _update_metadata merges self.metadata into the session dict,
# so the session layer keeps the session's own values (self < session < call-site).
session_metadata = deepcopy(workflow_session.metadata)
self._update_metadata(session=workflow_session)
# Update session state from DB
_session_state = session_state if session_state is not None else {}
_session_state = self._load_session_state(session=workflow_session, session_state=_session_state)
return workflow_session, _session_state
return workflow_session, _session_state, session_metadata
async def _aexecute(
self,
@@ -3812,8 +3837,10 @@ class Workflow:
from inspect import isasyncgenfunction, iscoroutinefunction, isgeneratorfunction
await aregister_run(run_context.run_id)
# Read existing session from database
workflow_session, run_context.session_state = await self._aload_or_create_session(
# Read existing session from database. run_context is already resolved at this
# point, so the session-metadata snapshot cannot reach this run's metadata here;
# arun() pre-reads the session for that when the DB is sync.
workflow_session, run_context.session_state, _ = await self._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=run_context.session_state
)
@@ -4171,8 +4198,10 @@ class Workflow:
await aregister_run(run_context.run_id)
# Read existing session from database
workflow_session, run_context.session_state = await self._aload_or_create_session(
# Read existing session from database. run_context is already resolved at this
# point, so the session-metadata snapshot cannot reach this run's metadata here;
# arun() pre-reads the session for that when the DB is sync.
workflow_session, run_context.session_state, _ = await self._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=run_context.session_state
)
@@ -4836,7 +4865,7 @@ class Workflow:
session_id, user_id = self._initialize_session(session_id=session_id, user_id=user_id)
# Read existing session from database
workflow_session, session_state = await self._aload_or_create_session(
workflow_session, session_state, session_metadata = await self._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=session_state
)
@@ -4844,6 +4873,7 @@ class Workflow:
resolved = self._resolve_run_params(
dependencies=dependencies,
metadata=metadata,
session_metadata=session_metadata,
add_dependencies_to_context=add_dependencies_to_context,
add_session_state_to_context=add_session_state_to_context,
)
@@ -5037,7 +5067,7 @@ class Workflow:
session_id, user_id = self._initialize_session(session_id=session_id, user_id=user_id)
# Read existing session from database
workflow_session, session_state = await self._aload_or_create_session(
workflow_session, session_state, session_metadata = await self._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=session_state
)
@@ -5045,6 +5075,7 @@ class Workflow:
resolved = self._resolve_run_params(
dependencies=dependencies,
metadata=metadata,
session_metadata=session_metadata,
add_dependencies_to_context=add_dependencies_to_context,
add_session_state_to_context=add_session_state_to_context,
)
@@ -5315,7 +5346,7 @@ class Workflow:
session_id, user_id = self._initialize_session(session_id=session_id, user_id=user_id)
# Read existing session from database
workflow_session, session_state = await self._aload_or_create_session(
workflow_session, session_state, _ = await self._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=session_state
)
@@ -6003,7 +6034,10 @@ class Workflow:
session_state: Optional[Dict[str, Any]],
) -> Tuple[WorkflowSession, Dict[str, Any]]:
"""Helper to load or create session for workflow agent execution"""
return await self._aload_or_create_session(session_id=session_id, user_id=user_id, session_state=session_state)
workflow_session, _session_state, _ = await self._aload_or_create_session(
session_id=session_id, user_id=user_id, session_state=session_state
)
return workflow_session, _session_state
def _aexecute_workflow_agent(
self,
@@ -10461,7 +10495,12 @@ class Workflow:
session_id, user_id = self._initialize_session(session_id=session_id, user_id=user_id)
# Read existing session from database
from copy import deepcopy
workflow_session = self.read_or_create_session(session_id=session_id, user_id=user_id)
# Snapshot BEFORE _update_metadata merges self.metadata into the session dict,
# so the session layer keeps the session's own values (self < session < call-site).
session_metadata = deepcopy(workflow_session.metadata)
self._update_metadata(session=workflow_session)
# Initialize session state. Get it from DB if relevant.
@@ -10513,6 +10552,7 @@ class Workflow:
resolved = self._resolve_run_params(
dependencies=dependencies,
metadata=metadata,
session_metadata=session_metadata,
add_dependencies_to_context=add_dependencies_to_context,
add_session_state_to_context=add_session_state_to_context,
)
@@ -10747,10 +10787,25 @@ class Workflow:
self.initialize_workflow()
session_id, user_id = self._initialize_session(session_id=session_id, user_id=user_id)
# Pre-read the session so session-stored metadata is visible in the resolved
# run params. Only possible with a sync DB: arun() is not async, and with an
# async DB the session is read inside _aexecute/_aexecute_stream AFTER params
# are resolved, so session metadata cannot reach this run's resolved params.
session_metadata: Optional[Dict[str, Any]] = None
if self.db is not None and not self._has_async_db():
from copy import deepcopy
workflow_session = self.read_or_create_session(session_id=session_id, user_id=user_id)
# Snapshot BEFORE _update_metadata merges self.metadata into the session dict,
# so the session layer keeps the session's own values (self < session < call-site).
session_metadata = deepcopy(workflow_session.metadata)
self._update_metadata(session=workflow_session)
# Resolve run-level params using shared helper (deep merge, call-site > self.<field> > None)
resolved = self._resolve_run_params(
dependencies=dependencies,
metadata=metadata,
session_metadata=session_metadata,
add_dependencies_to_context=add_dependencies_to_context,
add_session_state_to_context=add_session_state_to_context,
)
+259 -7
View File
@@ -1,11 +1,18 @@
"""Tests for centralized run option resolution."""
import dataclasses
import time
from typing import Any, AsyncIterator, Dict, Iterator
import pytest
from agno.agent._run_options import ResolvedRunOptions, resolve_run_options
from agno.agent.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.models.base import Model
from agno.models.message import MessageMetrics
from agno.models.response import ModelResponse
from agno.session import AgentSession
def _make_agent(**kwargs) -> Agent:
@@ -13,6 +20,60 @@ def _make_agent(**kwargs) -> Agent:
return Agent(**kwargs)
class MockModel(Model):
"""Minimal offline model: returns a canned text response without any network call."""
def __init__(self):
super().__init__(id="test-model", name="test-model", provider="test")
self.instructions = None
self._mock_response = ModelResponse(
content="ok",
role="assistant",
response_usage=MessageMetrics(),
)
def get_instructions_for_model(self, *args, **kwargs):
return None
def get_system_message_for_model(self, *args, **kwargs):
return None
async def aget_instructions_for_model(self, *args, **kwargs):
return None
async def aget_system_message_for_model(self, *args, **kwargs):
return None
def parse_args(self, *args, **kwargs):
return {}
def invoke(self, *args, **kwargs) -> ModelResponse:
return self._mock_response
async def ainvoke(self, *args, **kwargs) -> ModelResponse:
return self._mock_response
def invoke_stream(self, *args, **kwargs) -> Iterator[ModelResponse]:
yield self._mock_response
async def ainvoke_stream(self, *args, **kwargs) -> AsyncIterator[ModelResponse]:
yield self._mock_response
return
def _parse_provider_response(self, response: Any, **kwargs) -> ModelResponse:
return self._mock_response
def _parse_provider_response_delta(self, response: Any) -> ModelResponse:
return self._mock_response
def _seed_session(db: InMemoryDb, session_id: str, agent_id: str, metadata: Dict[str, Any]) -> None:
"""Insert a session record with the given metadata, as if written by an earlier deployment."""
db.upsert_session(
AgentSession(session_id=session_id, agent_id=agent_id, metadata=metadata, created_at=int(time.time()))
)
class TestResolvedRunOptionsImmutable:
def test_frozen_raises_on_assignment(self):
opts = ResolvedRunOptions(
@@ -154,19 +215,98 @@ class TestMetadataMerge:
opts = resolve_run_options(agent)
assert opts.metadata == {"agent": "value"}
def test_merge_agent_takes_precedence(self):
agent = _make_agent(metadata={"shared": "agent_wins", "agent_only": "a"})
def test_merge_callsite_takes_precedence(self):
# Metadata resolves like dependencies: call-site keys win on conflict.
agent = _make_agent(metadata={"shared": "agent_value", "agent_only": "a"})
opts = resolve_run_options(agent, metadata={"shared": "run_value", "run_only": "r"})
# agent.metadata takes precedence on conflicts
assert opts.metadata["shared"] == "agent_wins"
assert opts.metadata["shared"] == "run_value"
assert opts.metadata["agent_only"] == "a"
assert opts.metadata["run_only"] == "r"
def test_only_session(self):
agent = _make_agent()
opts = resolve_run_options(agent, session_metadata={"session": "value"})
assert opts.metadata == {"session": "value"}
def test_session_beats_agent(self):
agent = _make_agent(metadata={"shared": "agent_value", "agent_only": "a"})
opts = resolve_run_options(agent, session_metadata={"shared": "session_value", "session_only": "s"})
assert opts.metadata["shared"] == "session_value"
assert opts.metadata["agent_only"] == "a"
assert opts.metadata["session_only"] == "s"
def test_callsite_beats_session(self):
agent = _make_agent()
opts = resolve_run_options(
agent,
metadata={"shared": "run_value", "run_only": "r"},
session_metadata={"shared": "session_value", "session_only": "s"},
)
assert opts.metadata["shared"] == "run_value"
assert opts.metadata["session_only"] == "s"
assert opts.metadata["run_only"] == "r"
def test_three_layer_merge_non_conflicting_keys(self):
agent = _make_agent(metadata={"agent_only": "a", "shared": "agent_value"})
opts = resolve_run_options(
agent,
metadata={"run_only": "r", "shared": "run_value"},
session_metadata={"session_only": "s"},
)
assert opts.metadata == {
"agent_only": "a",
"session_only": "s",
"run_only": "r",
"shared": "run_value",
}
def test_merge_does_not_mutate_callsite(self):
agent = _make_agent(metadata={"a": 1})
callsite_meta = {"b": 2}
# Includes a nested dict: the recursive in-place merge must never write
# other layers' values into the caller's nested dicts.
agent = _make_agent(metadata={"a": 1, "nested": {"agent": True}})
callsite_meta = {"b": 2, "nested": {"call": True}}
resolve_run_options(agent, metadata=callsite_meta)
assert callsite_meta == {"b": 2}
assert callsite_meta == {"b": 2, "nested": {"call": True}}
def test_merge_does_not_mutate_agent_nested_dicts(self):
# merge_dictionaries recurses in place: a shallow copy of agent.metadata
# would let call-site values contaminate the agent's nested dicts.
agent = _make_agent(metadata={"policy": {"read_only": True}, "flat": "agent_value"})
opts = resolve_run_options(
agent,
metadata={"policy": {"read_only": False}, "flat": "run_value"},
session_metadata={"policy": {"source": "session"}},
)
assert opts.metadata["policy"] == {"read_only": False, "source": "session"}
assert agent.metadata == {"policy": {"read_only": True}, "flat": "agent_value"}
def test_merge_does_not_mutate_session_nested_dicts(self):
agent = _make_agent(metadata={"policy": {"read_only": True}})
session_meta = {"policy": {"source": "session"}}
resolve_run_options(agent, metadata={"policy": {"read_only": False}}, session_metadata=session_meta)
assert session_meta == {"policy": {"source": "session"}}
def test_resolved_metadata_mutation_does_not_reach_agent(self):
agent = _make_agent(metadata={"policy": {"read_only": True}})
opts = resolve_run_options(agent)
opts.metadata["policy"]["read_only"] = False # type: ignore[index]
assert agent.metadata == {"policy": {"read_only": True}}
def test_resolved_metadata_does_not_alias_callsite_nested_dicts(self):
# Every layer is deep-copied, so a later mutation of the resolved metadata
# cannot reach the caller's nested dict (a shared module-level default).
agent = _make_agent()
callsite = {"labels": {"team": "ops"}}
opts = resolve_run_options(agent, metadata=callsite)
opts.metadata["labels"]["team"] = "sre" # type: ignore[index]
assert callsite == {"labels": {"team": "ops"}}
def test_resolved_metadata_does_not_alias_session_nested_dicts(self):
agent = _make_agent()
session_meta = {"labels": {"tenant": "acme"}}
opts = resolve_run_options(agent, session_metadata=session_meta)
opts.metadata["labels"]["tenant"] = "other" # type: ignore[index]
assert session_meta == {"labels": {"tenant": "acme"}}
class TestDependenciesMerge:
@@ -371,3 +511,115 @@ class TestAgentWrappersDelegateCorrectly:
agent.arun(input="hello")
assert captured["called"] is True
assert captured["input"] == "hello"
# ---------------------------------------------------------------------------
# Session metadata precedence on the full run() path
# ---------------------------------------------------------------------------
class TestRunSessionMetadataPrecedence:
def test_session_beats_agent_on_run(self):
db = InMemoryDb()
_seed_session(db, "s1", "agent-1", {"shared": "session_value", "session_only": "s"})
agent = Agent(id="agent-1", model=MockModel(), db=db, metadata={"shared": "agent_value", "agent_only": "a"})
out = agent.run("hi", session_id="s1")
assert out.metadata["shared"] == "session_value"
assert out.metadata["agent_only"] == "a"
assert out.metadata["session_only"] == "s"
def test_callsite_beats_session_and_agent_on_run(self):
db = InMemoryDb()
_seed_session(db, "s1", "agent-1", {"shared": "session_value"})
agent = Agent(id="agent-1", model=MockModel(), db=db, metadata={"shared": "agent_value"})
out = agent.run("hi", session_id="s1", metadata={"shared": "call_value", "run_only": "r"})
assert out.metadata["shared"] == "call_value"
assert out.metadata["run_only"] == "r"
@pytest.mark.asyncio
async def test_arun_sync_db_session_metadata_visible(self):
# arun_dispatch pre-reads the session when the DB is sync
db = InMemoryDb()
_seed_session(db, "s1", "agent-1", {"shared": "session_value"})
agent = Agent(id="agent-1", model=MockModel(), db=db, metadata={"shared": "agent_value"})
out = await agent.arun("hi", session_id="s1", metadata={"run_only": "r"})
assert out.metadata["shared"] == "session_value"
assert out.metadata["run_only"] == "r"
def test_session_beats_agent_stays_stable_across_runs(self):
# The session value must survive persistence: update_metadata merges agent
# defaults as the losing layer, so the session's own value is not clobbered
# in the record and every subsequent run resolves it identically.
db = InMemoryDb()
_seed_session(db, "s1", "agent-1", {"tier": "gold"})
agent = Agent(id="agent-1", model=MockModel(), db=db, metadata={"tier": "standard"})
first = agent.run("hi", session_id="s1").metadata["tier"]
record = db.get_session(session_id="s1").metadata["tier"]
second = agent.run("hi", session_id="s1").metadata["tier"]
assert (first, record, second) == ("gold", "gold", "gold")
def test_continue_run_sees_session_metadata(self):
# continue_run resolves options through the same session_metadata layer as run.
db = InMemoryDb()
_seed_session(db, "s1", "agent-1", {"policy": "capture-only"})
agent = Agent(id="agent-1", model=MockModel(), db=db, metadata={"env": "test"})
run_out = agent.run("hi", session_id="s1")
cont = agent.continue_run(run_id=run_out.run_id, session_id="s1", requirements=[])
assert cont.metadata["policy"] == "capture-only"
assert cont.metadata["env"] == "test"
# ---------------------------------------------------------------------------
# Singleton isolation: runs never mutate the shared Agent instance
# ---------------------------------------------------------------------------
class TestSingletonIsolation:
def test_no_bleed_across_sessions(self):
db = InMemoryDb()
_seed_session(db, "session-a", "agent-1", {"leak": "from_a"})
agent = Agent(id="agent-1", model=MockModel(), db=db, metadata={"env": "test"}, cache_session=True)
out_a = agent.run("hi", session_id="session-a")
assert out_a.metadata["leak"] == "from_a"
assert agent.metadata == {"env": "test"}
assert agent.metadata is not agent._cached_session.metadata
out_b = agent.run("hi", session_id="session-b")
assert "leak" not in (out_b.metadata or {})
assert agent.metadata == {"env": "test"}
assert agent.metadata is not agent._cached_session.metadata
# Session B's persisted record carries agent defaults only, no session A values
session_b = db.get_session(session_id="session-b")
assert session_b.metadata == {"env": "test"}
def test_agent_metadata_not_aliased_to_session(self):
from agno.agent._storage import read_or_create_session, update_metadata
db = InMemoryDb()
_seed_session(db, "session-a", "agent-1", {"k": "v"})
agent = Agent(id="agent-1", model=MockModel(), db=db, metadata={"env": "test"})
session = read_or_create_session(agent, session_id="session-a")
update_metadata(agent, session=session)
assert agent.metadata is not session.metadata
assert agent.metadata == {"env": "test"}
# Session side: agent metadata fills keys the session does not set
assert session.metadata == {"k": "v", "env": "test"}
def test_new_session_seeding_not_aliased(self):
from agno.agent._storage import read_or_create_session
agent = Agent(id="agent-1", model=MockModel(), db=InMemoryDb(), metadata={"env": "test"})
session = read_or_create_session(agent, session_id="fresh")
assert session.metadata == {"env": "test"}
assert session.metadata is not agent.metadata
@pytest.mark.asyncio
async def test_new_session_seeding_not_aliased_async(self):
from agno.agent._storage import aread_or_create_session
agent = Agent(id="agent-1", model=MockModel(), db=InMemoryDb(), metadata={"env": "test"})
session = await aread_or_create_session(agent, session_id="fresh")
assert session.metadata == {"env": "test"}
assert session.metadata is not agent.metadata
@@ -164,6 +164,30 @@ def test_calendar_write_mode_creates_update_tool():
assert "update_calendar" in tool_names
def test_calendar_write_tools_replace_default_toolkit():
from agno.tools import tool
from agno.tools.google.calendar import GoogleCalendarTools
@tool(name="book_meeting_room")
def _book_meeting_room(room: str) -> str:
return "ok"
provider = GoogleCalendarContextProvider(write=True, write_tools=[_book_meeting_room])
agent = provider._ensure_write_agent()
assert agent.tools is not None
assert len(agent.tools) == 1
assert agent.tools[0] is _book_meeting_room
assert not any(isinstance(t, GoogleCalendarTools) for t in agent.tools)
def test_calendar_default_write_agent_keeps_calendar_toolkit():
from agno.tools.google.calendar import GoogleCalendarTools
provider = GoogleCalendarContextProvider(write=True)
agent = provider._ensure_write_agent()
assert any(isinstance(t, GoogleCalendarTools) for t in agent.tools or [])
# ============================================================================
# CALENDAR TOOLKIT TIMEOUT TESTS
# ============================================================================
@@ -8,7 +8,9 @@ catch before reaching the calling agent.
from __future__ import annotations
import asyncio
import json
import time
import pytest
@@ -957,3 +959,171 @@ async def test_streaming_unicode():
result += chunk.content or ""
assert result == content
# ---------------------------------------------------------------------------
# query_timeout — one wall-clock deadline over the query tool
# ---------------------------------------------------------------------------
def test_query_timeout_defaults_to_none():
p = _EchoProvider(id="e")
assert p.query_timeout is None
def test_query_timeout_requires_python_311(monkeypatch):
monkeypatch.setattr("sys.version_info", (3, 10, 9))
with pytest.raises(RuntimeError, match="query_timeout requires Python 3.11"):
_EchoProvider(id="e", query_timeout=1.0)
def test_query_timeout_unset_skips_python_guard(monkeypatch):
# The default path must keep working on older interpreters.
monkeypatch.setattr("sys.version_info", (3, 10, 9))
_EchoProvider(id="e")
@pytest.mark.parametrize("bad", [0, 0.0, -1, -5.5])
def test_query_timeout_must_be_positive(bad):
# A zero/negative deadline is already expired: reject it at construction
# instead of producing load-dependent "timed out after 0s" errors at runtime.
with pytest.raises(ValueError, match="query_timeout must be positive"):
_EchoProvider(id="e", query_timeout=bad)
@pytest.mark.asyncio
async def test_query_timeout_none_leaves_slow_query_unbounded():
class _SlowButFine(_EchoProvider):
async def aquery(self, question: str, *, run_context: RunContext | None = None) -> Answer:
await asyncio.sleep(0.05)
return Answer(text="slow answer")
p = _SlowButFine(id="e")
out = await _collect_tool_output(p._query_tool(), question="q")
assert json.loads(out) == {"text": "slow answer"}
@pytest.mark.asyncio
async def test_query_timeout_trips_on_hanging_aquery():
class _Hanging(_EchoProvider):
async def aquery(self, question: str, *, run_context: RunContext | None = None) -> Answer:
await asyncio.sleep(5)
return Answer(text="never")
p = _Hanging(id="e", query_timeout=0.1)
started = time.monotonic()
out = await _collect_tool_output(p._query_tool(), question="q")
assert time.monotonic() - started < 0.5
assert json.loads(out) == {"error": "e timed out after 0.1s"}
@pytest.mark.asyncio
async def test_query_timeout_trips_on_agent_acquisition_hang():
"""A dead session during _aget_query_agent (the MCP _ensure_session case)
must be under the same deadline as the query itself."""
class _HangingAcquisition(_EchoProvider):
async def _aget_query_agent(self, run_context):
await asyncio.sleep(5)
p = _HangingAcquisition(id="mcpish", query_timeout=0.1)
started = time.monotonic()
out = await _collect_tool_output(p._query_tool(), question="q")
assert time.monotonic() - started < 0.5
assert json.loads(out) == {"error": "mcpish timed out after 0.1s"}
@pytest.mark.asyncio
async def test_query_timeout_trips_on_inter_chunk_stall():
from agno.run.agent import RunOutput, ToolCallStartedEvent
class _Stalling(_EchoProvider):
async def _aget_query_agent(self, run_context):
class _FakeAgent:
async def arun(self, message, **kwargs):
yield ToolCallStartedEvent()
await asyncio.sleep(5)
yield RunOutput(content="never")
return _FakeAgent()
p = _Stalling(id="s", query_timeout=0.2)
gen = await p._query_tool().entrypoint(question="q")
events = []
final = None
started = time.monotonic()
async def _drain():
nonlocal final
async for chunk in gen:
if isinstance(chunk, str):
final = chunk
else:
events.append(chunk)
# Bounded so a per-chunk idle-timeout regression fails instead of hanging.
await asyncio.wait_for(_drain(), timeout=5)
assert time.monotonic() - started < 1.0
assert len(events) == 1
assert json.loads(final) == {"error": "s timed out after 0.2s"}
@pytest.mark.asyncio
async def test_query_timeout_is_wall_clock_not_inter_chunk_idle():
from agno.run.agent import ToolCallStartedEvent
class _SteadyStream(_EchoProvider):
async def _aget_query_agent(self, run_context):
class _FakeAgent:
async def arun(self, message, **kwargs):
while True:
await asyncio.sleep(0.02)
yield ToolCallStartedEvent()
return _FakeAgent()
p = _SteadyStream(id="s", query_timeout=0.2)
gen = await p._query_tool().entrypoint(question="q")
events = []
final = None
started = time.monotonic()
async def _drain():
nonlocal final
async for chunk in gen:
if isinstance(chunk, str):
final = chunk
else:
events.append(chunk)
# Bounded so a per-chunk idle-timeout regression fails instead of hanging:
# the stream yields forever and only a wall-clock deadline can end it.
await asyncio.wait_for(_drain(), timeout=5)
# Chunks land every 0.02s, so a per-chunk idle timeout of 0.2s would
# never trip — only a total wall-clock deadline ends this stream.
assert time.monotonic() - started < 1.0
assert len(events) >= 2
assert json.loads(final) == {"error": "s timed out after 0.2s"}
@pytest.mark.asyncio
async def test_provider_raised_timeout_error_is_not_reported_as_expiry():
class _InnerTimeout(_EchoProvider):
async def aquery(self, question: str, *, run_context: RunContext | None = None) -> Answer:
raise TimeoutError("upstream limit hit")
p = _InnerTimeout(id="e", query_timeout=5)
out = await _collect_tool_output(p._query_tool(), question="q")
assert json.loads(out) == {"error": "TimeoutError: upstream limit hit"}
@pytest.mark.asyncio
async def test_query_timeout_does_not_bound_update_tool():
class _SlowWriter(_EchoProvider):
async def aupdate(self, instruction: str, *, run_context: RunContext | None = None) -> Answer:
await asyncio.sleep(0.2)
return Answer(text="wrote")
p = _SlowWriter(id="w", query_timeout=0.05)
out = await _collect_update_output(p._update_tool(), instruction="add")
assert json.loads(out) == {"text": "wrote"}
@@ -7,6 +7,7 @@ The full end-to-end behaviour is covered by the cookbooks.
from __future__ import annotations
import asyncio
import json
from pathlib import Path
@@ -449,6 +450,41 @@ def test_db_read_false_drops_query_tool():
assert [t.name for t in p.get_tools()] == ["update_crm"]
def test_db_write_tools_replace_default_toolset():
from agno.tools import tool
from agno.tools.sql import SQLTools
@tool(name="create_crm_record")
def _create_crm_record(payload: str) -> str:
return "ok"
engine = create_engine("sqlite:///:memory:")
p = DatabaseContextProvider(
id="crm",
sql_engine=engine,
readonly_engine=engine,
write_tools=[_create_crm_record],
)
agent = p._ensure_write_agent()
assert agent.tools is not None
assert len(agent.tools) == 1
assert agent.tools[0] is _create_crm_record
assert not any(isinstance(t, SQLTools) for t in agent.tools)
def test_db_default_write_agent_keeps_sqltools():
from agno.tools.sql import SQLTools
engine = create_engine("sqlite:///:memory:")
p = DatabaseContextProvider(
id="crm",
sql_engine=engine,
readonly_engine=engine,
)
agent = p._ensure_write_agent()
assert any(isinstance(t, SQLTools) for t in agent.tools or [])
# ---------------------------------------------------------------------------
# Slack
# ---------------------------------------------------------------------------
@@ -498,6 +534,30 @@ def test_slack_status_reports_configured():
assert "token configured" in status.detail
def test_slack_write_tools_replace_default_toolset():
from agno.tools import tool
from agno.tools.slack import SlackTools
@tool(name="post_release_note")
def _post_release_note(text: str) -> str:
return "ok"
p = SlackContextProvider(token="xoxb-x", write_tools=[_post_release_note])
agent = p._ensure_write_agent()
assert agent.tools is not None
assert len(agent.tools) == 1
assert agent.tools[0] is _post_release_note
assert not any(isinstance(t, SlackTools) for t in agent.tools)
def test_slack_default_write_agent_keeps_slack_tools():
from agno.tools.slack import SlackTools
p = SlackContextProvider(token="xoxb-x")
agent = p._ensure_write_agent()
assert any(isinstance(t, SlackTools) for t in agent.tools or [])
def test_slack_read_surfaces_are_split_by_mode():
p = SlackContextProvider(token="xoxb-x")
bot_tools = p._ensure_bot_read_tools()
@@ -791,6 +851,33 @@ def test_gmail_write_enabled_adds_update_tool(monkeypatch):
assert [t.name for t in tools] == ["query_gmail", "update_gmail"]
def test_gmail_write_tools_replace_default_toolkit(monkeypatch):
from agno.tools import tool
from agno.tools.google.gmail import GmailTools
monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_FILE", raising=False)
@tool(name="create_draft_only")
def _create_draft_only(subject: str) -> str:
return "ok"
p = GmailContextProvider(write=True, write_tools=[_create_draft_only])
agent = p._ensure_write_agent()
assert agent.tools is not None
assert len(agent.tools) == 1
assert agent.tools[0] is _create_draft_only
assert not any(isinstance(t, GmailTools) for t in agent.tools)
def test_gmail_default_write_agent_keeps_gmail_toolkit(monkeypatch):
from agno.tools.google.gmail import GmailTools
monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_FILE", raising=False)
p = GmailContextProvider(write=True)
agent = p._ensure_write_agent()
assert any(isinstance(t, GmailTools) for t in agent.tools or [])
# ---------------------------------------------------------------------------
# Calendar
# ---------------------------------------------------------------------------
@@ -1020,3 +1107,68 @@ async def test_mcp_aclose_noop_when_never_connected():
p = MCPContextProvider("srv", transport="streamable-http", url="https://example.com/mcp")
# Must not raise even though asetup was never called.
await p.aclose()
@pytest.mark.asyncio
async def test_mcp_ensure_session_resets_on_cancelled_connect():
"""A query_timeout deadline cancels _connect mid-handshake (CancelledError).
_ensure_session must clear the partial toolkit so the next query reconnects
fresh, instead of caching a half-connected MCPTools that wedges the provider."""
p = MCPContextProvider("srv", transport="streamable-http", url="https://example.com/mcp")
class _PartialTools:
initialized = False
async def _connect(self):
raise asyncio.CancelledError()
p._tools = _PartialTools() # type: ignore[assignment]
p._tool_descriptions = [("stale", "d", "a")]
with pytest.raises(asyncio.CancelledError):
await p._ensure_session()
assert p._tools is None
assert p._tool_descriptions == []
# ---------------------------------------------------------------------------
# query_timeout plumbing — every concrete provider forwards the knob to the
# base class, where the query-tool deadline reads it.
# ---------------------------------------------------------------------------
def test_query_timeout_reaches_base_from_every_provider(tmp_path: Path, monkeypatch):
monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_FILE", raising=False)
engine = create_engine("sqlite:///:memory:")
providers = [
FilesystemContextProvider(root=tmp_path, query_timeout=2.5),
WorkspaceContextProvider(root=tmp_path, query_timeout=2.5),
WebContextProvider(backend=ExaBackend(api_key="x"), query_timeout=2.5),
DatabaseContextProvider(sql_engine=engine, readonly_engine=engine, query_timeout=2.5),
SlackContextProvider(token="xoxb-x", query_timeout=2.5),
GmailContextProvider(query_timeout=2.5),
GoogleCalendarContextProvider(query_timeout=2.5),
GoogleDriveContextProvider(query_timeout=2.5),
MCPContextProvider("srv", transport="streamable-http", url="https://example.com/mcp", query_timeout=2.5),
]
for p in providers:
assert p.query_timeout == 2.5, type(p).__name__
def test_query_timeout_defaults_to_none_per_provider(tmp_path: Path, monkeypatch):
monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_FILE", raising=False)
engine = create_engine("sqlite:///:memory:")
providers = [
FilesystemContextProvider(root=tmp_path),
WorkspaceContextProvider(root=tmp_path),
WebContextProvider(backend=ExaBackend(api_key="x")),
DatabaseContextProvider(sql_engine=engine, readonly_engine=engine),
SlackContextProvider(token="xoxb-x"),
GmailContextProvider(),
GoogleCalendarContextProvider(),
GoogleDriveContextProvider(),
MCPContextProvider("srv", transport="streamable-http", url="https://example.com/mcp"),
]
for p in providers:
assert p.query_timeout is None, type(p).__name__
@@ -0,0 +1,341 @@
"""Credential-handling tests for GitBackend.
The PAT must never reach disk (`.git/config`) or argv: auth is injected
per remote-facing git call through an ephemeral credential helper in the
subprocess environment (`GitBackend._git_env`). Tests here run against a
local bare repo over `file://` (offline, no real PAT) plus direct
assertions on the injected environment. `file://` transports never
consult credential helpers, so the round-trip test proves push works
with a bare origin, while the env/helper tests prove the injection
contents.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from agno.context.wiki import GitBackend
from agno.context.wiki.backend import _GIT_CREDENTIAL_HELPER
from agno.context.wiki.git_ops import GitResult
requires_git = pytest.mark.skipif(shutil.which("git") is None, reason="git binary required")
TOKEN = "ghp_supersecret"
def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
)
@pytest.fixture(autouse=True)
def _isolated_git_config(tmp_path_factory, monkeypatch):
# Keep the developer's global/system git config (gpg signing, hook
# templates, stored credential helpers) out of the subprocesses.
cfg = tmp_path_factory.mktemp("gitcfg") / "gitconfig"
cfg.write_text("")
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(cfg))
monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull)
@pytest.fixture()
def bare_remote(tmp_path: Path) -> Path:
"""Local bare repo with one commit on `main`, reachable over file://."""
seed = tmp_path / "seed"
seed.mkdir()
_git(["init", "--initial-branch=main"], seed)
_git(["config", "user.name", "Seed"], seed)
_git(["config", "user.email", "[email protected]"], seed)
(seed / "readme.md").write_text("# wiki\n")
_git(["add", "-A"], seed)
_git(["commit", "-m", "seed"], seed)
remote = tmp_path / "remote.git"
_git(["clone", "--bare", str(seed), str(remote)], tmp_path)
return remote
# ---------------------------------------------------------------------------
# _git_env / credential helper contents
# ---------------------------------------------------------------------------
def test_git_env_contents(tmp_path: Path):
b = GitBackend(
repo_url="https://github.com/owner/repo.git",
github_token=TOKEN,
local_path=tmp_path / "clone",
)
env = b._git_env()
assert env["GIT_CONFIG_COUNT"] == "5"
# Entry 0 resets any system/global helper so a stored identity
# (e.g. osxkeychain) cannot answer instead of the injected token.
assert env["GIT_CONFIG_KEY_0"] == "credential.helper"
assert env["GIT_CONFIG_VALUE_0"] == ""
assert env["GIT_CONFIG_KEY_1"] == "credential.helper"
helper = env["GIT_CONFIG_VALUE_1"]
assert "username=x-access-token" in helper
assert "password=%s" in helper
assert "$AGNO_GIT_TOKEN" in helper
assert TOKEN not in helper
assert env["GIT_CONFIG_KEY_2"] == "credential.useHttpPath"
assert env["GIT_CONFIG_VALUE_2"] == "true"
# Entries 3-4 pin repo_url to itself so a caller url.<prefix>.insteadOf
# cannot reroute the operation off the credential-injected transport.
assert env["GIT_CONFIG_KEY_3"] == "url.https://github.com/owner/repo.git.insteadOf"
assert env["GIT_CONFIG_VALUE_3"] == "https://github.com/owner/repo.git"
assert env["GIT_CONFIG_KEY_4"] == "url.https://github.com/owner/repo.git.pushInsteadOf"
assert env["GIT_CONFIG_VALUE_4"] == "https://github.com/owner/repo.git"
assert env["AGNO_GIT_TOKEN"] == TOKEN
assert env["GIT_TERMINAL_PROMPT"] == "0"
def test_credential_helper_emits_username_and_password():
# Execute the helper the way git does: shell fragment with the
# credential action appended as $1.
script = _GIT_CREDENTIAL_HELPER.lstrip("!") + ' "$@"'
out = subprocess.run(
["sh", "-c", script, "agno-cred-helper", "get"],
env={**os.environ, "AGNO_GIT_TOKEN": TOKEN},
input="protocol=https\nhost=github.com\n\n",
capture_output=True,
text=True,
)
assert out.returncode == 0
assert out.stdout.splitlines() == [
"username=x-access-token",
f"password={TOKEN}",
]
def test_credential_helper_is_silent_and_zero_for_store():
script = _GIT_CREDENTIAL_HELPER.lstrip("!") + ' "$@"'
out = subprocess.run(
["sh", "-c", script, "agno-cred-helper", "store"],
env={**os.environ, "AGNO_GIT_TOKEN": TOKEN},
input="protocol=https\nhost=github.com\n\n",
capture_output=True,
text=True,
)
assert out.returncode == 0
assert out.stdout == ""
def test_git_backend_accepts_file_remote(tmp_path: Path):
b = GitBackend(
repo_url="file:///somewhere/wiki.git",
github_token=TOKEN,
local_path=tmp_path / "clone",
)
# No https form to scrub; the token itself is still registered.
assert b._authenticated_url is None
assert TOKEN in b._scrubber.secrets
# ---------------------------------------------------------------------------
# env forwarding at the remote-facing call sites (stubbed git)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_remote_git_calls_inject_env_and_bare_url(monkeypatch, tmp_path: Path):
import agno.context.wiki.backend as backend_module
b = GitBackend(
repo_url="https://github.com/owner/repo.git",
github_token=TOKEN,
local_path=tmp_path / "clone",
)
calls: list[tuple[list[str], dict | None]] = []
async def _fake_run(args, *, cwd, scrubber=None, check=True, env=None, **kwargs): # noqa: ANN001
calls.append((list(args), env))
return GitResult(returncode=0, stdout="", stderr="")
monkeypatch.setattr(backend_module, "git_run", _fake_run)
await b.setup() # fresh path -> clone
await b.sync() # pull --rebase
await b.commit_after_write() # nothing staged -> idle push
# The token must never ride on argv.
for args, _ in calls:
assert all(TOKEN not in a for a in args)
remote_calls = [(args, env) for args, env in calls if args[0] in {"clone", "pull", "push"}]
assert {args[0] for args, _ in remote_calls} == {"clone", "pull", "push"}
for args, env in remote_calls:
assert env is not None, f"remote op {args} missing credential env"
assert env["AGNO_GIT_TOKEN"] == TOKEN
assert env["GIT_CONFIG_COUNT"] == "5"
clone_args = next(args for args, _ in calls if args[0] == "clone")
assert "https://github.com/owner/repo.git" in clone_args
set_url_calls = [args for args, _ in calls if args[:3] == ["remote", "set-url", "origin"]]
assert set_url_calls, "clone path must normalise origin to the bare URL"
assert all(args[3] == "https://github.com/owner/repo.git" for args in set_url_calls)
@pytest.mark.asyncio
async def test_staged_commit_pull_and_push_inject_env_and_bare_origin(monkeypatch, tmp_path: Path):
# Companion to the test above: there `diff --cached --quiet` reports
# nothing staged, so only the idle-push branch runs. Here the diff
# check returns 1 (changes staged) so the commit -> rebase -> push
# sequence runs, and the staged-path pull/push must carry the
# credential env and address the bare `origin` remote.
import agno.context.wiki.backend as backend_module
b = GitBackend(
repo_url="https://github.com/owner/repo.git",
github_token=TOKEN,
local_path=tmp_path / "clone",
)
sha = "0123456789abcdef0123456789abcdef01234567"
stat = " notes.md | 2 ++\n 1 file changed, 2 insertions(+)\n"
calls: list[tuple[list[str], dict | None]] = []
async def _fake_run(args, *, cwd, scrubber=None, check=True, env=None, **kwargs): # noqa: ANN001
args = list(args)
calls.append((args, env))
if args[:3] == ["diff", "--cached", "--quiet"]:
return GitResult(returncode=1, stdout="", stderr="")
if args[:3] == ["diff", "--cached", "--stat"]:
return GitResult(returncode=0, stdout=stat, stderr="")
if args[:2] == ["rev-parse", "HEAD"]:
return GitResult(returncode=0, stdout=sha + "\n", stderr="")
return GitResult(returncode=0, stdout="", stderr="")
monkeypatch.setattr(backend_module, "git_run", _fake_run)
summary = await b.commit_after_write(model=None)
assert summary is not None
assert summary.sha == sha
assert summary.files_changed == 1
# The token must never ride on argv.
for args, _ in calls:
assert all(TOKEN not in a for a in args)
pull_calls = [(args, env) for args, env in calls if args[0] == "pull"]
push_calls = [(args, env) for args, env in calls if args[0] == "push"]
assert [args for args, _ in pull_calls] == [["pull", "--rebase", "origin", "main"]]
assert [args for args, _ in push_calls] == [["push", "origin", "main"]]
for args, env in pull_calls + push_calls:
assert env is not None, f"remote op {args} missing credential env"
assert env["AGNO_GIT_TOKEN"] == TOKEN
assert env["GIT_CONFIG_COUNT"] == "5"
assert env["GIT_CONFIG_VALUE_1"] == _GIT_CREDENTIAL_HELPER
first_args = [args[0] for args, _ in calls]
assert first_args.index("commit") < first_args.index("pull") < first_args.index("push")
# ---------------------------------------------------------------------------
# real git against a local bare remote
# ---------------------------------------------------------------------------
@requires_git
@pytest.mark.asyncio
async def test_setup_leaves_no_token_on_disk(bare_remote: Path, tmp_path: Path):
b = GitBackend(
repo_url=bare_remote.as_uri(),
github_token=TOKEN,
local_path=tmp_path / "clone",
)
await b.setup()
config_text = (b.path / ".git" / "config").read_text()
assert TOKEN not in config_text
assert "x-access-token" not in config_text
origin = _git(["remote", "get-url", "origin"], b.path).stdout.strip()
assert origin == b.repo_url
@requires_git
@pytest.mark.asyncio
async def test_commit_after_write_pushes_with_bare_origin(bare_remote: Path, tmp_path: Path):
b = GitBackend(
repo_url=bare_remote.as_uri(),
github_token=TOKEN,
local_path=tmp_path / "clone",
)
await b.setup()
before = _git(["rev-parse", "main"], bare_remote).stdout.strip()
(b.path / "notes.md").write_text("# Notes\n\nadded by test\n")
summary = await b.commit_after_write(model=None)
assert summary is not None
assert summary.files_changed == 1
after = _git(["rev-parse", "main"], bare_remote).stdout.strip()
assert after != before
assert after == summary.sha
config_text = (b.path / ".git" / "config").read_text()
assert TOKEN not in config_text
@requires_git
@pytest.mark.asyncio
async def test_setup_migrates_token_bearing_origin(bare_remote: Path, tmp_path: Path):
b = GitBackend(
repo_url=bare_remote.as_uri(),
github_token=TOKEN,
local_path=tmp_path / "clone",
)
await b.setup()
# Simulate a clone left behind by an older version that persisted
# the credential-bearing URL in .git/config.
tainted = f"file://x-access-token:{TOKEN}@{bare_remote}"
_git(["remote", "set-url", "origin", tainted], b.path)
assert TOKEN in (b.path / ".git" / "config").read_text()
await b.setup()
config_text = (b.path / ".git" / "config").read_text()
assert TOKEN not in config_text
assert "x-access-token" not in config_text
origin = _git(["remote", "get-url", "origin"], b.path).stdout.strip()
assert origin == b.repo_url
@requires_git
@pytest.mark.asyncio
async def test_setup_defeats_hostile_insteadof_rewrite(bare_remote: Path, tmp_path: Path, monkeypatch):
# A caller `url.<base>.insteadOf` whose prefix matches the bare repo_url
# (e.g. the common `url."[email protected]:".insteadOf = https://github.com/`)
# would reroute clone/pull/push off the credential-injected transport. The
# identity pin in _git_env is a longer prefix match, so remote ops stay on
# repo_url. Without the pin, this rewrite sends the clone to a dead decoy.
hostile = tmp_path / "hostile_gitconfig"
hostile.write_text('[url "file:///nonexistent-decoy/"]\n\tinsteadOf = file://\n')
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(hostile))
b = GitBackend(
repo_url=bare_remote.as_uri(),
github_token=TOKEN,
local_path=tmp_path / "clone",
)
await b.setup()
assert (b.path / "readme.md").exists()
# A second setup() must revalidate cleanly: `remote get-url` would return the
# rewritten decoy URL, but _validate_existing_clone reads the raw config value.
await b.setup()
assert "nonexistent-decoy" not in (b.path / ".git" / "config").read_text()
raw_origin = _git(["config", "--get", "remote.origin.url"], b.path).stdout.strip()
assert raw_origin == b.repo_url
@@ -228,6 +228,12 @@ def test_provider_both_flags_false_raises(tmp_path: Path):
WikiContextProvider(backend=FileSystemBackend(path=tmp_path), read=False, write=False)
def test_provider_query_timeout_plumbs_to_base(tmp_path: Path):
p = WikiContextProvider(backend=FileSystemBackend(path=tmp_path), query_timeout=2.5)
assert p.query_timeout == 2.5
assert WikiContextProvider(backend=FileSystemBackend(path=tmp_path)).query_timeout is None
def test_provider_instructions_omit_update_when_write_false(tmp_path: Path):
p = WikiContextProvider(backend=FileSystemBackend(path=tmp_path), id="voice", write=False)
text = p.instructions()
@@ -860,6 +866,71 @@ async def test_web_backend_tools_attached_to_write_sub_agent(tmp_path: Path):
assert any(getattr(t, "name", "") == "workspace" for t in write_agent.tools or [])
def test_write_tools_replace_default_workspace(tmp_path: Path):
from agno.tools import tool
from agno.tools.workspace import Workspace
@tool(name="kb_write")
def _kb_write(path: str, content: str) -> str:
return "ok"
p = WikiContextProvider(backend=FileSystemBackend(path=tmp_path), write_tools=[_kb_write])
agent = p._ensure_write_agent()
assert agent.tools is not None
assert len(agent.tools) == 1
assert agent.tools[0] is _kb_write
assert not any(isinstance(t, Workspace) for t in agent.tools)
def test_write_tools_and_web_backend_both_attached(tmp_path: Path):
from agno.context.backend import ContextBackend
from agno.context.provider import Status as _Status
from agno.tools import tool
@tool(name="kb_write")
def _kb_write(path: str, content: str) -> str:
return "ok"
@tool(name="web_search")
async def _web_search(query: str) -> str:
return "{}"
class _StubWeb(ContextBackend):
def status(self) -> _Status:
return _Status(ok=True, detail="stub")
async def astatus(self) -> _Status:
return self.status()
def get_tools(self) -> list:
return [_web_search]
injected = [_kb_write]
p = WikiContextProvider(backend=FileSystemBackend(path=tmp_path), write_tools=injected, web=_StubWeb())
agent = p._ensure_write_agent()
names = [getattr(t, "name", None) for t in agent.tools or []]
assert "kb_write" in names
assert "web_search" in names
# Appending web tools must not mutate the caller's list.
assert len(injected) == 1
assert injected[0] is _kb_write
def test_default_write_agent_uses_read_write_workspace(tmp_path: Path):
from agno.tools.workspace import Workspace
p = WikiContextProvider(backend=FileSystemBackend(path=tmp_path))
agent = p._ensure_write_agent()
ws = next(t for t in agent.tools or [] if isinstance(t, Workspace))
assert sorted(ws.functions.keys()) == [
"edit_file",
"list_files",
"read_file",
"search_content",
"write_file",
]
@pytest.mark.asyncio
async def test_web_backend_not_attached_to_read_sub_agent(tmp_path: Path):
"""The read sub-agent stays scoped to the wiki even with web wired."""
@@ -64,3 +64,42 @@ async def test_get_or_create_table_creates_when_not_available_and_create_flag_se
assert result == mock_table
async_postgres_db._create_table.assert_called_once_with(table_name="test_table", table_type="sessions")
@pytest.mark.asyncio
async def test_get_schedules_default_swallows_error(async_postgres_db, monkeypatch):
"""Test that get_schedules returns ([], 0) on DB failure by default"""
async def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(async_postgres_db, "_get_table", _boom)
assert await async_postgres_db.get_schedules() == ([], 0)
@pytest.mark.asyncio
async def test_get_schedules_raise_on_error_reraises(async_postgres_db, monkeypatch):
"""Test that get_schedules(raise_on_error=True) re-raises DB failures"""
async def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(async_postgres_db, "_get_table", _boom)
with pytest.raises(RuntimeError, match="forced failure"):
await async_postgres_db.get_schedules(raise_on_error=True)
@pytest.mark.asyncio
async def test_get_schedules_table_none_raises_under_flag(async_postgres_db, monkeypatch):
"""Test that an unavailable schedules table raises under raise_on_error=True"""
async def _none(table_type, create_table_if_not_found=False):
return None
monkeypatch.setattr(async_postgres_db, "_get_table", _none)
assert await async_postgres_db.get_schedules() == ([], 0)
with pytest.raises(RuntimeError, match="schedules table unavailable"):
await async_postgres_db.get_schedules(raise_on_error=True)
@@ -107,6 +107,34 @@ def test_mongo_get_schedules_returns_paginated_items_and_total(monkeypatch: pyte
collection.count_documents.assert_called_once_with({"enabled": True})
def test_mongo_get_schedules_default_swallows_error(monkeypatch: pytest.MonkeyPatch):
db = MongoDb(db_client=MagicMock(), db_name="test_db") # type: ignore[arg-type]
collection = Mock()
collection.count_documents.side_effect = RuntimeError("forced failure")
monkeypatch.setattr(db, "_get_collection", lambda table_type: collection)
assert db.get_schedules() == ([], 0)
def test_mongo_get_schedules_raise_on_error_reraises(monkeypatch: pytest.MonkeyPatch):
db = MongoDb(db_client=MagicMock(), db_name="test_db") # type: ignore[arg-type]
collection = Mock()
collection.count_documents.side_effect = RuntimeError("forced failure")
monkeypatch.setattr(db, "_get_collection", lambda table_type: collection)
with pytest.raises(RuntimeError, match="forced failure"):
db.get_schedules(raise_on_error=True)
def test_mongo_get_schedules_collection_none_raises_under_flag(monkeypatch: pytest.MonkeyPatch):
db = MongoDb(db_client=MagicMock(), db_name="test_db") # type: ignore[arg-type]
monkeypatch.setattr(db, "_get_collection", lambda table_type: None)
assert db.get_schedules() == ([], 0)
with pytest.raises(RuntimeError, match="schedules table unavailable"):
db.get_schedules(raise_on_error=True)
def test_mongo_create_schedule_inserts_and_returns_data(monkeypatch: pytest.MonkeyPatch):
db = MongoDb(db_client=MagicMock(), db_name="test_db") # type: ignore[arg-type]
collection = Mock()
@@ -401,6 +429,37 @@ async def test_async_mongo_get_schedules_returns_paginated_items_and_total(monke
]
@pytest.mark.asyncio
async def test_async_mongo_get_schedules_default_swallows_error(monkeypatch: pytest.MonkeyPatch):
db = AsyncMongoDb(db_url="mongodb://localhost:27017", db_name="test_db")
collection = MagicMock()
collection.count_documents = AsyncMock(side_effect=RuntimeError("forced failure"))
monkeypatch.setattr(db, "_get_collection", AsyncMock(return_value=collection))
assert await db.get_schedules() == ([], 0)
@pytest.mark.asyncio
async def test_async_mongo_get_schedules_raise_on_error_reraises(monkeypatch: pytest.MonkeyPatch):
db = AsyncMongoDb(db_url="mongodb://localhost:27017", db_name="test_db")
collection = MagicMock()
collection.count_documents = AsyncMock(side_effect=RuntimeError("forced failure"))
monkeypatch.setattr(db, "_get_collection", AsyncMock(return_value=collection))
with pytest.raises(RuntimeError, match="forced failure"):
await db.get_schedules(raise_on_error=True)
@pytest.mark.asyncio
async def test_async_mongo_get_schedules_collection_none_raises_under_flag(monkeypatch: pytest.MonkeyPatch):
db = AsyncMongoDb(db_url="mongodb://localhost:27017", db_name="test_db")
monkeypatch.setattr(db, "_get_collection", AsyncMock(return_value=None))
assert await db.get_schedules() == ([], 0)
with pytest.raises(RuntimeError, match="schedules table unavailable"):
await db.get_schedules(raise_on_error=True)
@pytest.mark.asyncio
async def test_async_mongo_create_schedule_inserts_and_returns_data(monkeypatch: pytest.MonkeyPatch):
db = AsyncMongoDb(db_url="mongodb://localhost:27017", db_name="test_db")
+32
View File
@@ -465,3 +465,35 @@ def test_get_table_schema_definition_invalid():
"""Test getting schema for invalid table type"""
with pytest.raises(ValueError, match="Unknown table type"):
get_table_schema_definition("invalid_table")
def test_get_schedules_default_swallows_error(postgres_db, monkeypatch):
"""Test that get_schedules returns ([], 0) on DB failure by default"""
def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(postgres_db, "_get_table", _boom)
assert postgres_db.get_schedules() == ([], 0)
def test_get_schedules_raise_on_error_reraises(postgres_db, monkeypatch):
"""Test that get_schedules(raise_on_error=True) re-raises DB failures"""
def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(postgres_db, "_get_table", _boom)
with pytest.raises(RuntimeError, match="forced failure"):
postgres_db.get_schedules(raise_on_error=True)
def test_get_schedules_table_none_raises_under_flag(postgres_db, monkeypatch):
"""Test that an unavailable schedules table raises under raise_on_error=True"""
monkeypatch.setattr(postgres_db, "_get_table", lambda table_type, create_table_if_not_found=False: None)
assert postgres_db.get_schedules() == ([], 0)
with pytest.raises(RuntimeError, match="schedules table unavailable"):
postgres_db.get_schedules(raise_on_error=True)
@@ -0,0 +1,198 @@
"""Scheduler-table tests for SqliteDb and AsyncSqliteDb get_schedules."""
import time
from uuid import uuid4
import pytest
from agno.db.sqlite import AsyncSqliteDb, SqliteDb
def _schedule_data(**overrides):
now = int(time.time())
d = {
"id": str(uuid4()),
"name": f"schedule-{uuid4()}",
"description": None,
"method": "POST",
"endpoint": "/agents/a1/runs",
"payload": None,
"cron_expr": "0 9 * * *",
"timezone": "UTC",
"timeout_seconds": 3600,
"max_retries": 0,
"retry_delay_seconds": 60,
"enabled": True,
"next_run_at": now + 3600,
"locked_by": None,
"locked_at": None,
"created_at": now,
"updated_at": None,
}
d.update(overrides)
return d
@pytest.fixture
def sqlite_db(tmp_path):
return SqliteDb(db_file=str(tmp_path / "test.db"))
@pytest.fixture
def async_sqlite_db(tmp_path):
return AsyncSqliteDb(db_file=str(tmp_path / "test_async.db"))
# =============================================================================
# Sync SqliteDb
# =============================================================================
def test_sqlite_get_schedules_roundtrip_with_raise_on_error(sqlite_db):
created = sqlite_db.create_schedule(_schedule_data(name="nightly"))
schedules, total = sqlite_db.get_schedules(raise_on_error=True)
assert total == 1
assert schedules[0]["id"] == created["id"]
def test_sqlite_get_schedules_default_swallows_error(sqlite_db, monkeypatch):
def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(sqlite_db, "_get_table", _boom)
assert sqlite_db.get_schedules() == ([], 0)
def test_sqlite_get_schedules_raise_on_error_reraises(sqlite_db, monkeypatch):
def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(sqlite_db, "_get_table", _boom)
with pytest.raises(RuntimeError, match="forced failure"):
sqlite_db.get_schedules(raise_on_error=True)
def test_sqlite_get_schedules_table_none_default_returns_empty(sqlite_db, monkeypatch):
monkeypatch.setattr(sqlite_db, "_get_table", lambda table_type, create_table_if_not_found=False: None)
assert sqlite_db.get_schedules() == ([], 0)
def test_sqlite_get_schedules_table_none_raises_under_flag(sqlite_db, monkeypatch):
monkeypatch.setattr(sqlite_db, "_get_table", lambda table_type, create_table_if_not_found=False: None)
with pytest.raises(RuntimeError, match="schedules table unavailable"):
sqlite_db.get_schedules(raise_on_error=True)
def test_sqlite_get_schedules_never_created_table_raises_under_flag(sqlite_db):
# A fresh DB without the schedules table counts as unavailable for strict callers
with pytest.raises(RuntimeError, match="schedules table unavailable"):
sqlite_db.get_schedules(raise_on_error=True)
# =============================================================================
# Async AsyncSqliteDb
# =============================================================================
@pytest.mark.asyncio
async def test_async_sqlite_get_schedules_roundtrip_with_raise_on_error(async_sqlite_db):
created = await async_sqlite_db.create_schedule(_schedule_data(name="nightly"))
schedules, total = await async_sqlite_db.get_schedules(raise_on_error=True)
assert total == 1
assert schedules[0]["id"] == created["id"]
@pytest.mark.asyncio
async def test_async_sqlite_get_schedules_default_swallows_error(async_sqlite_db, monkeypatch):
async def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(async_sqlite_db, "_get_table", _boom)
assert await async_sqlite_db.get_schedules() == ([], 0)
@pytest.mark.asyncio
async def test_async_sqlite_get_schedules_raise_on_error_reraises(async_sqlite_db, monkeypatch):
async def _boom(table_type, create_table_if_not_found=False):
raise RuntimeError("forced failure")
monkeypatch.setattr(async_sqlite_db, "_get_table", _boom)
with pytest.raises(RuntimeError, match="forced failure"):
await async_sqlite_db.get_schedules(raise_on_error=True)
@pytest.mark.asyncio
async def test_async_sqlite_get_schedules_table_none_default_returns_empty(async_sqlite_db, monkeypatch):
async def _none(table_type, create_table_if_not_found=False):
return None
monkeypatch.setattr(async_sqlite_db, "_get_table", _none)
assert await async_sqlite_db.get_schedules() == ([], 0)
@pytest.mark.asyncio
async def test_async_sqlite_get_schedules_table_none_raises_under_flag(async_sqlite_db, monkeypatch):
async def _none(table_type, create_table_if_not_found=False):
return None
monkeypatch.setattr(async_sqlite_db, "_get_table", _none)
with pytest.raises(RuntimeError, match="schedules table unavailable"):
await async_sqlite_db.get_schedules(raise_on_error=True)
@pytest.mark.asyncio
async def test_async_sqlite_get_schedules_never_created_table_raises_under_flag(async_sqlite_db):
# A fresh DB without the schedules table counts as unavailable for strict callers
with pytest.raises(RuntimeError, match="schedules table unavailable"):
await async_sqlite_db.get_schedules(raise_on_error=True)
# =============================================================================
# ScheduleManager end-to-end over sqlite
# =============================================================================
def test_manager_list_all_over_sqlite(sqlite_db):
pytest.importorskip("croniter", reason="croniter not installed")
pytest.importorskip("pytz", reason="pytz not installed")
from agno.scheduler.manager import ScheduleManager
mgr = ScheduleManager(sqlite_db)
for i in range(3):
sqlite_db.create_schedule(_schedule_data(name=f"schedule-{i}"))
result = mgr.list_all()
assert {s.name for s in result} == {"schedule-0", "schedule-1", "schedule-2"}
def test_manager_list_all_paginates_tied_created_at_without_loss(sqlite_db):
# Every schedule shares the same created_at second, so pagination must rely on
# the unique (created_at, id) sort to page across 250 rows with no dup or gap.
pytest.importorskip("croniter", reason="croniter not installed")
pytest.importorskip("pytz", reason="pytz not installed")
from agno.scheduler.manager import ScheduleManager
same_second = int(time.time())
expected_ids = set()
for i in range(250):
created = sqlite_db.create_schedule(_schedule_data(name=f"schedule-{i}", created_at=same_second))
expected_ids.add(created["id"])
ids = [s.id for s in ScheduleManager(sqlite_db).list_all()]
assert len(ids) == 250
assert len(set(ids)) == 250
assert set(ids) == expected_ids
@@ -581,3 +581,118 @@ class TestResponseBlocks:
submitted_card = result[-1]
assert len(submitted_card["body"]["text"]) <= 200
assert submitted_card["body"]["text"].endswith("...")
# -- render_arg_value inerting --
class TestRenderArgValueInerting:
def test_backtick_replaced_with_lookalike(self):
from agno.os.interfaces.slack.builders import render_arg_value
result = render_arg_value("x ` breakout `code`")
assert "`" not in result
assert "ˋ" in result
def test_angle_brackets_replaced_with_lookalikes(self):
from agno.os.interfaces.slack.builders import render_arg_value
result = render_arg_value("<!channel> and <@U123>")
assert "<" not in result
assert ">" not in result
assert "‹" in result
assert "›" in result
def test_newlines_neutralized(self):
from agno.os.interfaces.slack.builders import render_arg_value
result = render_arg_value("line1\nline2\rline3")
assert "\n" not in result
assert "\r" not in result
assert "\\n" in result
assert "\\r" in result
def test_json_encoded_values_also_inerted(self):
from agno.os.interfaces.slack.builders import render_arg_value
result = render_arg_value({"cmd": "`rm -rf /`", "note": "<!here>\nping"})
assert "`" not in result
assert "<" not in result
assert ">" not in result
assert "\n" not in result
def test_plain_string_passes_through(self):
from agno.os.interfaces.slack.builders import render_arg_value
assert render_arg_value("/tmp/demo.txt") == "/tmp/demo.txt"
def test_card_body_inerts_untrusted_args(self):
card = build_pause_message("A1", [_make_requirement(tool_args={"path": "a`b\nc<d>"})])[0]
body = card.body.text
# Only the two wrapping code-span backticks survive
assert body.count("`") == 2
assert "ˋ" in body
assert "<" not in body
assert ">" not in body
assert "\n" not in body
def test_card_body_inerts_untrusted_arg_keys(self):
# Keys sit outside the code span, where <...> is an active Slack control sequence
card = build_pause_message("A1", [_make_requirement(tool_args={"<!channel>": "v"})])[0]
body = card.body.text
assert "<" not in body
assert ">" not in body
assert "‹" in body
assert "›" in body
def test_card_body_key_backticks_and_newlines_neutralized(self):
card = build_pause_message("A1", [_make_requirement(tool_args={"a`b\nc\rd": "v"})])[0]
body = card.body.text
# Only the two wrapping code-span backticks survive
assert body.count("`") == 2
assert "ˋ" in body
assert "\n" not in body
assert "\r" not in body
def test_response_blocks_inerts_submitted_labels(self):
from agno.os.interfaces.slack.builders import response_blocks
original = [
{
"type": "input",
"block_id": "row:r1:user_input:field1",
"label": {"type": "plain_text", "text": "<!here> `label`\nx"},
"element": {"type": "plain_text_input", "action_id": "input_field:field1"},
}
]
state_values = {"row:r1:user_input:field1": {"input_field:field1": {"type": "plain_text_input", "value": "ok"}}}
result = response_blocks(original, state_values, [])
body = result[-1]["body"]["text"]
assert "<" not in body
assert ">" not in body
# Only the two wrapping code-span backticks survive
assert body.count("`") == 2
assert "\n" not in body
def test_response_blocks_inerts_submitted_values(self):
from agno.os.interfaces.slack.builders import response_blocks
original = [
{
"type": "input",
"block_id": "row:r1:user_input:field1",
"label": {"type": "plain_text", "text": "Command"},
"element": {"type": "plain_text_input", "action_id": "input_field:field1"},
}
]
state_values = {
"row:r1:user_input:field1": {"input_field:field1": {"type": "plain_text_input", "value": "a`b\n<!channel>"}}
}
result = response_blocks(original, state_values, [])
body = result[-1]["body"]["text"]
# Only the two wrapping code-span backticks survive
assert body.count("`") == 2
assert "ˋ" in body
assert "<" not in body
assert ">" not in body
assert "\n" not in body
@@ -120,7 +120,39 @@ class TestSendSlackMessageAsync:
async def test_normal_send(self):
client = AsyncMock()
await send_slack_message_async(client, "C1", "ts1", "hello world")
client.chat_postMessage.assert_called_once_with(channel="C1", text="hello world", thread_ts="ts1")
client.chat_postMessage.assert_called_once_with(
channel="C1", text="hello world", thread_ts="ts1", unfurl_links=True, unfurl_media=True, mrkdwn=True
)
@pytest.mark.asyncio
async def test_default_send_has_no_plaintext_kwargs(self):
# Control: defaults keep Slack's markdown delivery, so parse/link_names stay unset
client = AsyncMock()
await send_slack_message_async(client, "C1", "ts1", "hello world")
kwargs = client.chat_postMessage.call_args.kwargs
assert "parse" not in kwargs
assert "link_names" not in kwargs
@pytest.mark.asyncio
async def test_explicit_false_flags_forwarded(self):
client = AsyncMock()
await send_slack_message_async(
client, "C1", "ts1", "hello", unfurl_links=False, unfurl_media=False, mrkdwn=False
)
kwargs = client.chat_postMessage.call_args.kwargs
assert kwargs["unfurl_links"] is False
assert kwargs["unfurl_media"] is False
assert kwargs["mrkdwn"] is False
@pytest.mark.asyncio
async def test_mrkdwn_false_derives_plaintext_kwargs(self):
# mrkdwn=False implies parse="none" and link_names=False so bare URLs and
# pre-encoded mentions are not auto-linked
client = AsyncMock()
await send_slack_message_async(client, "C1", "ts1", "hello <!channel>", mrkdwn=False)
kwargs = client.chat_postMessage.call_args.kwargs
assert kwargs["parse"] == "none"
assert kwargs["link_names"] is False
@pytest.mark.asyncio
async def test_long_message_batching(self):
@@ -128,6 +160,20 @@ class TestSendSlackMessageAsync:
await send_slack_message_async(client, "C1", "ts1", "x" * 50000)
assert client.chat_postMessage.call_count == 2
@pytest.mark.asyncio
async def test_batch_path_forwards_flags(self):
client = AsyncMock()
await send_slack_message_async(
client, "C1", "ts1", "x" * 50000, unfurl_links=False, unfurl_media=False, mrkdwn=False
)
assert client.chat_postMessage.call_count == 2
for call in client.chat_postMessage.call_args_list:
assert call.kwargs["unfurl_links"] is False
assert call.kwargs["unfurl_media"] is False
assert call.kwargs["mrkdwn"] is False
assert call.kwargs["parse"] == "none"
assert call.kwargs["link_names"] is False
class TestUploadResponseMediaAsync:
@pytest.mark.asyncio
@@ -1178,3 +1178,254 @@ class TestAdminApprovalFlow:
mock_client.chat_update.assert_awaited_once()
call = mock_client.chat_update.call_args
assert call.kwargs["text"] == "Status: pending"
class TestDeliveryFlags:
def test_slack_interface_flags_reach_event_handler(self):
from agno.os.interfaces.slack.slack import Slack
agent_mock = make_agent_mock()
with (
patch("agno.os.interfaces.slack.router.SlackTools"),
patch("agno.os.interfaces.slack.router.SlackEventHandler") as handler_cls,
):
Slack(agent=agent_mock, markdown=False, unfurl_links=False, unfurl_media=False).get_router()
kwargs = handler_cls.call_args.kwargs
assert kwargs["markdown"] is False
assert kwargs["unfurl_links"] is False
assert kwargs["unfurl_media"] is False
def test_slack_interface_flags_default_true(self):
from agno.os.interfaces.slack.slack import Slack
agent_mock = make_agent_mock()
with (
patch("agno.os.interfaces.slack.router.SlackTools"),
patch("agno.os.interfaces.slack.router.SlackEventHandler") as handler_cls,
):
Slack(agent=agent_mock).get_router()
kwargs = handler_cls.call_args.kwargs
assert kwargs["markdown"] is True
assert kwargs["unfurl_links"] is True
assert kwargs["unfurl_media"] is True
@pytest.mark.asyncio
async def test_send_error_forwards_delivery_flags(self):
handler = _make_event_handler(markdown=False, unfurl_links=False, unfurl_media=False)
mock_client = make_async_client_mock()
with patch("agno.os.interfaces.slack.event_handler.AsyncWebClient", return_value=mock_client):
await handler.send_error(_make_event_context(), "boom")
kwargs = mock_client.chat_postMessage.call_args.kwargs
assert kwargs["unfurl_links"] is False
assert kwargs["unfurl_media"] is False
assert kwargs["mrkdwn"] is False
assert kwargs["parse"] == "none"
assert kwargs["link_names"] is False
@pytest.mark.asyncio
async def test_send_error_defaults_keep_markdown_delivery(self):
handler = _make_event_handler()
mock_client = make_async_client_mock()
with patch("agno.os.interfaces.slack.event_handler.AsyncWebClient", return_value=mock_client):
await handler.send_error(_make_event_context(), "boom")
kwargs = mock_client.chat_postMessage.call_args.kwargs
assert kwargs["unfurl_links"] is True
assert kwargs["unfurl_media"] is True
assert kwargs["mrkdwn"] is True
assert "parse" not in kwargs
assert "link_names" not in kwargs
@pytest.mark.asyncio
async def test_non_streaming_reply_and_reasoning_honor_flags(self):
agent_mock = make_agent_mock()
agent_mock.arun = AsyncMock(
return_value=Mock(
status="OK",
content="done",
reasoning_content="thinking",
images=None,
files=None,
videos=None,
audio=None,
)
)
mock_slack = make_slack_mock(token="xoxb-test")
mock_client = make_async_client_mock()
with (
patch("agno.os.interfaces.slack.router.verify_slack_signature", return_value=True),
patch("agno.os.interfaces.slack.router.SlackTools", return_value=mock_slack),
patch("agno.os.interfaces.slack.event_handler.AsyncWebClient", return_value=mock_client),
):
app = build_app(
agent_mock, reply_to_mentions_only=False, markdown=False, unfurl_links=False, unfurl_media=False
)
from fastapi.testclient import TestClient
client = TestClient(app)
body = {
"type": "event_callback",
"event": {
"type": "message",
"channel_type": "im",
"text": "hello",
"user": "U123",
"channel": "C123",
"ts": str(time.time()),
},
}
resp = make_signed_request(client, body)
assert resp.status_code == 200
await wait_for_call(mock_client.chat_postMessage)
# Reasoning post and content post both carry the flags
assert mock_client.chat_postMessage.call_count == 2
for call in mock_client.chat_postMessage.call_args_list:
assert call.kwargs["unfurl_links"] is False
assert call.kwargs["unfurl_media"] is False
assert call.kwargs["mrkdwn"] is False
assert call.kwargs["parse"] == "none"
assert call.kwargs["link_names"] is False
@pytest.mark.asyncio
async def test_paused_non_streaming_honors_flags(self):
handler = _make_event_handler(markdown=False, unfurl_links=False, unfurl_media=False)
mock_client = make_async_client_mock()
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "123.456"})
response = Mock(content="pausing", active_requirements=[_make_requirement()], run_id="run-1")
with patch("agno.os.interfaces.slack.event_handler.AsyncWebClient", return_value=mock_client):
handled = await handler._handle_paused_non_streaming(_make_event_context(), response)
assert handled is True
# Content, awaiting labels, and pause card: three posts, all carrying the flags
assert mock_client.chat_postMessage.call_count == 3
for call in mock_client.chat_postMessage.call_args_list:
assert call.kwargs["unfurl_links"] is False
assert call.kwargs["unfurl_media"] is False
assert call.kwargs["mrkdwn"] is False
@pytest.mark.asyncio
async def test_finalize_pause_forwards_flags(self):
from agno.os.interfaces.slack.pause import finalize_pause
client = make_async_client_mock()
client.chat_postMessage = AsyncMock(return_value={"ts": "123.456"})
state = Mock()
state.has_content = Mock(return_value=False)
state.task_cards = {}
awaiting_ts = await finalize_pause(
client=client,
stream=make_stream_mock(),
state=state,
run_id="run-1",
channel="C123",
thread_ts="111.222",
requirements=[_make_requirement()],
unfurl_links=False,
unfurl_media=False,
mrkdwn=False,
)
assert awaiting_ts == "123.456"
kwargs = client.chat_postMessage.call_args.kwargs
assert kwargs["unfurl_links"] is False
assert kwargs["unfurl_media"] is False
assert kwargs["mrkdwn"] is False
assert kwargs["parse"] == "none"
assert kwargs["link_names"] is False
@pytest.mark.asyncio
async def test_post_pause_card_forwards_flags(self):
from agno.os.interfaces.slack.pause import post_pause_card
client = make_async_client_mock()
client.chat_postMessage = AsyncMock(return_value={"ts": "123.456"})
paused = Mock(run_id="run-1", active_requirements=[_make_requirement()])
ts = await post_pause_card(client, paused, "C123", "111.222", unfurl_links=False, unfurl_media=False)
assert ts == "123.456"
kwargs = client.chat_postMessage.call_args.kwargs
assert kwargs["unfurl_links"] is False
assert kwargs["unfurl_media"] is False
assert "blocks" in kwargs
@pytest.mark.asyncio
async def test_post_pause_card_defaults_keep_current_behavior(self):
from agno.os.interfaces.slack.pause import post_pause_card
client = make_async_client_mock()
client.chat_postMessage = AsyncMock(return_value={"ts": "123.456"})
paused = Mock(run_id="run-1", active_requirements=[_make_requirement()])
await post_pause_card(client, paused, "C123", "111.222")
kwargs = client.chat_postMessage.call_args.kwargs
assert kwargs["unfurl_links"] is True
assert kwargs["unfurl_media"] is True
assert kwargs["mrkdwn"] is True
assert "parse" not in kwargs
assert "link_names" not in kwargs
def test_slack_interface_flags_reach_hitl_handler(self):
from agno.os.interfaces.slack.slack import Slack
agent_mock = make_agent_mock()
with (
patch("agno.os.interfaces.slack.router.SlackTools"),
patch("agno.os.interfaces.slack.router.HITLHandler") as hitl_cls,
):
Slack(agent=agent_mock, markdown=False, unfurl_links=False, unfurl_media=False).get_router()
kwargs = hitl_cls.call_args.kwargs
assert kwargs["markdown"] is False
assert kwargs["unfurl_links"] is False
assert kwargs["unfurl_media"] is False
@pytest.mark.asyncio
async def test_repause_honors_delivery_flags(self):
# A run that pauses AGAIN after approval posts the awaiting indicator and
# the second pause card through complete_or_repause; both must carry the
# operator's disabled markdown/unfurl flags (chat_postMessage sites, not
# the scope-outed chat_stream continuation).
from agno.os.interfaces.slack.state import StreamState
from agno.os.interfaces.slack.types import SubmitContext
handler = HITLHandler(
slack_tools=make_slack_mock(token="xoxb-test"),
ssl=None,
entity=AsyncMock(),
entity_id="agent-1",
entity_name="Test Agent",
entity_type="agent",
task_display_mode="plan",
buffer_size=100,
markdown=False,
unfurl_links=False,
unfurl_media=False,
)
mock_client = make_async_client_mock()
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "await-2"})
state = StreamState()
state.paused_event = Mock(run_id="run-1", active_requirements=[_make_requirement()])
ctx = SubmitContext(
run_id="run-1",
channel="C123",
msg_ts="1.0",
thread_ts="111.222",
awaiting_ts=None,
user_id="U1",
team_id="T1",
state_values={},
)
with patch("agno.os.interfaces.slack.hitl.AsyncWebClient", return_value=mock_client):
await handler.complete_or_repause(ctx, make_stream_mock(), state)
# Awaiting indicator + pause card, both carrying the disabled flags.
assert mock_client.chat_postMessage.call_count == 2
for call in mock_client.chat_postMessage.call_args_list:
assert call.kwargs["unfurl_links"] is False
assert call.kwargs["unfurl_media"] is False
assert call.kwargs["mrkdwn"] is False
+135 -1
View File
@@ -256,7 +256,7 @@ class TestManagerCreateNameScope:
class TestManagerList:
def test_list_all(self, mgr, mock_db):
def test_list_returns_first_page(self, mgr, mock_db):
result = mgr.list()
assert len(result) == 1
mock_db.get_schedules.assert_called_once_with(enabled=None, limit=100, page=1, user_id=None)
@@ -266,6 +266,74 @@ class TestManagerList:
mock_db.get_schedules.assert_called_once_with(enabled=True, limit=10, page=2, user_id=None)
def _make_paged_get_schedules(schedules):
"""Build a get_schedules side effect serving pages from the given rows."""
def _get_schedules(enabled=None, limit=100, page=1, user_id=None, raise_on_error=False):
offset = (page - 1) * limit
return schedules[offset : offset + limit], len(schedules)
return _get_schedules
class TestManagerListAll:
def test_list_all_pages_past_first_page(self, mgr, mock_db):
schedules = [_make_schedule(id=f"sched-{i}", name=f"schedule-{i}") for i in range(150)]
mock_db.get_schedules = MagicMock(side_effect=_make_paged_get_schedules(schedules))
result = mgr.list_all()
assert len(result) == 150
assert {s.id for s in result} == {f"sched-{i}" for i in range(150)}
assert mock_db.get_schedules.call_count == 2
mock_db.get_schedules.assert_called_with(enabled=None, limit=100, page=2, user_id=None, raise_on_error=True)
def test_list_all_exact_page_multiple(self, mgr, mock_db):
schedules = [_make_schedule(id=f"sched-{i}", name=f"schedule-{i}") for i in range(200)]
mock_db.get_schedules = MagicMock(side_effect=_make_paged_get_schedules(schedules))
result = mgr.list_all()
assert len(result) == 200
# A trailing empty page marks the end of the sweep
assert mock_db.get_schedules.call_count == 3
def test_list_all_propagates_db_error(self, mgr, mock_db):
mock_db.get_schedules = MagicMock(side_effect=RuntimeError("schedules table unavailable"))
with pytest.raises(RuntimeError, match="schedules table unavailable"):
mgr.list_all()
def test_list_all_empty_table_returns_empty_list(self, mgr, mock_db):
mock_db.get_schedules = MagicMock(return_value=([], 0))
assert mgr.list_all() == []
mock_db.get_schedules.assert_called_once_with(
enabled=None, limit=100, page=1, user_id=None, raise_on_error=True
)
def test_list_all_forwards_flags(self, mgr, mock_db):
mock_db.get_schedules = MagicMock(return_value=([], 0))
mgr.list_all(enabled=True, raise_on_error=False)
mock_db.get_schedules.assert_called_once_with(
enabled=True, limit=100, page=1, user_id=None, raise_on_error=False
)
def test_list_all_tolerates_bare_list_result(self, mgr, mock_db):
# Legacy third-party Dbs may return a bare list instead of (rows, total)
schedules = [_make_schedule(id=f"sched-{i}", name=f"schedule-{i}") for i in range(3)]
mock_db.get_schedules = MagicMock(return_value=schedules)
result = mgr.list_all()
assert len(result) == 3
def test_list_all_tolerates_none_rows_result(self, mgr, mock_db):
# A (None, 0) result must be treated as an empty page, not crash on len(None);
# list() tolerates the same shape, so list_all(raise_on_error=False) must too.
mock_db.get_schedules = MagicMock(return_value=(None, 0))
assert mgr.list_all(raise_on_error=False) == []
assert mock_db.get_schedules.call_count == 1
class TestManagerGet:
def test_get_found(self, mgr, mock_db):
result = mgr.get("sched-1")
@@ -385,6 +453,72 @@ class TestAsyncList:
assert len(result) == 1
class TestAsyncListAll:
@pytest.mark.asyncio
async def test_alist_all_pages_past_first_page(self, async_mgr, mock_async_db):
schedules = [_make_schedule(id=f"sched-{i}", name=f"schedule-{i}") for i in range(150)]
mock_async_db.get_schedules = AsyncMock(side_effect=_make_paged_get_schedules(schedules))
result = await async_mgr.alist_all()
assert len(result) == 150
assert {s.id for s in result} == {f"sched-{i}" for i in range(150)}
assert mock_async_db.get_schedules.call_count == 2
mock_async_db.get_schedules.assert_called_with(
enabled=None, limit=100, page=2, user_id=None, raise_on_error=True
)
@pytest.mark.asyncio
async def test_alist_all_propagates_db_error(self, async_mgr, mock_async_db):
mock_async_db.get_schedules = AsyncMock(side_effect=RuntimeError("schedules table unavailable"))
with pytest.raises(RuntimeError, match="schedules table unavailable"):
await async_mgr.alist_all()
@pytest.mark.asyncio
async def test_alist_all_empty_table_returns_empty_list(self, async_mgr, mock_async_db):
mock_async_db.get_schedules = AsyncMock(return_value=([], 0))
assert await async_mgr.alist_all() == []
mock_async_db.get_schedules.assert_called_once_with(
enabled=None, limit=100, page=1, user_id=None, raise_on_error=True
)
@pytest.mark.asyncio
async def test_alist_all_tolerates_bare_list_result(self, async_mgr, mock_async_db):
# Legacy third-party Dbs may return a bare list instead of (rows, total)
schedules = [_make_schedule(id=f"sched-{i}", name=f"schedule-{i}") for i in range(3)]
mock_async_db.get_schedules = AsyncMock(return_value=schedules)
result = await async_mgr.alist_all()
assert len(result) == 3
assert mock_async_db.get_schedules.call_count == 1
@pytest.mark.asyncio
async def test_alist_all_exact_page_multiple(self, async_mgr, mock_async_db):
schedules = [_make_schedule(id=f"sched-{i}", name=f"schedule-{i}") for i in range(200)]
mock_async_db.get_schedules = AsyncMock(side_effect=_make_paged_get_schedules(schedules))
result = await async_mgr.alist_all()
assert len(result) == 200
# A trailing empty page marks the end of the sweep
assert mock_async_db.get_schedules.call_count == 3
@pytest.mark.asyncio
async def test_alist_all_forwards_flags(self, async_mgr, mock_async_db):
mock_async_db.get_schedules = AsyncMock(return_value=([], 0))
await async_mgr.alist_all(enabled=True, raise_on_error=False)
mock_async_db.get_schedules.assert_called_once_with(
enabled=True, limit=100, page=1, user_id=None, raise_on_error=False
)
@pytest.mark.asyncio
async def test_alist_all_tolerates_none_rows_result(self, async_mgr, mock_async_db):
# A (None, 0) result must be treated as an empty page, not crash on len(None)
mock_async_db.get_schedules = AsyncMock(return_value=(None, 0))
assert await async_mgr.alist_all(raise_on_error=False) == []
class TestAsyncGet:
@pytest.mark.asyncio
async def test_aget(self, async_mgr, mock_async_db):
@@ -63,9 +63,19 @@ def _patch_team_dispatch_dependencies(team: Team, monkeypatch: pytest.MonkeyPatc
knowledge_filters=({"team_filter": "default", **(kwargs.get("knowledge_filters") or {})})
if (team.knowledge_filters or kwargs.get("knowledge_filters"))
else None,
metadata=({**(kwargs.get("metadata") or {}), **(team.metadata or {})})
if (kwargs.get("metadata") is not None or team.metadata is not None)
else None,
metadata=(
{
**(team.metadata or {}),
**(kwargs.get("session_metadata") or {}),
**(kwargs.get("metadata") or {}),
}
if (
kwargs.get("metadata") is not None
or kwargs.get("session_metadata") is not None
or team.metadata is not None
)
else None
),
output_schema=kwargs.get("output_schema")
if kwargs.get("output_schema") is not None
else team.output_schema,
+263 -6
View File
@@ -1,9 +1,17 @@
"""Tests for centralized team run option resolution and renamed run functions."""
import dataclasses
import time
from typing import Any, AsyncIterator, Dict, Iterator
import pytest
from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.models.base import Model
from agno.models.message import MessageMetrics
from agno.models.response import ModelResponse
from agno.session import TeamSession
from agno.team._run_options import ResolvedRunOptions, resolve_run_options
from agno.team.team import Team
@@ -13,6 +21,60 @@ def _make_team(**kwargs) -> Team:
return Team(members=[], **kwargs)
class MockModel(Model):
"""Minimal offline model: returns a canned text response without any network call."""
def __init__(self):
super().__init__(id="test-model", name="test-model", provider="test")
self.instructions = None
self._mock_response = ModelResponse(
content="ok",
role="assistant",
response_usage=MessageMetrics(),
)
def get_instructions_for_model(self, *args, **kwargs):
return None
def get_system_message_for_model(self, *args, **kwargs):
return None
async def aget_instructions_for_model(self, *args, **kwargs):
return None
async def aget_system_message_for_model(self, *args, **kwargs):
return None
def parse_args(self, *args, **kwargs):
return {}
def invoke(self, *args, **kwargs) -> ModelResponse:
return self._mock_response
async def ainvoke(self, *args, **kwargs) -> ModelResponse:
return self._mock_response
def invoke_stream(self, *args, **kwargs) -> Iterator[ModelResponse]:
yield self._mock_response
async def ainvoke_stream(self, *args, **kwargs) -> AsyncIterator[ModelResponse]:
yield self._mock_response
return
def _parse_provider_response(self, response: Any, **kwargs) -> ModelResponse:
return self._mock_response
def _parse_provider_response_delta(self, response: Any) -> ModelResponse:
return self._mock_response
def _seed_team_session(db: InMemoryDb, session_id: str, team_id: str, metadata: Dict[str, Any]) -> None:
"""Insert a session record with the given metadata, as if written by an earlier deployment."""
db.upsert_session(
TeamSession(session_id=session_id, team_id=team_id, metadata=metadata, created_at=int(time.time()))
)
# ---------------------------------------------------------------------------
# ResolvedRunOptions immutability
# ---------------------------------------------------------------------------
@@ -237,18 +299,91 @@ class TestMetadataMerge:
opts = resolve_run_options(team)
assert opts.metadata == {"team": "value"}
def test_merge_team_takes_precedence(self):
team = _make_team(metadata={"shared": "team_wins", "team_only": "t"})
def test_merge_callsite_takes_precedence(self):
# Metadata resolves like dependencies: call-site keys win on conflict.
team = _make_team(metadata={"shared": "team_value", "team_only": "t"})
opts = resolve_run_options(team, metadata={"shared": "run_value", "run_only": "r"})
assert opts.metadata["shared"] == "team_wins"
assert opts.metadata["shared"] == "run_value"
assert opts.metadata["team_only"] == "t"
assert opts.metadata["run_only"] == "r"
def test_only_session(self):
team = _make_team()
opts = resolve_run_options(team, session_metadata={"session": "value"})
assert opts.metadata == {"session": "value"}
def test_session_beats_team(self):
team = _make_team(metadata={"shared": "team_value", "team_only": "t"})
opts = resolve_run_options(team, session_metadata={"shared": "session_value", "session_only": "s"})
assert opts.metadata["shared"] == "session_value"
assert opts.metadata["team_only"] == "t"
assert opts.metadata["session_only"] == "s"
def test_callsite_beats_session(self):
team = _make_team()
opts = resolve_run_options(
team,
metadata={"shared": "run_value", "run_only": "r"},
session_metadata={"shared": "session_value", "session_only": "s"},
)
assert opts.metadata["shared"] == "run_value"
assert opts.metadata["session_only"] == "s"
assert opts.metadata["run_only"] == "r"
def test_three_layer_merge_non_conflicting_keys(self):
team = _make_team(metadata={"team_only": "t", "shared": "team_value"})
opts = resolve_run_options(
team,
metadata={"run_only": "r", "shared": "run_value"},
session_metadata={"session_only": "s"},
)
assert opts.metadata == {
"team_only": "t",
"session_only": "s",
"run_only": "r",
"shared": "run_value",
}
def test_merge_does_not_mutate_callsite(self):
team = _make_team(metadata={"a": 1})
callsite_meta = {"b": 2}
# Includes a nested dict: the recursive in-place merge must never write
# other layers' values into the caller's nested dicts.
team = _make_team(metadata={"a": 1, "nested": {"team": True}})
callsite_meta = {"b": 2, "nested": {"call": True}}
resolve_run_options(team, metadata=callsite_meta)
assert callsite_meta == {"b": 2}
assert callsite_meta == {"b": 2, "nested": {"call": True}}
def test_merge_does_not_mutate_team_nested_dicts(self):
# merge_dictionaries recurses in place: a shallow copy of team.metadata
# would let call-site values contaminate the team's nested dicts.
team = _make_team(metadata={"policy": {"read_only": True}, "flat": "team_value"})
opts = resolve_run_options(
team,
metadata={"policy": {"read_only": False}, "flat": "run_value"},
session_metadata={"policy": {"source": "session"}},
)
assert opts.metadata["policy"] == {"read_only": False, "source": "session"}
assert team.metadata == {"policy": {"read_only": True}, "flat": "team_value"}
def test_merge_does_not_mutate_session_nested_dicts(self):
# merge_dictionaries recurses in place: a shallow copy of session_metadata
# would let call-site values contaminate the caller's nested dicts.
team = _make_team(metadata={"policy": {"read_only": True}})
session_meta = {"policy": {"source": "session"}}
resolve_run_options(team, metadata={"policy": {"read_only": False}}, session_metadata=session_meta)
assert session_meta == {"policy": {"source": "session"}}
def test_resolved_metadata_mutation_does_not_reach_team(self):
team = _make_team(metadata={"policy": {"read_only": True}})
opts = resolve_run_options(team)
opts.metadata["policy"]["read_only"] = False # type: ignore[index]
assert team.metadata == {"policy": {"read_only": True}}
def test_resolved_metadata_does_not_alias_callsite_nested_dicts(self):
team = _make_team()
callsite = {"labels": {"team": "ops"}}
opts = resolve_run_options(team, metadata=callsite)
opts.metadata["labels"]["team"] = "sre" # type: ignore[index]
assert callsite == {"labels": {"team": "ops"}}
# ---------------------------------------------------------------------------
@@ -451,3 +586,125 @@ class TestParityWithAgent:
agent_types = {f.name: f.type for f in dataclasses.fields(AgentOpts)}
team_types = {f.name: f.type for f in dataclasses.fields(TeamOpts)}
assert agent_types == team_types
# ---------------------------------------------------------------------------
# Singleton isolation: session reads never mutate the shared Team instance
# ---------------------------------------------------------------------------
class TestTeamSingletonIsolation:
def _seed_session(self, db, session_id: str, team_id: str, metadata) -> None:
import time
from agno.session import TeamSession
db.upsert_session(
TeamSession(session_id=session_id, team_id=team_id, metadata=metadata, created_at=int(time.time()))
)
def test_team_metadata_not_aliased_to_session(self):
from agno.db.in_memory import InMemoryDb
from agno.team._storage import _read_or_create_session, _update_metadata
db = InMemoryDb()
self._seed_session(db, "session-a", "team-1", {"k": "v"})
team = _make_team(id="team-1", db=db, metadata={"env": "test"})
session = _read_or_create_session(team, session_id="session-a")
_update_metadata(team, session=session)
assert team.metadata is not session.metadata
assert team.metadata == {"env": "test"}
# Session side: team metadata fills keys the session does not set
assert session.metadata == {"k": "v", "env": "test"}
def test_new_session_seeding_not_aliased(self):
from agno.db.in_memory import InMemoryDb
from agno.team._storage import _read_or_create_session
team = _make_team(id="team-1", db=InMemoryDb(), metadata={"env": "test"})
session = _read_or_create_session(team, session_id="fresh")
assert session.metadata == {"env": "test"}
assert session.metadata is not team.metadata
@pytest.mark.asyncio
async def test_new_session_seeding_not_aliased_async(self):
from agno.db.in_memory import InMemoryDb
from agno.team._storage import _aread_or_create_session
team = _make_team(id="team-1", db=InMemoryDb(), metadata={"env": "test"})
session = await _aread_or_create_session(team, session_id="fresh")
assert session.metadata == {"env": "test"}
assert session.metadata is not team.metadata
# ---------------------------------------------------------------------------
# Session metadata precedence on the full run() path
# ---------------------------------------------------------------------------
class TestTeamRunSessionMetadataPrecedence:
"""Run-level twin of the agent's TestRunSessionMetadataPrecedence."""
def _make_run_team(self, db: InMemoryDb, **kwargs) -> Team:
member = Agent(name="member", model=MockModel(), telemetry=False)
return Team(id="team-1", members=[member], model=MockModel(), db=db, telemetry=False, **kwargs)
def test_session_beats_team_on_run(self):
db = InMemoryDb()
_seed_team_session(db, "s1", "team-1", {"shared": "session_value", "session_only": "s"})
team = self._make_run_team(db, metadata={"shared": "team_value", "team_only": "t"})
out = team.run(input="hi", session_id="s1")
assert out.metadata["shared"] == "session_value"
assert out.metadata["team_only"] == "t"
assert out.metadata["session_only"] == "s"
def test_callsite_beats_session_and_team_on_run(self):
db = InMemoryDb()
_seed_team_session(db, "s1", "team-1", {"shared": "session_value"})
team = self._make_run_team(db, metadata={"shared": "team_value"})
out = team.run(input="hi", session_id="s1", metadata={"shared": "call_value", "run_only": "r"})
assert out.metadata["shared"] == "call_value"
assert out.metadata["run_only"] == "r"
def test_run_does_not_mutate_team_metadata(self):
db = InMemoryDb()
_seed_team_session(db, "s1", "team-1", {"leak": "from_session"})
team = self._make_run_team(db, metadata={"env": "test"})
out = team.run(input="hi", session_id="s1")
assert out.metadata["leak"] == "from_session"
assert team.metadata == {"env": "test"}
def test_session_beats_team_stays_stable_across_runs(self):
# The session value must survive persistence: _update_metadata merges team
# defaults as the losing layer, so the session's own value is not clobbered
# in the record and every subsequent run resolves it identically.
db = InMemoryDb()
_seed_team_session(db, "s1", "team-1", {"tier": "gold"})
team = self._make_run_team(db, metadata={"tier": "standard"})
first = team.run(input="hi", session_id="s1").metadata["tier"]
record = db.get_session(session_id="s1").metadata["tier"]
second = team.run(input="hi", session_id="s1").metadata["tier"]
assert (first, record, second) == ("gold", "gold", "gold")
def test_continue_run_sees_session_metadata(self):
db = InMemoryDb()
_seed_team_session(db, "s1", "team-1", {"policy": "capture-only"})
team = self._make_run_team(db, metadata={"env": "test"})
run_out = team.run(input="hi", session_id="s1")
cont = team.continue_run(run_id=run_out.run_id, session_id="s1", requirements=[])
assert cont.metadata["policy"] == "capture-only"
assert cont.metadata["env"] == "test"
@pytest.mark.asyncio
async def test_arun_sync_db_matches_async_db_path(self):
# Unlike agent.arun, team.arun does NOT pre-read the session even with a
# sync DB (arun_dispatch resolves options before the session id is known),
# so session metadata does not reach the resolved options. Pinned so a
# future change to either side is a conscious decision, not a silent drift.
db = InMemoryDb()
_seed_team_session(db, "s1", "team-1", {"shared": "session_value"})
team = self._make_run_team(db, metadata={"shared": "team_value"})
out = await team.arun(input="hi", session_id="s1", metadata={"run_only": "r"})
assert out.metadata["shared"] == "team_value"
assert out.metadata["run_only"] == "r"
assert "session_value" not in out.metadata.values()
@@ -28,6 +28,7 @@ def test_auth_config_default_values(monkeypatch):
monkeypatch.delenv("GOOGLE_DELEGATED_USER", raising=False)
monkeypatch.delenv("GOOGLE_HOSTED_DOMAIN", raising=False)
monkeypatch.delenv("GOOGLE_TOKEN_ENCRYPTION_KEY", raising=False)
monkeypatch.delenv("GOOGLE_OAUTH_NONINTERACTIVE", raising=False)
auth = AuthConfig()
@@ -43,6 +44,7 @@ def test_auth_config_default_values(monkeypatch):
assert auth.db is None
assert auth.token_encryption_key is None
assert auth.encrypt_tokens is True
assert auth.interactive is True
assert auth._scopes == set()
assert auth._creds is None
@@ -316,6 +318,48 @@ def test_encrypt_tokens_true_without_key_valid(monkeypatch):
assert auth.token_encryption_key is None
# ============================================================================
# INTERACTIVE FLAG TESTS
# ============================================================================
def test_interactive_defaults_true(monkeypatch):
monkeypatch.delenv("GOOGLE_OAUTH_NONINTERACTIVE", raising=False)
assert AuthConfig().interactive is True
def test_interactive_explicit_false():
assert AuthConfig(interactive=False).interactive is False
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes"])
def test_interactive_env_noninteractive(monkeypatch, value):
monkeypatch.setenv("GOOGLE_OAUTH_NONINTERACTIVE", value)
assert AuthConfig().interactive is False
@pytest.mark.parametrize("value", ["0", "false", ""])
def test_interactive_env_falsy_values_keep_interactive(monkeypatch, value):
monkeypatch.setenv("GOOGLE_OAUTH_NONINTERACTIVE", value)
assert AuthConfig().interactive is True
def test_interactive_explicit_overrides_env(monkeypatch):
monkeypatch.setenv("GOOGLE_OAUTH_NONINTERACTIVE", "1")
assert AuthConfig(interactive=True).interactive is True
def test_interactive_env_reaches_internally_built_auth_config(monkeypatch, mock_creds):
# Users who never pass auth= get an internally-built AuthConfig; the env
# default is the only way for them to reach the flag
monkeypatch.setenv("GOOGLE_OAUTH_NONINTERACTIVE", "1")
with patch("googleapiclient.discovery.build"):
gmail = GmailTools(creds=mock_creds)
assert gmail._auth.interactive is False
# ============================================================================
# REPR TESTS
# ============================================================================
@@ -1,9 +1,11 @@
import json
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock, Mock, patch
import pytest
from agno.tools.google.auth import AuthConfig
from agno.tools.google.auth.decorator import google_authenticate
from agno.tools.google.gmail import GmailTools
class FakeToolkit:
@@ -224,3 +226,31 @@ def test_decorator_service_name_titlecased_in_error(service_name, expected_prefi
def test_decorator_wraps_preserves_method_name():
assert FakeToolkit.do_work.__name__ == "do_work"
# ============================================================================
# NON-INTERACTIVE END-TO-END TESTS
# ============================================================================
def test_decorator_noninteractive_dead_token_returns_auth_error_json():
# Real GmailTools with interactive=False and no usable token: the tool call
# returns the auth-failed JSON and never reaches run_local_server
auth = AuthConfig(interactive=False)
auth.service_account_path = None
tools = GmailTools(auth=auth)
with (
patch("agno.tools.google.base.Path") as mock_path,
patch("google_auth_oauthlib.flow.InstalledAppFlow") as mock_flow,
):
mock_path.return_value.exists.return_value = False
mock_flow.from_client_config.return_value.run_local_server.side_effect = AssertionError(
"run_local_server must not be called"
)
result = tools.get_latest_emails(count=1)
data = json.loads(result)
assert "Gmail authentication failed" in data["error"]
assert "interactive login is disabled" in data["error"]
mock_flow.from_client_config.return_value.run_local_server.assert_not_called()
@@ -53,6 +53,9 @@ def mock_valid_creds():
def clean_google_env(monkeypatch):
monkeypatch.delenv("GOOGLE_API_TIMEOUT", raising=False)
monkeypatch.delenv("GOOGLE_CLOUD_QUOTA_PROJECT_ID", raising=False)
monkeypatch.delenv("GOOGLE_OAUTH_NONINTERACTIVE", raising=False)
monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_FILE", raising=False)
monkeypatch.delenv("GOOGLE_DELEGATED_USER", raising=False)
# ============================================================================
@@ -824,6 +827,119 @@ def test_resolve_creds_service_account_with_delegated_user():
assert result is delegated_creds
# ============================================================================
# _resolve_creds INTERACTIVE FLAG TESTS
# ============================================================================
def test_resolve_creds_interactive_false_raises_before_flow():
auth = AuthConfig(interactive=False)
auth.service_account_path = None
toolkit = _make_toolkit(auth=auth, scopes=["scope1"])
with (
patch("agno.tools.google.base.Path") as mock_path,
patch("google_auth_oauthlib.flow.InstalledAppFlow") as mock_flow,
):
mock_path.return_value.exists.return_value = False
with pytest.raises(RuntimeError, match="interactive login is disabled"):
toolkit._resolve_creds()
mock_flow.from_client_config.assert_not_called()
mock_flow.from_client_secrets_file.assert_not_called()
def test_resolve_creds_interactive_false_dead_token_raises():
# Expired file token with a failing refresh must not fall through to the flow
auth = AuthConfig(interactive=False)
auth.service_account_path = None
toolkit = _make_toolkit(auth=auth, scopes=["scope1"])
file_creds = _make_creds(valid=False, expired=True, scopes=["scope1"])
file_creds.refresh.side_effect = Exception("invalid_grant")
with (
patch("agno.tools.google.base.Path") as mock_path,
patch("google.oauth2.credentials.Credentials.from_authorized_user_file", return_value=file_creds),
patch("google_auth_oauthlib.flow.InstalledAppFlow") as mock_flow,
):
token_path = MagicMock()
token_path.exists.return_value = True
creds_path = MagicMock()
creds_path.exists.return_value = False
mock_path.side_effect = [token_path, creds_path]
with pytest.raises(RuntimeError, match="interactive login is disabled"):
toolkit._resolve_creds()
file_creds.refresh.assert_called_once()
mock_flow.from_client_config.assert_not_called()
mock_flow.from_client_secrets_file.assert_not_called()
def test_resolve_creds_interactive_true_control_reaches_flow():
toolkit = _make_toolkit(scopes=["scope1"])
with (
patch("agno.tools.google.base.Path") as mock_path,
patch("google_auth_oauthlib.flow.InstalledAppFlow") as mock_flow,
):
mock_path.return_value.exists.return_value = False
mock_flow.from_client_config.return_value.run_local_server.return_value = None
toolkit._resolve_creds()
mock_flow.from_client_config.return_value.run_local_server.assert_called_once()
def test_resolve_creds_interactive_false_valid_file_token_ok():
# interactive=False only gates step 6: a valid file token resolves without the flow
auth = AuthConfig(interactive=False)
auth.service_account_path = None
toolkit = _make_toolkit(auth=auth, scopes=["scope1"])
file_creds = _make_creds(valid=True, scopes=["scope1"])
with (
patch("agno.tools.google.base.Path") as mock_path,
patch("google.oauth2.credentials.Credentials.from_authorized_user_file", return_value=file_creds),
):
mock_path.return_value.exists.return_value = True
result = toolkit._resolve_creds()
assert result is file_creds
def test_resolve_creds_interactive_false_service_account_ok():
auth = AuthConfig(service_account_path="/path/to/sa.json", interactive=False)
toolkit = _make_toolkit(auth=auth, scopes=["scope1"])
sa_creds = _make_creds(valid=True, scopes=["scope1"])
with patch("google.oauth2.service_account.Credentials.from_service_account_file", return_value=sa_creds):
result = toolkit._resolve_creds()
assert result is sa_creds
def test_sheets_duplicate_interactive_false_surfaces_error_string():
# create_duplicate_sheet calls _resolve_creds directly inside try/except:
# the non-interactive raise surfaces as the method's error string
auth = AuthConfig(interactive=False)
auth.service_account_path = None
tools = GoogleSheetsTools(auth=auth)
tools.creds = _make_creds(valid=True, scopes=["https://www.googleapis.com/auth/spreadsheets"])
tools.scopes = ["https://www.googleapis.com/auth/spreadsheets"]
tools._service = MagicMock()
with (
patch("agno.tools.google.base.Path") as mock_path,
patch("agno.tools.google.base.log_warning"),
patch("google_auth_oauthlib.flow.InstalledAppFlow") as mock_flow,
):
mock_path.return_value.exists.return_value = False
result = tools.create_duplicate_sheet("source-id")
mock_flow.from_client_config.assert_not_called()
assert result.startswith("Error duplicating spreadsheet via Drive API")
assert "interactive login is disabled" in result
# ============================================================================
# AUTH CONFIG + LEGACY PARAM CONFLICT TESTS
# ============================================================================
@@ -31,7 +31,7 @@ class TestWorkflowAgentBackgroundSingleRun:
# pairing the session-row save with a per-run write
from agno.run.workflow import WorkflowRunOutput
session, _ = await wf._aload_or_create_session(
session, _, _ = await wf._aload_or_create_session(
session_id=run_context.session_id, user_id=None, session_state=None
)
run = WorkflowRunOutput(
@@ -41,7 +41,7 @@ def make_workflow_agent_workflow(monkeypatch, executed: dict, mode: str = "persi
async def _aload_or_create_session(session_id=None, user_id=None, session_state=None):
sessions.setdefault(session_id, FakeSession(session_id))
return sessions[session_id], (session_state or {})
return sessions[session_id], (session_state or {}), None
async def asave_session(session=None, **kw):
pass
@@ -57,7 +57,7 @@ def make_workflow_agent_workflow(monkeypatch, executed: dict, mode: str = "persi
async def fake_execute_workflow_agent(user_input=None, execution_input=None, run_context=None, **kwargs):
executed["run_context_run_id"] = run_context.run_id if run_context else None
run_id = run_context.run_id if mode == "persist_same_id" else "divergent-id"
session, _ = await _aload_or_create_session(session_id="s1")
session, _, _ = await _aload_or_create_session(session_id="s1")
real_run = WorkflowRunOutput(
run_id=run_id, workflow_id=wf.id, session_id="s1", status=RunStatus.completed, content="answer"
)
@@ -132,7 +132,7 @@ async def test_background_workflow_agent_run_visible_while_executing(monkeypatch
async def hanging_then_real_execute(user_input=None, execution_input=None, run_context=None, **kwargs):
executed["run_context_run_id"] = run_context.run_id if run_context else None
await release.wait()
session, _ = await wf._aload_or_create_session(session_id="s1")
session, _, _ = await wf._aload_or_create_session(session_id="s1")
real_run = WorkflowRunOutput(
run_id=run_context.run_id,
workflow_id=wf.id,
@@ -11,17 +11,22 @@ Tests cover:
- End-to-end: Full workflow.run() -> step -> agent.run() chain with MockTestModel
"""
import time
from copy import deepcopy
from typing import Any, AsyncIterator, Dict, Iterator
from unittest.mock import AsyncMock, Mock
import pytest
from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.metrics import MessageMetrics
from agno.models.base import Model
from agno.models.response import ModelResponse
from agno.run.base import RunContext
from agno.session import WorkflowSession
from agno.workflow.step import Step
from agno.workflow.types import StepInput, StepOutput
from agno.workflow.workflow import Workflow
# =============================================================================
@@ -128,12 +133,12 @@ class TestResolveRunParamsMetadata:
resolved = wf._resolve_run_params(metadata={"campaign": "launch"})
assert resolved["metadata"] == {"campaign": "launch"}
def test_merge_class_wins_on_conflict(self, workflow_with_metadata):
"""Class-level metadata wins on key conflicts (opposite of dependencies)."""
def test_merge_callsite_wins_on_conflict(self, workflow_with_metadata):
"""Call-site metadata wins on key conflicts (matching Agent/Team)."""
resolved = workflow_with_metadata._resolve_run_params(
metadata={"project": "docs", "campaign": "launch"},
)
assert resolved["metadata"]["project"] == "blog" # class-level wins
assert resolved["metadata"]["project"] == "docs" # call-site wins
assert resolved["metadata"]["version"] == "1.0" # class-level preserved
assert resolved["metadata"]["campaign"] == "launch" # call-site added
@@ -144,6 +149,77 @@ class TestResolveRunParamsMetadata:
assert workflow_with_metadata.metadata == original
# =============================================================================
# _resolve_run_params() — Session metadata layer
# =============================================================================
class TestResolveRunParamsSessionMetadata:
"""Session-stored metadata sits between class-level and call-site values
(self < session < call-site, matching Agent/Team)."""
def test_session_only(self):
wf = Workflow(id="wf", name="WF")
resolved = wf._resolve_run_params(session_metadata={"tenant": "acme"})
assert resolved["metadata"] == {"tenant": "acme"}
def test_session_beats_class(self, workflow_with_metadata):
resolved = workflow_with_metadata._resolve_run_params(
session_metadata={"project": "docs", "tenant": "acme"},
)
assert resolved["metadata"]["project"] == "docs" # session wins
assert resolved["metadata"]["tenant"] == "acme" # session added
def test_callsite_beats_session(self):
wf = Workflow(id="wf", name="WF")
resolved = wf._resolve_run_params(
metadata={"shared": "call_value", "run_only": "r"},
session_metadata={"shared": "session_value", "session_only": "s"},
)
assert resolved["metadata"]["shared"] == "call_value" # call-site wins
assert resolved["metadata"]["run_only"] == "r"
assert resolved["metadata"]["session_only"] == "s"
def test_three_layer_merge(self, workflow_with_metadata):
resolved = workflow_with_metadata._resolve_run_params(
metadata={"project": "docs", "run_only": "r"},
session_metadata={"project": "wiki", "session_only": "s"},
)
assert resolved["metadata"] == {
"project": "docs", # call-site wins over session and class
"version": "1.0",
"session_only": "s",
"run_only": "r",
}
def test_callsite_nested_dicts_not_mutated(self, workflow_with_metadata):
# merge_dictionaries recurses in place: a shallow copy of the call-site
# dict would let session/class values contaminate its nested dicts.
callsite_meta = {"campaign": "launch", "nested": {"call": True}}
workflow_with_metadata._resolve_run_params(
metadata=callsite_meta,
session_metadata={"nested": {"session": True}},
)
assert callsite_meta == {"campaign": "launch", "nested": {"call": True}}
def test_class_metadata_not_mutated_by_session_layer(self, workflow_with_metadata):
original = deepcopy(workflow_with_metadata.metadata)
workflow_with_metadata._resolve_run_params(
metadata={"campaign": "launch"},
session_metadata={"tenant": "acme"},
)
assert workflow_with_metadata.metadata == original
def test_resolved_metadata_does_not_alias_workflow_nested_dicts(self):
# self.metadata is merged as a layer; a shallow merge would alias its
# nested dicts, so an in-run write to run_context.metadata would mutate
# the shared Workflow and bleed into the next session's run.
wf = Workflow(id="wf", name="WF", metadata={"tags": {"env": "prod"}})
resolved = wf._resolve_run_params()
resolved["metadata"]["tags"]["owner"] = "x"
assert wf.metadata == {"tags": {"env": "prod"}}
# =============================================================================
# _resolve_run_params() — Boolean Flags
# =============================================================================
@@ -780,3 +856,118 @@ class TestWorkflowRunE2EDependencies:
assert captured_kwargs["add_dependencies_to_context"] is True
assert captured_kwargs["add_session_state_to_context"] is True
# =============================================================================
# Run-level: session-stored metadata reaches RunContext (sync + async)
# =============================================================================
def _seed_workflow_session(db: InMemoryDb, session_id: str, metadata: Dict[str, Any]) -> None:
"""Insert a session record with the given metadata, as if written by an earlier deployment."""
db.upsert_session(
WorkflowSession(
session_id=session_id, workflow_id="meta-run-wf", metadata=metadata, created_at=int(time.time())
)
)
def _make_capture_workflow(db: InMemoryDb, captured: Dict[str, Any], **workflow_kwargs) -> Workflow:
"""Workflow with a single function step that records run_context.metadata."""
def capture_executor(step_input: StepInput, run_context: RunContext) -> StepOutput:
captured["metadata"] = deepcopy(run_context.metadata)
return StepOutput(content="ok")
return Workflow(
id="meta-run-wf",
name="Meta Run WF",
db=db,
steps=[Step(name="capture", executor=capture_executor)],
**workflow_kwargs,
)
class TestRunSessionMetadataPrecedence:
def test_session_metadata_visible_on_run(self):
db = InMemoryDb()
_seed_workflow_session(db, "s1", {"tenant": "acme"})
captured: Dict[str, Any] = {}
workflow = _make_capture_workflow(db, captured)
workflow.run(input="hi", session_id="s1")
assert captured["metadata"] == {"tenant": "acme"}
def test_session_beats_workflow_on_run(self):
db = InMemoryDb()
_seed_workflow_session(db, "s1", {"shared": "session_value", "session_only": "s"})
captured: Dict[str, Any] = {}
workflow = _make_capture_workflow(db, captured, metadata={"shared": "wf_value", "wf_only": "w"})
workflow.run(input="hi", session_id="s1")
assert captured["metadata"]["shared"] == "session_value" # session wins
assert captured["metadata"]["session_only"] == "s"
assert captured["metadata"]["wf_only"] == "w"
def test_callsite_beats_session_on_run(self):
db = InMemoryDb()
_seed_workflow_session(db, "s1", {"shared": "session_value"})
captured: Dict[str, Any] = {}
workflow = _make_capture_workflow(db, captured)
workflow.run(input="hi", session_id="s1", metadata={"shared": "call_value", "run_only": "r"})
assert captured["metadata"]["shared"] == "call_value" # call-site wins
assert captured["metadata"]["run_only"] == "r"
def test_run_does_not_mutate_workflow_metadata(self):
db = InMemoryDb()
_seed_workflow_session(db, "s1", {"leak": "from_session"})
captured: Dict[str, Any] = {}
workflow = _make_capture_workflow(db, captured, metadata={"env": "test"})
workflow.run(input="hi", session_id="s1")
assert captured["metadata"] == {"leak": "from_session", "env": "test"}
assert workflow.metadata == {"env": "test"}
@pytest.mark.asyncio
async def test_session_metadata_visible_on_arun(self):
# arun() pre-reads the session when the DB is sync
db = InMemoryDb()
_seed_workflow_session(db, "s1", {"shared": "session_value", "session_only": "s"})
captured: Dict[str, Any] = {}
workflow = _make_capture_workflow(db, captured, metadata={"shared": "wf_value"})
await workflow.arun(input="hi", session_id="s1", metadata={"run_only": "r"})
assert captured["metadata"]["shared"] == "session_value" # session beats workflow
assert captured["metadata"]["session_only"] == "s"
assert captured["metadata"]["run_only"] == "r"
@pytest.mark.asyncio
async def test_arun_does_not_mutate_workflow_metadata(self):
db = InMemoryDb()
_seed_workflow_session(db, "s1", {"leak": "from_session"})
captured: Dict[str, Any] = {}
workflow = _make_capture_workflow(db, captured, metadata={"env": "test"})
await workflow.arun(input="hi", session_id="s1")
assert captured["metadata"] == {"leak": "from_session", "env": "test"}
assert workflow.metadata == {"env": "test"}
def test_session_metadata_persists_across_runs(self):
"""Session metadata must persist across multiple runs.
Regression test: _update_metadata must preserve session values rather
than overwriting them with workflow defaults, otherwise session wins
only on the first run.
"""
db = InMemoryDb()
_seed_workflow_session(db, "s1", {"shared": "session_value", "session_only": "s"})
captured: Dict[str, Any] = {}
workflow = _make_capture_workflow(db, captured, metadata={"shared": "wf_value", "wf_only": "w"})
# Run 1: session wins
workflow.run(input="hi", session_id="s1")
assert captured["metadata"]["shared"] == "session_value"
# Run 2: session must STILL win (not overwritten by _update_metadata)
captured.clear()
workflow.run(input="hello again", session_id="s1")
assert captured["metadata"]["shared"] == "session_value", (
"session metadata was overwritten by _update_metadata; should persist"
)
assert captured["metadata"]["session_only"] == "s"
assert captured["metadata"]["wf_only"] == "w"