Skip to content

feat(decorator): support callables on subject and action fields (#45)#46

Merged
amavashev merged 3 commits intomainfrom
feat/45-dynamic-subject-action-fields
Apr 27, 2026
Merged

feat(decorator): support callables on subject and action fields (#45)#46
amavashev merged 3 commits intomainfrom
feat/45-dynamic-subject-action-fields

Conversation

@amavashev
Copy link
Copy Markdown
Contributor

Summary

Closes #45.

Widens the @cycles decorator so every previously-static field — six subject fields (tenant, workspace, app, workflow, agent, toolset), three action fields (action_kind, action_name, action_tags), and dimensions — now accepts a callable in addition to a constant. Callables are invoked with the decorated function's *args, **kwargs at reservation time, enabling per-call budget routing and dynamic action labeling.

@cycles(
    estimate=lambda req, workspace_id: req.tokens * 10,
    workspace=lambda req, workspace_id: workspace_id,        # per-call routing
    action_kind=lambda req, *_: f"llm.{req.provider}",       # dynamic label
    action_name=lambda req, *_: req.model,
    dimensions=lambda req, *_: {"region": req.region},
)
def run_request(req: ResponseRequest, workspace_id: str) -> Response: ...

Mirrors the existing estimate / actual callable contract and re-aligns the Python client with the Java client's SpEL behavior shipped in cycles-spring-boot-starter 0.2.1 (java#50).

Fallback semantics (preserved)

  • Subject callables returning None fall through to the client-config default.
  • action_kind / action_name returning None fall through to "unknown".
  • action_tags / dimensions returning None are omitted from the request.
  • Exceptions in user callables propagate fail-fast without creating a reservation.

Implementation

  • New _resolve_value(val, args, kwargs) helper in lifecycle.py (mirrors _evaluate_amount).
  • DecoratorConfig field types widened to T | Callable[..., T | None] | None.
  • _build_reservation_body signature now accepts args / kwargs; threaded through both CyclesLifecycle.execute and AsyncCyclesLifecycle.execute.
  • Public cycles(...) parameter types widened; docstring updated.

No protocol or wire-format changes — only the source of each field's value is widened.

Files

  • runcycles/lifecycle.py, runcycles/decorator.py — implementation
  • tests/test_lifecycle.py — three new test classes (TestCallableSubjectFields, TestCallableActionFields, TestCallableDimensions); existing _build_reservation_body calls updated for the new signature
  • tests/test_decorator.py — end-to-end test asserting the captured reservation body
  • README.md — new "Dynamic subject and action fields" section
  • AUDIT.md — dated entry per project policy
  • CHANGELOG.md — 0.4.0 entry
  • pyproject.toml — version bump 0.3.0 → 0.4.0

Test plan

  • python -m ruff check runcycles tests — clean
  • python -m mypy runcycles — no issues
  • python -m pytest --cov=runcycles — 382 passed / 5 skipped (live-server)
  • Coverage: 99.38% overall; lifecycle.py and decorator.py both 100% (project floor 95%)
  • Constant-string regression covered (test_constant_subject_field_regression, test_constant_action_kind_regression, test_constant_dimensions_regression)
  • None fallback covered for subject, action, and dimensions paths
  • Callable exception propagation covered (test_callable_exception_propagates)
  • Parametrized sweep across all five non-workspace subject fields
  • End-to-end decorator test asserts captured request body contains per-call values

Subject fields (tenant, workspace, app, workflow, agent, toolset),
action fields (action_kind, action_name, action_tags), and dimensions
on the @cycles decorator now accept callables in addition to constants.
Callables are invoked with the decorated function's *args, **kwargs at
reservation time, enabling per-call budget routing and dynamic action
labeling.

Mirrors the existing estimate / actual callable contract and re-aligns
the Python client with the Java client's SpEL behavior shipped in
cycles-spring-boot-starter 0.2.1 (java#50).

Fallback semantics are preserved:
- Subject callables returning None fall through to the client-config
  default (default_subject_fields).
- action_kind / action_name returning None fall through to "unknown".
- action_tags / dimensions returning None are omitted.
- Exceptions in user callables propagate fail-fast without creating
  a reservation.

Internal:
- Adds _resolve_value(val, args, kwargs) helper in lifecycle.py.
- Widens DecoratorConfig field types and the cycles() public signature.
- Threads args/kwargs through _build_reservation_body and both sync
  and async execute() paths.

No protocol or wire-format changes. Coverage holds at 99.38% with
lifecycle.py and decorator.py both at 100%.

Bumps version to 0.4.0; updates CHANGELOG.md, README.md, AUDIT.md.
Pre-merge cross-doc consistency pass:

- AUDIT.md: add `**Version:** 0.4.0` to the new entry to match the
  format of the prior streaming entry.
- CHANGELOG.md: add the `[0.4.0]` compare link at the bottom so the
  Keep-a-Changelog reference list stays complete.
- runcycles/decorator.py: extend the docstring example block with a
  callable subject/action example so the rendered Sphinx/PyPI docs
  show the new feature, not just the existing estimate/actual pattern.
- examples/decorator_usage.py: add a third decorator demonstrating
  per-call workspace + action_kind + action_name routing via lambdas.

No code or test changes. Coverage 99.38% holds, mypy clean, ruff clean.
Adds 7 tests in tests/test_streaming.py to close the last 9 uncovered
lines in runcycles/streaming.py:

- ttl_ms=0 returns None from _start_heartbeat (sync + async)
- heartbeat extend_reservation HTTP failure → warning branch
  (sync + async)
- heartbeat extend_reservation raises → exception branch (sync + async)
- AsyncStreamReservation.decision property getter

Total coverage now 100.00% across all 13 modules (was 99.38%, with
streaming.py at 97%). 389 passed / 5 skipped.

No production code changes.
@amavashev amavashev merged commit 8f8084c into main Apr 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: dynamic subject fields on @cycles decorator (callable workspace/tenant/etc.)

1 participant