Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion examples/12_streaming/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
from __future__ import annotations

import asyncio
import logging
from pathlib import Path

from strands_compose import AnsiRenderer, cli_errors, load

logger = logging.getLogger(__name__)

CONFIG = Path(__file__).parent / "config.yaml"
STARTER = "Analyse the impact of large language models on software engineering."

Expand All @@ -28,7 +31,9 @@ async def _invoke() -> None:
try:
result = await entry.invoke_async(prompt)
except Exception:
pass # nosec B110
# Errors surface to the user via the ERROR StreamEvent emitted by
# EventPublisher; log here only for local debugging.
logger.debug("entry invocation failed", exc_info=True)
finally:
await queue.close()

Expand Down
1 change: 1 addition & 0 deletions examples/13_graph_conditions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def needs_revision(context: dict) -> bool:
last_output = str(context.get("last_output", ""))
return "REVISE" in last_output.upper()


def is_approved(context: dict) -> bool:
"""Route to publisher if the review says 'APPROVED'."""
last_output = str(context.get("last_output", ""))
Expand Down
4 changes: 2 additions & 2 deletions examples/15_plugins/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@

from __future__ import annotations

from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any

from strands.vended_plugins.context_injector import ContextInjector


def _render_utc_clock(_context: Any) -> str:
"""Render the current UTC time as an injectable context block."""
now = datetime.now(timezone.utc).isoformat()
now = datetime.now(UTC).isoformat()
return f"<current_utc_time>{now}</current_utc_time>"


Expand Down
14 changes: 7 additions & 7 deletions src/strands_compose/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def _render_check_success_ansi(app_config: AppConfig) -> None:
for label, value in rows:
parts.append(f" {label.ljust(width)} : {value}")

print("\n".join(parts)) # noqa: T201
print("\n".join(parts))


def _render_check_success_json(app_config: AppConfig) -> None:
Expand All @@ -170,7 +170,7 @@ def _render_check_success_json(app_config: AppConfig) -> None:
"session_manager": app_config.session_manager.type if app_config.session_manager else None,
"hooks": _count_hooks(app_config),
}
print(json.dumps(payload)) # noqa: T201
print(json.dumps(payload))


