Skip to content

Commit 74ee6ce

Browse files
nficanoclaude
andcommitted
recipes: add composed-feature recipes mirroring other SDK shapes
Adds the four cross-SDK recipes (email_vendor_leases, multi_agent_budget, stream_resume, mcp_skill) so ruby-sdk matches typescript-sdk, go-sdk, rust-sdk, java-sdk, kotlin-sdk, csharp-sdk, fsharp-sdk, php-sdk, swift-sdk, and python-sdk. Each recipe follows the existing samples layout (server.rb / client.rb / run.rb) and wires a real provider gem (anthropic, ruby-openai, GLM-5 via z.ai, MCP over stdio JSON-RPC) around ARCP features. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7794fa2 commit 74ee6ce

14 files changed

Lines changed: 784 additions & 0 deletions

File tree

recipes/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Recipes
2+
3+
Composed ARCP features wired around a real LLM workload. Unlike the
4+
single-feature [`samples/`](../samples/) — which use toy agents (echo,
5+
slow timer, fake budget) — each recipe is a complete end-to-end shape
6+
with an actual provider SDK driving the agent.
7+
8+
Each subdirectory mirrors the samples layout:
9+
10+
| File | Purpose |
11+
| ----------- | ------------------------------------------------------------ |
12+
| `server.rb` | Agent handler + runtime registration |
13+
| `client.rb` | Submits the job, observes events, returns the terminal state |
14+
| `run.rb` | Wires both sides with `MemoryTransport.pair` under `Sync { }` |
15+
16+
## Running
17+
18+
```
19+
bundle exec ruby recipes/<name>/run.rb
20+
```
21+
22+
Provider gems (`anthropic`, `ruby-openai`) are not pinned in the
23+
gemspec because they are not core dependencies — install whichever
24+
ones the recipe you want to run needs:
25+
26+
```
27+
gem install anthropic ruby-openai
28+
```
29+
30+
## [multi_agent_budget/](multi_agent_budget/) — OpenAI
31+
32+
The planner decomposes a question into sub-questions and delegates each
33+
to a worker carrying a budget slice carved from its own remaining cap.
34+
After each grant the planner emits a `cost.delegate` metric on itself
35+
so the runtime's subset check at the next delegate sees an honest
36+
remaining balance. Workers that overspend trip `BudgetExhausted`;
37+
sub-questions that no longer fit are skipped before the delegate.
38+
39+
## [email_vendor_leases/](email_vendor_leases/) — Claude
40+
41+
A triage agent runs Claude through a tool-use loop with three tools, but
42+
the lease grants only the two read-only ones. When the model proposes
43+
`send_reply` the `LeaseManager#check!` raises `PermissionDenied` and
44+
the agent feeds the denial back to Claude, which observes the deny and
45+
returns a drafted-but-unsent reply. Each `inbox_read` also emits an
46+
`x-vendor.acme.email.parsed` event so dashboards recognising the
47+
namespace can render parsed metadata specially.
48+
49+
## [stream_resume/](stream_resume/) — GLM-5
50+
51+
The writer pipes GLM-5's streaming deltas into `ctx.stream_result`,
52+
batching ~200 chars per `result_chunk` envelope. Every envelope lands
53+
in the runtime's `EventLog` under a monotonic `event_seq`. The client
54+
drops the transport mid-stream, opens a fresh session with
55+
`Client.resume`, and the runtime replays every envelope past the
56+
cutoff so reassembly completes seamlessly across the gap.
57+
58+
## [mcp_skill/](mcp_skill/) — MCP bridge
59+
60+
A minimal MCP server fronts the [multi_agent_budget](multi_agent_budget/)
61+
planner so any MCP host (Claude Code, Cursor, Desktop) can call it as
62+
a single `research` tool. The bridge keeps one long-lived ARCP session;
63+
each MCP tool invocation submits a fresh planner job and returns the
64+
terminal result as the tool's text response. A Claude Code skill at
65+
[skills/research/SKILL.md](mcp_skill/skills/research/SKILL.md) tells
66+
the model when to reach for the tool.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
# email_vendor_leases client — submit triage with a lease that omits send_reply.
4+
5+
require_relative '../../samples/_harness'
6+
7+
module EmailVendorLeasesRecipe
8+
module Client
9+
def self.run(client)
10+
# the lease grants tool.call only for read-only inbox tools. send_reply
11+
# is intentionally absent — when Claude proposes that tool the agent's
12+
# lease check raises PermissionDenied and a tool_result error is fed
13+
# back. the model recovers and returns a drafted (not-sent) reply.
14+
handle = client.submit_job(
15+
agent: 'triage',
16+
lease_request: Arcp::Lease::LeaseRequest.new(
17+
capabilities: ['tool.call:inbox_list', 'tool.call:inbox_read'],
18+
budget: nil,
19+
model_use: nil,
20+
expires_at: nil
21+
)
22+
)
23+
events = handle.subscribe(client: client).to_a
24+
result = handle.get_result(client: client)
25+
[handle, events, result]
26+
end
27+
end
28+
end

