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
14 changes: 7 additions & 7 deletions .lint_baselines/falsey_clobber.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,17 @@
"axonflow/client.py:1669:37",
"axonflow/client.py:1710:18",
"axonflow/client.py:1768:37",
"axonflow/client.py:2286:24",
"axonflow/client.py:2307:33",
"axonflow/client.py:2308:31",
"axonflow/client.py:2320:25",
"axonflow/client.py:2381:28",
"axonflow/client.py:2422:69",
"axonflow/client.py:2292:24",
"axonflow/client.py:2313:33",
"axonflow/client.py:2314:31",
"axonflow/client.py:2326:25",
"axonflow/client.py:2387:28",
"axonflow/client.py:2428:69",
"axonflow/client.py:300:14",
"axonflow/client.py:305:24",
"axonflow/client.py:306:20",
"axonflow/client.py:529:44",
"axonflow/client.py:6463:25",
"axonflow/client.py:6469:25",
"axonflow/client.py:845:20",
"axonflow/client.py:931:20",
"axonflow/execution.py:205:19",
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and tag v{X.Y.Z}. The release workflow's preflight checks the section
header matches the tag. -->

## [Unreleased]

### Added

- **`AuditToolCallRequest.caller_name`** (getaxonflow/axonflow-enterprise#2912,
sub-issue of epic #2905) — identifies which client made a non-LLM tool
call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). Replaces the
misleadingly-named `tool_type` field, which every real caller actually
used to identify the calling client rather than any property of the
tool. `tool_type` is kept as a deprecated input fallback (not removed):
the server resolves `caller_name` if supplied, else the legacy
`tool_type`, else a default. See
`runtime-e2e/caller_name_audit/` for the real-stack proof that
`caller_name` reaches `policy_details.caller_name` on the audit row.

## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes

