Skip to content

Commit 8b2fd94

Browse files
committed
docs: add 10 spec-aligned guides (closes #28 partially)
1 parent e8225df commit 8b2fd94

10 files changed

Lines changed: 943 additions & 0 deletions

File tree

docs/guides/auth.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Authentication
2+
3+
> Spec reference: ARCP v1.1 §6.1
4+
5+
ARCP uses **bearer token authentication**. The client presents a token; the runtime maps it to a *principal* (a string identity). All authorization decisions use the principal.
6+
7+
## Static bearer verifier
8+
9+
For development and testing, use `StaticBearerVerifier` with a hard-coded token-to-principal map:
10+
11+
```python
12+
from arcp.runtime import ARCPRuntime, RuntimeInfo, StaticBearerVerifier
13+
14+
runtime = ARCPRuntime(
15+
runtime=RuntimeInfo(name="my-service", version="1.0.0"),
16+
bearer=StaticBearerVerifier({
17+
"alice-token": "alice@example.com",
18+
"bob-token": "bob@example.com",
19+
}),
20+
)
21+
```
22+
23+
## Custom verifier
24+
25+
Implement the `BearerVerifier` protocol to integrate with any auth backend:
26+
27+
```python
28+
from arcp.runtime import BearerVerifier
29+
30+
class DatabaseVerifier:
31+
"""Look up tokens in a database."""
32+
33+
def __init__(self, db):
34+
self._db = db
35+
36+
async def verify(self, token: str) -> str | None:
37+
"""Return the principal, or None to reject the token."""
38+
row = await self._db.fetchone(
39+
"SELECT principal FROM api_tokens WHERE token = $1 AND revoked = false",
40+
token,
41+
)
42+
return row["principal"] if row else None
43+
44+
runtime = ARCPRuntime(
45+
runtime=RuntimeInfo(name="my-service", version="1.0.0"),
46+
bearer=DatabaseVerifier(db),
47+
)
48+
```
49+
50+
The `verify` method is called once per session, at connect time. Returning `None` causes the runtime to close the connection with `AuthenticationError`.
51+
52+
## JWT verifier
53+
54+
```python
55+
import jwt
56+
from arcp.runtime import BearerVerifier
57+
58+
class JWTVerifier:
59+
def __init__(self, secret: str):
60+
self._secret = secret
61+
62+
async def verify(self, token: str) -> str | None:
63+
try:
64+
payload = jwt.decode(token, self._secret, algorithms=["HS256"])
65+
return payload["sub"]
66+
except jwt.InvalidTokenError:
67+
return None
68+
```
69+
70+
## Accessing the principal in an agent
71+
72+
The session principal is available in the `JobContext`:
73+
74+
```python
75+
async def my_agent(input, ctx):
76+
principal = ctx.principal # e.g. "alice@example.com"
77+
if principal != "admin@example.com":
78+
raise PermissionError("admin only")
79+
return {"ok": True}
80+
```
81+
82+
## Authorization
83+
84+
ARCP handles *authentication* (who are you?). *Authorization* (what can you do?) is the application's responsibility. Use `ctx.principal` to make authorization decisions inside agent functions.
85+
86+
## Related
87+
88+
- [Sessions guide](sessions.md)
89+
- [Custom auth recipe](../recipes/custom-auth.md)
90+
- [Provisioned credentials recipe](../recipes/provisioned-credentials.md)
91+
- [Delegation guide](delegation.md)

docs/guides/delegation.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Delegation
2+
3+
> Spec reference: ARCP v1.1 §10
4+
5+
**Delegation** allows an agent acting as a client to downstream runtimes to carry the original caller's identity. This creates a verifiable trust chain: the downstream runtime can see who originally initiated the work.
6+
7+
## Basic pattern
8+
9+
```
10+
Alice ──► Runtime A ──► Runtime B
11+
(agent A) (agent B)
12+
```
13+
14+
Agent A on Runtime A submits a job to Runtime B. Runtime B sees Alice as the delegated principal, not agent A's service identity.
15+
16+
## Creating a delegation token
17+
18+
On Runtime A, inside the agent function:
19+
20+
```python
21+
async def orchestrator(input, ctx):
22+
# Create a scoped delegation token for the downstream call
23+
token = ctx.create_delegation_token(
24+
scopes=["summarise"], # restrict to specific agents
25+
expires_in_s=60,
26+
)
27+
28+
# Connect to Runtime B with the delegation token
29+
client_b = ARCPClient(
30+
client=ClientInfo(name="orchestrator", version="1.0.0"),
31+
token=token,
32+
)
33+
await client_b.connect(runtime_b_transport)
34+
35+
handle = await client_b.submit(agent="summarise", input=input)
36+
result = await handle.done
37+
await client_b.close()
38+
39+
return result.result
40+
```
41+
42+
## Verifying delegation on Runtime B
43+
44+
Runtime B automatically validates delegation tokens if configured with the same signing key:
45+
46+
```python
47+
runtime_b = ARCPRuntime(
48+
runtime=RuntimeInfo(name="runtime-b", version="1.0.0"),
49+
bearer=StaticBearerVerifier({"service-token": "service@example.com"}),
50+
delegation_secret="shared-signing-key", # must match Runtime A
51+
)
52+
```
53+
54+
Inside agent B, `ctx.principal` will be Alice's identity, and `ctx.delegated_by` will be the orchestrator's identity.
55+
56+
```python
57+
async def summarise(input, ctx):
58+
print(ctx.principal) # "alice@example.com"
59+
print(ctx.delegated_by) # "orchestrator@service.example.com"
60+
...
61+
```
62+
63+
## Delegation depth
64+
65+
ARCP supports multi-hop delegation (A → B → C). Each hop appends to a delegation chain. The chain is validated at each runtime.
66+
67+
## Restricting delegation scope
68+
69+
```python
70+
token = ctx.create_delegation_token(
71+
scopes=["summarise", "translate"], # only these agents may be called
72+
max_hops=2, # chain depth limit
73+
expires_in_s=120,
74+
)
75+
```
76+
77+
Attempting to call an out-of-scope agent raises `AuthorizationError`.
78+
79+
## Related
80+
81+
- [Auth guide](auth.md)
82+
- [Delegate recipe](../recipes/delegate.md)
83+
- [Multi-agent budget recipe](../recipes/multi-agent-budget.md)

docs/guides/errors.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Errors
2+
3+
> Spec reference: ARCP v1.1 §12
4+
5+
All ARCP exceptions inherit from `ARCPError`. The 15 typed exceptions map 1:1 to spec §12 error codes and are importable from `arcp`.
6+
7+
## Exception hierarchy
8+
9+
```
10+
ARCPError
11+
├── AuthenticationError (auth.failed)
12+
├── AuthorizationError (auth.unauthorized)
13+
├── AgentNotFoundError (agent.not_found)
14+
├── AgentVersionNotFoundError(agent.version_not_found)
15+
├── JobNotFoundError (job.not_found)
16+
├── JobCancelledError (job.cancelled)
17+
├── LeaseExceededError (lease.exceeded)
18+
├── LeaseExpiredError (lease.expired)
19+
├── LeaseDeniedError (lease.denied)
20+
├── DelegationError (delegation.invalid)
21+
├── ResumeError (resume.invalid)
22+
├── CapabilityError (capability.unsupported)
23+
├── RateLimitError (rate_limit.exceeded)
24+
├── InternalError (internal)
25+
└── ProtocolError (protocol)
26+
```
27+
28+
## Handling errors
29+
30+
```python
31+
from arcp import (
32+
ARCPError,
33+
AuthenticationError,
34+
LeaseExceededError,
35+
JobCancelledError,
36+
AgentNotFoundError,
37+
)
38+
39+
try:
40+
handle = await client.submit(agent="my-agent", input={"x": 1})
41+
result = await handle.done
42+
except AuthenticationError:
43+
# Token was rejected at session connect time
44+
print("Check your bearer token")
45+
except AgentNotFoundError:
46+
# Agent name not registered on the runtime
47+
print("Agent not found")
48+
except LeaseExceededError as e:
49+
# Job exceeded its cost or time budget
50+
print(f"Budget exceeded: {e.code}")
51+
except JobCancelledError:
52+
# Job was cancelled (by client or runtime)
53+
print("Job was cancelled")
54+
except ARCPError as e:
55+
# Catch-all for any other ARCP error
56+
print(f"ARCP error: {e.code}{e.message}")
57+
```
58+
59+
## Error attributes
60+
61+
Every `ARCPError` has:
62+
63+
| Attribute | Type | Description |
64+
|---|---|---|
65+
| `code` | `str` | Spec error code, e.g. `"lease.exceeded"` |
66+
| `message` | `str` | Human-readable description |
67+
| `data` | `dict \| None` | Optional structured detail |
68+
69+
## Raising errors from agents
70+
71+
Agents can signal well-typed failures by raising `ARCPError` subclasses:
72+
73+
```python
74+
from arcp import AuthorizationError
75+
76+
async def admin_only(input, ctx):
77+
if ctx.principal != "admin@example.com":
78+
raise AuthorizationError("only admin can call this agent")
79+
return {"secret": 42}
80+
```
81+
82+
The runtime converts the exception into a `job.failed` event with the matching error code.
83+
84+
## Catching errors inside agents
85+
86+
Unhandled exceptions from agent functions are caught by the runtime and emitted as `job.failed` with `InternalError`. Catch expected exceptions explicitly:
87+
88+
```python
89+
async def fragile_agent(input, ctx):
90+
try:
91+
result = await call_external_api(input)
92+
except httpx.TimeoutException as e:
93+
raise InternalError(f"external API timed out: {e}") from e
94+
return result
95+
```
96+
97+
## Related
98+
99+
- [Troubleshooting](../troubleshooting.md)
100+
- [Leases guide](leases.md)`LeaseExceededError`, `LeaseExpiredError`
101+
- [Auth guide](auth.md)`AuthenticationError`, `AuthorizationError`

docs/guides/job-events.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Job events
2+
3+
> Spec reference: ARCP v1.1 §8
4+
5+
While a job runs, the runtime emits a stream of **typed events**. Every event has a `kind` discriminator, a `seq` (monotonically increasing sequence number), and a `job_id`.
6+
7+
## Event kinds
8+
9+
| Kind | Trigger | Key fields |
10+
|---|---|---|
11+
| `job.queued` | Job accepted by runtime | `job_id`, `agent`, `seq` |
12+
| `job.started` | Agent function invoked | `job_id`, `resume_token`, `seq` |
13+
| `job.log` | `ctx.log(level, msg)` | `level`, `message`, `seq` |
14+
| `job.progress` | `ctx.progress(done, total)` | `done`, `total`, `seq` |
15+
| `job.result_chunk` | `ctx.result_chunk(chunk)` | `chunk`, `seq` |
16+
| `job.completed` | Agent returned | `result`, `seq` |
17+
| `job.failed` | Agent raised an exception | `error`, `seq` |
18+
| `job.cancelled` | Job cancelled | `seq` |
19+
| `job.heartbeat` | Runtime keep-alive | `seq` |
20+
21+
## Subscribing to events
22+
23+
### Await completion only
24+
25+
```python
26+
handle = await client.submit(agent="echo", input={"x": 1})
27+
result = await handle.done
28+
```
29+
30+
### Iterate all events
31+
32+
```python
33+
handle = await client.submit(agent="echo", input={"x": 1})
34+
async for event in handle.events():
35+
match event.kind:
36+
case "job.log":
37+
print(f"[{event.level}] {event.message}")
38+
case "job.progress":
39+
print(f"Progress: {event.done}/{event.total}")
40+
case "job.result_chunk":
41+
print(f"Chunk: {event.chunk}")
42+
case "job.completed":
43+
print(f"Result: {event.result}")
44+
break
45+
case "job.failed":
46+
raise RuntimeError(event.error)
47+
```
48+
49+
### Subscribe without a job handle
50+
51+
Use `client.subscribe()` to attach to an existing job by ID:
52+
53+
```python
54+
sub = await client.subscribe(job_id="job-abc123")
55+
async for event in sub.events():
56+
...
57+
```
58+
59+
## Event acknowledgement
60+
61+
By default, events are auto-acknowledged. For explicit backpressure control:
62+
63+
```python
64+
handle = await client.submit(
65+
agent="chunky",
66+
input={},
67+
auto_ack=False,
68+
)
69+
async for event in handle.events():
70+
process(event)
71+
await handle.ack(event.seq) # acknowledge after processing
72+
```
73+
74+
The runtime will not send the next event until the previous one is acknowledged.
75+
76+
## Typed event objects
77+
78+
Events are Pydantic models with a discriminated union on `kind`. You can use `isinstance` checks or `match` statements:
79+
80+
```python
81+
from arcp import JobLogEvent, JobProgressEvent, JobCompletedEvent
82+
83+
async for event in handle.events():
84+
if isinstance(event, JobLogEvent):
85+
logger.info(event.message)
86+
elif isinstance(event, JobCompletedEvent):
87+
return event.result
88+
```
89+
90+
## Related
91+
92+
- [Jobs guide](jobs.md)
93+
- [Stream resume guide](resume.md)
94+
- [Ack / backpressure recipe](../recipes/ack-backpressure.md)
95+
- [Subscribe recipe](../recipes/subscribe.md)

0 commit comments

Comments
 (0)