recipes/email_vendor_leases/run.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'server'
4+
require_relative 'client'
5+
6+
code = Harness.run_or_exit('email_vendor_leases') do |emit|
7+
server_t, client_t = Harness.pair_memory
8+
runtime = EmailVendorLeasesRecipe.runtime
9+
client, task = Harness.open_client(server_t, client_t, runtime, client_name: 'email-vendor-leases')
10+
11+
handle, events, result = EmailVendorLeasesRecipe::Client.run(client)
12+
denied = events.select { |e| e.kind == Arcp::Job::EventKind::TOOL_RESULT && e.body.error }
13+
parsed = events.select { |e| e.kind.to_s.start_with?('x-vendor.acme.email.parsed') }
14+
15+
emit.call(
16+
'job_id' => handle.job_id,
17+
'final_status' => result.final_status,
18+
'denied_tool_calls' => denied.map { |e| e.body.error['code'] },
19+
'parsed_emails' => parsed.size,
20+
'drafted_reply_present' => !result.result.dig('drafted_reply').to_s.empty?,
21+
'sent' => result.result.dig('sent')
22+
)
23+
client.close
24+
task.stop
25+
end
26+
exit code
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# frozen_string_literal: true
2+
3+
# email_vendor_leases — Claude tool-use loop with a lease that denies send_reply.
4+
#
5+
# A triage agent receives an "inbox check" task with a lease that grants
6+
# read-only tools but NOT send_reply. Claude reads each message, emits a
7+
# vendor-extension event per parsed message so dashboards can render
8+
# them specially, and eventually decides one needs a reply. When it
9+
# tries to call send_reply the lease check denies it; Claude observes
10+
# the PERMISSION_DENIED tool_result and degrades to drafting the reply
11+
# for human review.
12+
#
13+
# Highlights: §13.4 lease violation as a *recoverable* tool_result error
14+
# (not session-fatal), §15 / §8.2 x-vendor.* event-kind namespace, and
15+
# a realistic Claude tool-use loop that handles a deny without crashing.
16+
17+
require 'anthropic'
18+
require_relative '../../samples/_harness'
19+
20+
module EmailVendorLeasesRecipe
21+
TOOLS = [
22+
{
23+
'name' => 'inbox_list',
24+
'description' => 'List recent unread messages.',
25+
'input_schema' => { 'type' => 'object', 'properties' => {} }
26+
},
27+
{
28+
'name' => 'inbox_read',
29+
'description' => 'Read one message by id.',
30+
'input_schema' => {
31+
'type' => 'object',
32+
'properties' => { 'id' => { 'type' => 'string' } },
33+
'required' => ['id']
34+
}
35+
},
36+
{
37+
'name' => 'send_reply',
38+
'description' => 'Send a reply to a message.',
39+
'input_schema' => {
40+
'type' => 'object',
41+
'properties' => { 'id' => { 'type' => 'string' }, 'body' => { 'type' => 'string' } },
42+
'required' => %w[id body]
43+
}
44+
}
45+
].freeze
46+
47+
# stand-in inbox so the recipe is self-contained — swap for IMAP/Gmail in real use
48+
INBOX = {
49+
'm1' => { 'id' => 'm1', 'from' => 'ops@acme.dev', 'subject' => 'Status',
50+
'body' => 'All quiet.', 'urgency' => 'low' },
51+
'm2' => { 'id' => 'm2', 'from' => 'ceo@acme.dev', 'subject' => 'Outage!',
52+
'body' => 'Site is down — fix asap.', 'urgency' => 'high' }
53+
}.freeze
54+
55+
def self.run_tool(name, args)
56+
case name
57+
when 'inbox_list'
58+
INBOX.values.map { |m| m.slice('id', 'subject', 'from') }
59+
when 'inbox_read'
60+
INBOX.fetch(args['id'])
61+
else
62+
raise "tool #{name} should have been denied before reaching run_tool"
63+
end
64+
end
65+
66+
HANDLER = lambda do |ctx|
67+
lease_manager = $arcp_runtime.lease_manager
68+
anthropic = Anthropic::Client.new
69+
70+
messages = [{
71+
role: 'user',
72+
content: 'Triage my inbox. Read each unread message and reply to anything urgent.'
73+
}]
74+
75+
# tool-use loop: Claude proposes a tool call, we authorize against the
76+
# lease, run it (or surface a denial), feed the result back, repeat.
77+
loop do
78+
turn = anthropic.messages(
79+
parameters: {
80+
model: 'claude-sonnet-4-6',
81+
max_tokens: 1024,
82+
tools: TOOLS,
83+
messages: messages
84+
}
85+
)
86+
87+
if turn['stop_reason'] == 'end_turn'
88+
text = turn['content'].find { |b| b['type'] == 'text' }&.dig('text').to_s
89+
ctx.finish(result: { 'drafted_reply' => text, 'sent' => false })
90+
return
91+
end
92+
93+
# append the assistant turn so the next call has full context
94+
messages << { role: 'assistant', content: turn['content'] }
95+
tool_results = []
96+
97+
turn['content'].each do |block|
98+
next unless block['type'] == 'tool_use'
99+
100+
ctx.tool_call(call_id: block['id'], tool: block['name'], args: block['input'])
101+
102+
begin
103+
# the lease grants tool.call only for the read-only tools; the
104+
# send_reply pattern is absent so this raises PermissionDenied
105+
lease_manager.check!(ctx.job_id, capability: "tool.call:#{block['name']}")
106+
rescue Arcp::Errors::PermissionDenied => e
107+
# surface the denial on the ARCP stream as a recoverable error...
108+
ctx.tool_result(call_id: block['id'], error: e.to_payload)
109+
# ...and hand it to Claude as the tool result so the model can
110+
# recover gracefully — lease violations are not session-fatal
111+
tool_results << {
112+
type: 'tool_result',
113+
tool_use_id: block['id'],
114+
content: "denied: #{e.message}",
115+
is_error: true
116+
}
117+
next
118+
end
119+
120+
result = run_tool(block['name'], block['input'])
121+
if block['name'] == 'inbox_read'
122+
# vendor-extension event — dashboards that recognise the
123+
# x-vendor.acme.* namespace render parsed metadata specially
124+
ctx.emit(
125+
kind: 'x-vendor.acme.email.parsed',
126+
body: {
127+
'message_id' => result['id'],
128+
'from' => result['from'],
129+
'subject' => result['subject'],
130+
'urgency' => result['urgency']
131+
}
132+
)
133+
end
134+
ctx.tool_result(call_id: block['id'], result: result)
135+
tool_results << { type: 'tool_result', tool_use_id: block['id'], content: result.to_json }
136+
end
137+
138+
messages << { role: 'user', content: tool_results }
139+
end
140+
end
141+
142+
def self.runtime
143+
r = Harness.runtime(agents: { 'triage' => HANDLER })
144+
$arcp_runtime = r
145+
r
146+
end
147+
end