def _cmd_check(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> None:
Expand Down Expand Up @@ -232,15 +232,15 @@ def _render_report_ansi(report: StartupReport) -> None:
report: The :class:`StartupReport` from :func:`validate_mcp`.
"""
for check in report.checks:
print(_render_check_result_ansi(check)) # noqa: T201
print(_render_check_result_ansi(check))

n_ok = len(report.passed_checks)
n_warn = len(report.warnings)
n_crit = len(report.critical_checks)
total = len(report.checks)

if total == 0:
print(_colour("✓ Load OK", _GREEN + _BOLD) + " (no MCP servers/clients configured)") # noqa: T201
print(_colour("✓ Load OK", _GREEN + _BOLD) + " (no MCP servers/clients configured)")
return

summary = f"{n_ok}/{total} passed"
Expand All @@ -250,9 +250,9 @@ def _render_report_ansi(report: StartupReport) -> None:
summary += f", {n_crit} critical"

if report.ok:
print(_colour(f"✓ Load OK — {summary}", _GREEN + _BOLD)) # noqa: T201
print(_colour(f"✓ Load OK — {summary}", _GREEN + _BOLD))
else:
print(_colour(f"✗ Load FAILED — {summary}", _RED + _BOLD)) # noqa: T201
print(_colour(f"✗ Load FAILED — {summary}", _RED + _BOLD))


def _render_report_json(report: StartupReport) -> None:
Expand All @@ -277,7 +277,7 @@ def _render_report_json(report: StartupReport) -> None:
for c in report.checks
],
}
print(json.dumps(payload)) # noqa: T201
print(json.dumps(payload))


async def _cmd_load_async(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> None:
Expand Down
10 changes: 5 additions & 5 deletions src/strands_compose/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
)

__all__ = [
"AgentDef",
"AppConfig",
"COLLECTION_KEYS",
"ConversationManagerDef",
"JOINT_NAMESPACES",
"AgentDef",
"AppConfig",
"ConfigInput",
"ConversationManagerDef",
"DelegateConnectionDef",
"DelegateOrchestrationDef",
"GraphEdgeDef",
Expand All @@ -42,10 +42,10 @@
"ModelDef",
"OrchestrationDef",
"PluginDef",
"SessionManagerDef",
"SwarmOrchestrationDef",
"ResolvedConfig",
"ResolvedInfra",
"SessionManagerDef",
"SwarmOrchestrationDef",
"interpolate",
"load",
"load_config",
Expand Down
2 changes: 1 addition & 1 deletion src/strands_compose/config/loaders/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ def parse_single_source(source: str | Path) -> dict:
raise ConfigurationError(f"Invalid YAML in inline content: {exc}") from None

if not isinstance(raw, dict):
raise ValueError(f"Config must contain a YAML mapping, got {type(raw).__name__}")
raise ConfigurationError(f"Config must contain a YAML mapping, got {type(raw).__name__}")

vars_block = raw.pop("vars", {})
raw = strip_anchors(raw)
Expand Down
6 changes: 3 additions & 3 deletions src/strands_compose/config/resolvers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
"ResolvedInfra",
"resolve_agents",
"resolve_conversation_manager",
"resolve_infra",
"resolve_hook",
"resolve_hook_entry",
"resolve_plugin",
"resolve_plugin_entry",
"resolve_infra",
"resolve_mcp_client",
"resolve_mcp_server",
"resolve_model",
"resolve_orchestrations",
"resolve_plugin",
"resolve_plugin_entry",
"resolve_session_manager",
"resolve_tools",
]
4 changes: 2 additions & 2 deletions src/strands_compose/config/resolvers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def resolve_model(model_def: ModelDef) -> Model:
A strands-compatible model instance.

Raises:
ValueError: If the custom model class is not a Model subclass.
TypeError: If the custom model class is not a Model subclass.
ImportError: If a required optional provider package is not installed.
"""
if model_def.provider.lower() in {p.lower() for p in PROVIDERS}:
Expand All @@ -42,7 +42,7 @@ def resolve_model(model_def: ModelDef) -> Model:
# Custom provider — load class from import spec
model_cls = load_object(model_def.provider, target="model class")
if not issubclass(model_cls, Model):
raise ValueError(
raise TypeError(
f"Custom model class '{model_def.provider}' must be a subclass of strands.models.Model."
)
return model_cls(model_id=model_def.model_id, **model_def.params)
2 changes: 1 addition & 1 deletion src/strands_compose/converters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .raw import RawStreamConverter

__all__ = [
"StreamConverter",
"OpenAIStreamConverter",
"RawStreamConverter",
"StreamConverter",
]
10 changes: 3 additions & 7 deletions src/strands_compose/hooks/event_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,9 @@ def _wrapper(event: StreamEvent) -> None:
logger.warning(
"hook=<%s> | event callback raised an exception", "EventPublisher", exc_info=True
)
except Exception as e:
logger.error(
"hook=<%s> | event callback raised an unexpected exception: %s: %s",
"EventPublisher",
type(e).__name__,
e,
exc_info=True,
except Exception:
logger.exception(
"hook=<%s> | event callback raised an unexpected exception", "EventPublisher"
)
raise

Expand Down
2 changes: 1 addition & 1 deletion src/strands_compose/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The compose config layer resolves YAML declarations into the objects defined her
async def run_streamable_http_async(self):
config = uvicorn.Config(self.streamable_http_app(), ...)
server = uvicorn.Server(config)
await server.serve() # blocks forever
await server.serve() # blocks forever
```

The `uvicorn.Server` instance is a **local variable** — it is never stored on `self`. When running in a background thread, uvicorn cannot install signal handlers (Python restricts `signal.signal()` to the main thread), so there is no way to trigger shutdown from outside.
Expand Down
6 changes: 3 additions & 3 deletions src/strands_compose/mcp/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Self

if TYPE_CHECKING:
from types import TracebackType
Expand Down Expand Up @@ -186,7 +186,7 @@ def stop(self) -> None:

self._started = False

def __enter__(self) -> MCPLifecycle:
def __enter__(self) -> Self:
"""Start lifecycle on context entry."""
self.start()
return self
Expand All @@ -200,7 +200,7 @@ def __exit__(
"""Stop lifecycle on context exit."""
self.stop()

async def __aenter__(self) -> MCPLifecycle:
async def __aenter__(self) -> Self:
"""Async context entry — delegates to sync :meth:`start`.

Useful with Starlette / ASGI lifespan::
Expand Down
4 changes: 4 additions & 0 deletions src/strands_compose/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ def _target() -> None:
try:
asyncio.run(self._uvicorn_server.serve()) # ty: ignore
except BaseException as exc:
# Captured here and re-raised by wait_ready() on the caller's
# thread — logged so it is not silently lost if wait_ready()
# is never called.
logger.warning("server=<%s> | MCP server thread crashed", self.name, exc_info=True)
self._error = exc
self._ready.set()

Expand Down
4 changes: 2 additions & 2 deletions src/strands_compose/renderers/ansi.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,12 @@ def __init__(

# -- Public API --------------------------------------------------------

def render(self, event: StreamEvent) -> None: # noqa: D102
def render(self, event: StreamEvent) -> None:
handler = self._handlers.get(event.type)
if handler is not None:
handler(event)

def flush(self) -> None: # noqa: D102
def flush(self) -> None:
if self._in_stream:
self._out.write("\n")
self._out.flush()
Expand Down
4 changes: 3 additions & 1 deletion src/strands_compose/startup/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ async def _check_mcp_client(name: str, client: StrandsMCPClient) -> CheckResult:
return CheckResult.passed("runtime", subject, "Client has tool registry")
return CheckResult.passed("runtime", subject, "Client is available")
except Exception as exc:
logger.debug("client=<%s> | startup check failed", name, exc_info=True)
return CheckResult.warn(
"runtime",
subject,
Expand Down Expand Up @@ -136,7 +137,7 @@ async def probe_http_health(subject: str, url: str) -> CheckResult:
resp = await asyncio.to_thread(
urllib.request.urlopen,
url,
timeout=5, # noqa: S310
timeout=5,
)
status = resp.status
if status < 500:
Expand All @@ -158,6 +159,7 @@ async def probe_http_health(subject: str, url: str) -> CheckResult:
hint=f"Service at {url} returned a server error",
)
except Exception as exc:
logger.debug("subject=<%s>, url=<%s> | startup probe failed", subject, url, exc_info=True)
return CheckResult.critical(
"network",
subject,
Expand Down
2 changes: 1 addition & 1 deletion src/strands_compose/tools/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ def resolve_tool_spec(spec: str) -> list[AgentTool]:

# No colon: file or directory
candidate = Path(spec)
if candidate.is_dir() or spec.endswith("/") or spec.endswith("\\"):
if candidate.is_dir() or spec.endswith(("/", "\\")):
return list(load_tools_from_directory(candidate))

return list(load_tools_from_file(spec))
Expand Down
6 changes: 3 additions & 3 deletions src/strands_compose/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from __future__ import annotations

from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from datetime import UTC, datetime
from enum import StrEnum
from typing import Annotated, Any, Literal

Expand Down Expand Up @@ -78,7 +78,7 @@ class StreamEvent:

type: str
agent_name: str
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=UTC))
data: dict[str, Any] = field(default_factory=dict)

def asdict(self) -> dict[str, Any]:
Expand Down Expand Up @@ -106,7 +106,7 @@ def from_dict(cls, data: dict[str, Any]) -> StreamEvent:
elif isinstance(ts_raw, datetime):
ts = ts_raw
else:
ts = datetime.now(tz=timezone.utc)
ts = datetime.now(tz=UTC)

return cls(
type=data.get("type", ""),
Expand Down
6 changes: 3 additions & 3 deletions src/strands_compose/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ class _SuppressTaskExceptions(logging.Filter):
emitted by EventPublisher, so the raw asyncio traceback is redundant.
"""

def filter(self, record: logging.LogRecord) -> bool: # noqa: A003
def filter(self, record: logging.LogRecord) -> bool:
return "exception was never retrieved" not in record.getMessage()


Expand Down Expand Up @@ -198,11 +198,11 @@ def cli_errors(*, exit_code: int = 1) -> Generator[None]:
yield
except (KeyboardInterrupt, SystemExit):
raise
except Exception as exc:
except Exception as exc: # noqa: BLE001 — CLI boundary must format any error type
msg = f"\n{_format_exception(exc)}\n"
if sys.stderr.isatty():
msg = f"\033[31m{msg}\033[0m"
print(msg, file=sys.stderr) # noqa: T201
print(msg, file=sys.stderr)
if exit_code:
sys.exit(exit_code)
finally:
Expand Down
2 changes: 1 addition & 1 deletion tests/contract/test_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ def test_public_shape_matches_reviewed_baseline():

if __name__ == "__main__": # regenerate the baseline (reviewed change only)
BASELINE.write_text(json.dumps(public_shape(), indent=2, sort_keys=True) + "\n")
print(f"wrote {BASELINE}") # noqa: T201
print(f"wrote {BASELINE}")
2 changes: 1 addition & 1 deletion tests/resolve/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_custom_provider_import_spec_returns_instance():


def test_custom_provider_not_a_model_subclass_raises():
with pytest.raises(ValueError, match="Model"):
with pytest.raises(TypeError, match="Model"):
resolve_model(model_def(provider="builtins:dict", model_id="x"))


Expand Down
7 changes: 5 additions & 2 deletions tests/runtime/test_event_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import asyncio
import logging

from strands import Agent, tool

Expand All @@ -17,6 +18,8 @@
from tests.factories import agent_def
from tests.fakes import BoomModel, FakeModel, ToolThenTextModel

logger = logging.getLogger(__name__)


async def _drain(eq) -> list:
events = []
Expand All @@ -32,8 +35,8 @@ async def _run_agent(prompt: str, agent: Agent, eq) -> list:
async def _invoke() -> None:
try:
await agent.invoke_async(prompt)
except Exception: # noqa: BLE001 — error path is asserted via events
pass
except Exception:
logger.debug("invoke_async raised (expected for BoomModel cases)", exc_info=True)
finally:
await eq.close()

Expand Down
Loading