Hostile-testing sweep ahead of the BukuWarung integration
Expand Down
10 changes: 8 additions & 2 deletions axonflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2163,7 +2163,7 @@ async def audit_tool_call(
trail for governance and compliance.

Args:
request: Tool call details including tool name, type, input/output,
request: Tool call details including tool name, caller, input/output,
and associated workflow/step information.

Returns:
Expand All @@ -2173,12 +2173,17 @@ async def audit_tool_call(
ValueError: If tool_name is empty.
AxonFlowError: If audit recording fails.

Note:
`tool_type` is deprecated in favor of `caller_name` and is kept
only for backward compatibility. New callers should set
`caller_name` to identify which client made the call.

Example:
>>> from axonflow.types import AuditToolCallRequest
>>> result = await client.audit_tool_call(
... AuditToolCallRequest(
... tool_name="getUserInfo",
... tool_type="mcp",
... caller_name="claude_code",
... workflow_id="wf_abc123",
... success=True,
... duration_ms=45,
Expand All @@ -2196,6 +2201,7 @@ async def audit_tool_call(
self._logger.debug(
"Audit tool call request",
tool_name=request.tool_name,
caller_name=request.caller_name,
tool_type=request.tool_type,
)

Expand Down
14 changes: 13 additions & 1 deletion axonflow/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1482,8 +1482,20 @@ class AuditToolCallRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)

tool_name: str = Field(description="Name of the tool that was called")
caller_name: str | None = Field(
default=None,
description=(
"Identifies which client made the call (e.g., claude_code, codex, cursor, openclaw). "
"Requires a platform with caller_name support (v9.11.0+); older platforms silently "
"drop this field, so also set tool_type if you need attribution there."
),
)
tool_type: str | None = Field(
default=None, description="Type of tool (e.g., mcp, api, function)"
default=None,
description=(
"Deprecated: use caller_name instead. Type of tool (e.g., mcp, api, function), "
"historically used to identify the calling client."
),
)
input: dict[str, Any] | None = Field(default=None, alias="input", description="Tool input data")
output: dict[str, Any] | None = Field(
Expand Down
76 changes: 76 additions & 0 deletions runtime-e2e/caller_name_audit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# caller_name_audit (#2912)

Real-stack proof for `AuditToolCallRequest.caller_name`
(getaxonflow/axonflow-enterprise#2912, sub-issue of epic #2905).

`tool_type` on `audit_tool_call` was misleadingly named — every real caller
(claude_code/codex/cursor/openclaw) used it to identify *which client* made
the call, not any property of the tool. `caller_name` is the correctly-named
replacement; `tool_type` is kept as a deprecated input fallback (not removed).

## What this proves

Drives the real SDK's `client.audit_tool_call(...)` against a real running
agent+orchestrator and confirms the value actually lands in
`policy_details.caller_name` on the persisted `audit_logs` row — not just
that it marshals onto the wire correctly (that's covered by the
`httpx_mock`-based unit tests in `tests/test_audit_tool_call.py`):

1. `caller_name="e2e-caller-name-probe-<run>"` → `policy_details.caller_name`
equals that value verbatim.
2. Legacy `tool_type="mcp"` with **no** `caller_name` → the deprecated
fallback still resolves `policy_details.caller_name` to `"mcp"`.

The SDK's typed `AuditLogEntry` does not declare a `policy_details` field
(it's an internal JSONB blob), so the read side uses a raw `httpx` call
against `GET /api/v1/audit/tenant/{tenant_id}` (the same route
`client.get_audit_logs_by_tenant` hits) to observe it — the only way to see
it from outside the platform.

## Enterprise-mode identity scoping

The tenant-audit read endpoint scopes non-tenant-wide callers to their own
`user_email` (#2922). This test patches the SDK's real httpx transport (not
a mock of the SDK — the same monkeypatch-the-transport technique
`runtime-e2e/x-client-id/test.py` uses) so every request, read and write,
carries a distinctive trust-gated `X-User-Email`. That requires
`AXONFLOW_TRUST_IDENTITY_HEADERS=true` on the platform (already set for the
local-dev Enterprise stack in `axonflow-enterprise/main-tree/.env`). In
Community mode, tenant-audit reads are tenant-wide regardless, so the header
is a no-op there.

## Prerequisite: platform support is not yet on `main`

`caller_name` support (axonflow-enterprise#2953) is implemented but, as of
this writing, still an open PR on the `feat/2912-caller-name-tool-type-deprecation`
branch — not yet merged to `axonflow-enterprise` main. Against a stack built
from `axonflow-enterprise` main, this test will FAIL (the 45s poll of
`GET /api/v1/audit/tenant/{tenant_id}` times out waiting for
`policy_details.caller_name`, which the server doesn't write yet) — that's
not a bug in this test, it means the platform side isn't deployed on
whatever stack you're pointed at. Point your local `axonflow-enterprise`
checkout at that branch (or a later commit that includes it) before running
this test.

## Run

```
export AXONFLOW_AGENT_URL=http://localhost:8080
export AXONFLOW_TENANT_ID=local-dev-org
export AXONFLOW_TENANT_SECRET=<AXONFLOW_CLIENT_SECRET from your local axonflow-enterprise .env>
python runtime-e2e/caller_name_audit/test.py
```

The orchestrator's `AuditLogger` batches writes (flush every 10s per
`platform/orchestrator/audit_logger.go`), so the test polls
`GET /api/v1/audit/tenant/{tenant_id}` for up to 45s before failing.

Exits non-zero if `caller_name` (or the `tool_type` fallback) does not reach
`policy_details.caller_name` on the real row.

## Companion unit coverage

`tests/test_audit_tool_call.py` exercises the same surface through
`httpx_mock` for the wire-body shape (`caller_name` alone, `tool_type` alone,
both together, both omitted). This runtime proof is the real-stack
confirmation the `runtime-e2e/` DoD gate requires.
202 changes: 202 additions & 0 deletions runtime-e2e/caller_name_audit/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Real-stack assertion: AuditToolCallRequest.caller_name reaches
policy_details.caller_name on the audit_logs row (#2912, sub-issue of
epic #2905).

getaxonflow/axonflow-enterprise PR #2953 added a `caller_name` field to
the orchestrator's tool-call audit path alongside the legacy `tool_type`
field (kept as a deprecated input fallback). The server resolves caller
identity as: caller_name if supplied -> legacy tool_type if supplied ->
a default. This test drives the real SDK's `client.audit_tool_call`
against a real running agent+orchestrator and proves the new field
actually lands in `policy_details.caller_name` on the persisted
audit_logs row -- not just that it marshals correctly onto the wire
(that's covered by the httpx_mock-based unit tests in
tests/test_audit_tool_call.py).

Sister proof to the Go SDK's runtime-e2e/caller_name_audit/main.go.

Steps:
1. Call the real SDK's `audit_tool_call` with `caller_name` set to a
distinctive, non-default value and a unique `workflow_id` so we can
find the resulting row.
2. Call it again with only the legacy `tool_type` set (no `caller_name`)
to prove the backward-compat fallback still resolves into
`policy_details.caller_name` on a real row.
3. The orchestrator's AuditLogger batches writes (flush every 10s), so
poll `GET /api/v1/audit/tenant/{tenant_id}` (the same route
`client.get_audit_logs_by_tenant` hits) until each row lands.
4. The SDK's typed `AuditLogEntry` does not surface `policy_details`
(it's an internal JSONB blob), so this step reads the raw JSON body
directly -- the only way to observe `policy_details.caller_name`
from outside the platform -- and asserts it equals what we sent.

Enterprise-mode note: the tenant-audit read endpoint scopes non-tenant-wide
callers to their own `user_email` (#2922). This test trust-gates a
distinctive `X-User-Email` on every SDK request via a small httpx patch (the
same monkeypatch-the-real-transport technique used by
`runtime-e2e/x-client-id/test.py`) so the write and the read agree on
identity. This requires `AXONFLOW_TRUST_IDENTITY_HEADERS=true` on the
platform (already the case for the local-dev Enterprise stack); in
Community mode reads are tenant-wide regardless, so this is a no-op there.

Usage::

export AXONFLOW_AGENT_URL=http://localhost:8080
export AXONFLOW_TENANT_ID=local-dev-org
export AXONFLOW_TENANT_SECRET=<your local AXONFLOW_CLIENT_SECRET>
python runtime-e2e/caller_name_audit/test.py

See ../README.md for how to register/obtain a tenant against a local stack.
"""

from __future__ import annotations

import asyncio
import base64
import os
import sys
import time
import uuid

import httpx

from axonflow import AxonFlow
from axonflow.types import AuditToolCallRequest

AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080")
CLIENT_ID = os.environ.get("AXONFLOW_TENANT_ID", "buku-e-py-e2e")
SECRET = os.environ.get("AXONFLOW_TENANT_SECRET", "buku-e-secret")

# Distinctive per-run identity so the write and the read agree on scope
# under Enterprise-mode's role-scoped audit reads (#2922), and so repeated
# runs never collide on a stale row.
_RUN_ID = uuid.uuid4().hex[:12]
SCOPE_EMAIL = f"e2e-caller-name-{_RUN_ID}@runtime-e2e.axonflow.test"

WANT_CALLER_NAME = f"e2e-caller-name-probe-{_RUN_ID}"
WORKFLOW_CALLER_NAME = f"e2e-caller-name-wf-{_RUN_ID}"
WORKFLOW_TOOL_TYPE_FALLBACK = f"e2e-tool-type-fallback-wf-{_RUN_ID}"
WANT_TOOL_TYPE_FALLBACK = "mcp"

# Batch writer flushes every 10s (platform/orchestrator/audit_logger.go);
# give it generous headroom under CI/local load.
POLL_DEADLINE_SECONDS = 45.0
POLL_INTERVAL_SECONDS = 2.0

# Patch the real httpx transport (not a mock -- we still send real
# requests over the real wire) so every SDK request carries a trust-gated
# X-User-Email identifying this test run.
_orig_request = httpx.AsyncClient.request


async def _patched_request(self, method, url, **kw):
headers = dict(kw.get("headers") or {})
headers["X-User-Email"] = SCOPE_EMAIL
kw["headers"] = headers
return await _orig_request(self, method, url, **kw)


httpx.AsyncClient.request = _patched_request


def _fail(msg: str) -> None:
sys.stderr.write(f"FAIL: {msg}\n")
sys.exit(1)


def _fetch_policy_details(workflow_id: str) -> dict[str, object]:
"""Poll GET /api/v1/audit/tenant/{tenant_id} for the row with this
workflow_id (audit_logs.request_id) and return its raw policy_details.

Uses raw httpx (not the SDK) because AuditLogEntry does not declare a
policy_details field -- it's the only way to observe it from outside
the platform.
"""
auth = base64.b64encode(f"{CLIENT_ID}:{SECRET}".encode()).decode()
headers = {
"Authorization": f"Basic {auth}",
"X-User-Email": SCOPE_EMAIL,
}
deadline = time.monotonic() + POLL_DEADLINE_SECONDS
last_seen_total = None
while True:
resp = httpx.get(
f"{AGENT_URL}/api/v1/audit/tenant/{CLIENT_ID}?limit=50",
headers=headers,
timeout=15.0,
)
if resp.status_code != 200: # noqa: PLR2004
_fail(f"GET /api/v1/audit/tenant/{CLIENT_ID} HTTP {resp.status_code}: {resp.text}")
body = resp.json()
last_seen_total = body.get("total")
for entry in body.get("entries", []):
if entry.get("request_id") == workflow_id:
return entry.get("policy_details") or {}
if time.monotonic() > deadline:
_fail(
f"audit row for workflow_id={workflow_id!r} did not appear within "
f"{POLL_DEADLINE_SECONDS}s (batch flush may not have fired; "
f"last seen total={last_seen_total})"
)
time.sleep(POLL_INTERVAL_SECONDS)


async def main() -> None:
async with AxonFlow(endpoint=AGENT_URL, client_id=CLIENT_ID, client_secret=SECRET) as client:
# 1. caller_name reaches policy_details.caller_name verbatim.
resp1 = await client.audit_tool_call(
AuditToolCallRequest(
tool_name="e2eCallerNameTool",
caller_name=WANT_CALLER_NAME,
workflow_id=WORKFLOW_CALLER_NAME,
)
)
print(
f"SDK audit_tool_call (caller_name) -> audit_id={resp1.audit_id} status={resp1.status}"
)

# 2. Legacy tool_type-only (no caller_name) still resolves into
# policy_details.caller_name -- the deprecated backward-compat path.
resp2 = await client.audit_tool_call(
AuditToolCallRequest(
tool_name="e2eToolTypeFallbackTool",
tool_type=WANT_TOOL_TYPE_FALLBACK,
workflow_id=WORKFLOW_TOOL_TYPE_FALLBACK,
)
)
print(
f"SDK audit_tool_call (tool_type fallback) -> audit_id={resp2.audit_id} status={resp2.status}"
)

policy_details_1 = _fetch_policy_details(WORKFLOW_CALLER_NAME)
got_caller_name = policy_details_1.get("caller_name")
if got_caller_name != WANT_CALLER_NAME:
_fail(
f"policy_details.caller_name = {got_caller_name!r}, want {WANT_CALLER_NAME!r} "
f"(full policy_details: {policy_details_1})"
)
print(
f"PASS: policy_details.caller_name = {got_caller_name!r} reached the audit_logs "
f"row via the real agent+orchestrator stack"
)
print(f"Wire policy_details (caller_name case): {policy_details_1}")

policy_details_2 = _fetch_policy_details(WORKFLOW_TOOL_TYPE_FALLBACK)
got_fallback = policy_details_2.get("caller_name")
if got_fallback != WANT_TOOL_TYPE_FALLBACK:
_fail(
f"backward-compat: policy_details.caller_name = {got_fallback!r} "
f"(from legacy tool_type={WANT_TOOL_TYPE_FALLBACK!r}), want {WANT_TOOL_TYPE_FALLBACK!r} "
f"(full policy_details: {policy_details_2})"
)
print(
f"PASS: legacy tool_type={WANT_TOOL_TYPE_FALLBACK!r} still resolves into "
f"policy_details.caller_name = {got_fallback!r} (deprecated fallback intact)"
)
print(f"Wire policy_details (tool_type fallback case): {policy_details_2}")

print("ALL PASS: caller_name (#2912) verified end-to-end through the real SDK + platform")


if __name__ == "__main__":
asyncio.run(main())
7 changes: 7 additions & 0 deletions tests/fixtures/wire_shape_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@
],
"spec_only": []
},
"AuditToolCallRequest": {
"note": "acknowledged-sdk-superset: tracked in getaxonflow/axonflow-enterprise#2912. SDK declares caller_name; platform accepts/writes it (PR #2953) but the community OpenAPI spec has not caught up yet.",
"sdk_only": [
"caller_name"
],
"spec_only": []
},
"Budget": {
"note": "acknowledged-sdk-superset: tracked in #1745. SDK declares `enabled`; platform emits it but spec doesn't yet declare it.",
"sdk_only": [
Expand Down
Loading
Loading