mirror of
https://github.com/agno-agi/agno.git
synced 2026-10-11 22:09:58 +00:00
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:
co-authored by
Claude Fable 5
Mustafa Esoofally
parent
b99ba1b410
commit
8fbf53a291
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
"""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
---------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user