recipes/mcp_skill/client.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
# mcp_skill client — simulates an MCP host issuing a `tools/call` for `research`.
4+
#
5+
# A real MCP host (Claude Code, Cursor, Desktop) speaks JSON-RPC over
6+
# stdio to the bridge process. Here we wire the bridge in-process and
7+
# call `handle_request` directly to keep the recipe self-contained.
8+
9+
require 'json'
10+
require_relative '../../samples/_harness'
11+
require_relative 'server'
12+
13+
module McpSkillRecipe
14+
module Client
15+
def self.run(arcp_client)
16+
# `tools/list` — what the MCP host would advertise to the model.
17+
tools = McpSkillRecipe.handle_request(
18+
{ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list' },
19+
arcp_client: arcp_client
20+
)
21+
22+
# `tools/call` — the model has decided to invoke `research`.
23+
call = McpSkillRecipe.handle_request(
24+
{
25+
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call',
26+
'params' => {
27+
'name' => 'research',
28+
'arguments' => { 'question' => 'What causes urban heat islands?', 'budget_usd' => 0.5 }
29+
}
30+
},
31+
arcp_client: arcp_client
32+
)
33+
34+
[tools, call]
35+
end
36+
end
37+
end

recipes/mcp_skill/run.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'server'
4+
require_relative 'client'
5+
6+
code = Harness.run_or_exit('mcp_skill') do |emit|
7+
server_t, client_t = Harness.pair_memory
8+
runtime = McpSkillRecipe.runtime
9+
arcp_client, task = Harness.open_client(server_t, client_t, runtime, client_name: 'mcp-bridge')
10+
11+
tools, call = McpSkillRecipe::Client.run(arcp_client)
12+
tool_names = tools.dig('result', 'tools')&.map { |t| t['name'] } || []
13+
content = call.dig('result', 'content') || []
14+
15+
emit.call(
16+
'tools_advertised' => tool_names,
17+
'call_returned_blocks' => content.size,
18+
'first_block_type' => content.first&.dig('type')
19+
)
20+
arcp_client.close
21+
task.stop
22+
end
23+
exit code

0 commit comments

Comments
 (0)