riffer 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/architecture.md +41 -2
  3. data/.release-please-manifest.json +1 -1
  4. data/CHANGELOG.md +8 -0
  5. data/docs/03_AGENTS.md +78 -62
  6. data/docs/04_TOOLS.md +3 -3
  7. data/docs/06_STREAM_EVENTS.md +1 -1
  8. data/docs/07_CONFIGURATION.md +8 -8
  9. data/docs/10_SKILLS.md +166 -0
  10. data/lib/riffer/agent.rb +143 -75
  11. data/lib/riffer/providers/anthropic.rb +7 -0
  12. data/lib/riffer/providers/base.rb +9 -0
  13. data/lib/riffer/skills/activate_tool.rb +34 -0
  14. data/lib/riffer/skills/adapter.rb +35 -0
  15. data/lib/riffer/skills/backend.rb +37 -0
  16. data/lib/riffer/skills/config.rb +58 -0
  17. data/lib/riffer/skills/context.rb +76 -0
  18. data/lib/riffer/skills/filesystem_backend.rb +79 -0
  19. data/lib/riffer/skills/frontmatter.rb +99 -0
  20. data/lib/riffer/skills/markdown_adapter.rb +27 -0
  21. data/lib/riffer/skills/xml_adapter.rb +31 -0
  22. data/lib/riffer/skills.rb +5 -0
  23. data/lib/riffer/stream_events/skill_activation.rb +22 -0
  24. data/lib/riffer/stream_events.rb +1 -0
  25. data/lib/riffer/version.rb +1 -1
  26. data/sig/generated/riffer/agent.rbs +56 -26
  27. data/sig/generated/riffer/providers/anthropic.rbs +5 -0
  28. data/sig/generated/riffer/providers/base.rbs +7 -0
  29. data/sig/generated/riffer/skills/activate_tool.rbs +17 -0
  30. data/sig/generated/riffer/skills/adapter.rbs +30 -0
  31. data/sig/generated/riffer/skills/backend.rbs +32 -0
  32. data/sig/generated/riffer/skills/config.rbs +44 -0
  33. data/sig/generated/riffer/skills/context.rbs +54 -0
  34. data/sig/generated/riffer/skills/filesystem_backend.rbs +41 -0
  35. data/sig/generated/riffer/skills/frontmatter.rbs +65 -0
  36. data/sig/generated/riffer/skills/markdown_adapter.rbs +16 -0
  37. data/sig/generated/riffer/skills/xml_adapter.rbs +15 -0
  38. data/sig/generated/riffer/skills.rbs +4 -0
  39. data/sig/generated/riffer/stream_events/skill_activation.rbs +16 -0
  40. data/sig/generated/riffer/stream_events.rbs +1 -0
  41. metadata +26 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 849df927faaf4614ad3a4833aafa5323731d3c1f4a7a8687db35ee5d341eb334
4
- data.tar.gz: 1d992d83487a673fef8714d6f72811eb94fdb5e646e33612eb18ac915257b832
3
+ metadata.gz: 12ca19d6aeb04239a6938aa523a9e7254a1a5599a9629e6a333fc385a0d94de3
4
+ data.tar.gz: dec4506d7e4800b9cf12aa817e5df14c65b8822930d50ad75b0c912a6d5099bf
5
5
  SHA512:
6
- metadata.gz: f1967690dfbe21181d4670117de7563936c05dd7d7303597378584acba35a0543de292fb9e4f72d9ed71789d4b73dc17023ce1d1c0bba27434dea93646dccc4d
7
- data.tar.gz: 71b4c1568a2b4f5f420f3546283f5c56bc979b8234734049a8df714363faf132f36bc892dabc3e62702c59c43e74ad7e697fb702c772f2bfdec6f56950c0a1fb
6
+ metadata.gz: 84b914f6160009c13ba5ac1ed6b659fe45ba13dea14ccb1dd94d529b9de4526c0c7afff84e6bbeac565b81d529e42689c07874da89f6509e4510d59756cad073
7
+ data.tar.gz: df04dc8a8ee28e70b95073b790d268c7488f0a9cd16131e1acafbfa651a9254d8868d9042607a73a5f4572e5e0a5eaee84b27965034894e4022294344d89be1f
@@ -4,7 +4,7 @@
4
4
 
5
5
  ### Agent (`lib/riffer/agent.rb`)
6
6
 
7
- Base class for AI agents. Subclass and use DSL methods `model`, `instructions`, and `structured_output` to configure. Orchestrates message flow, LLM calls, tool execution, and structured output parsing via a generate/stream loop.
7
+ Base class for AI agents. Subclass and use DSL methods `model`, `instructions`, `structured_output`, and `skills` to configure. Orchestrates message flow, LLM calls, tool execution, structured output parsing, and skill activation via a generate/stream loop.
8
8
 
9
9
  ```ruby
10
10
  class EchoAgent < Riffer::Agent
@@ -40,6 +40,22 @@ Adapters for LLM APIs. The base class uses a template-method pattern — `genera
40
40
 
41
41
  Providers are registered in `Riffer::Providers::Repository::REPO` with identifiers (e.g., `openai`, `amazon_bedrock`).
42
42
 
43
+ Each provider declares a preferred skill adapter via `self.skills_adapter` (Markdown for most, XML for Anthropic).
44
+
45
+ ### Skills (`lib/riffer/skills/`)
46
+
47
+ Support for the [Agent Skills spec](https://agentskills.io/). Skills are packaged as directories containing `SKILL.md` files with YAML frontmatter. The framework discovers skills through a pluggable backend, injects metadata into the system prompt, and provides a tool (`skill_activate`) for the LLM to load full skill instructions on demand.
48
+
49
+ - `Config` - DSL configuration object (`backend`, `adapter`, `activate`)
50
+ - `Backend` - base class interface (`list_skills`, `read_skill`)
51
+ - `FilesystemBackend` - built-in filesystem scanner
52
+ - `Frontmatter` - parsed YAML frontmatter value object with `.parse(raw)` class method
53
+ - `Context` - coordinates discovery, activation, caching, and prompt rendering for a generation cycle
54
+ - `Adapter` - base class for skill adapters (`render_catalog`, `activate_tool`)
55
+ - `MarkdownAdapter` - default Markdown skill adapter
56
+ - `XmlAdapter` - XML skill adapter for Anthropic/Claude
57
+ - `ActivateTool` - default tool the LLM calls to activate a skill
58
+
43
59
  ### Messages (`lib/riffer/messages/`)
44
60
 
45
61
  Typed message objects that extend `Riffer::Messages::Base`:
@@ -65,6 +81,10 @@ Structured events for streaming responses:
65
81
  - `WebSearchDone` - web search completion with query and sources
66
82
  - `Interrupt` - callback interrupted the agent loop
67
83
 
84
+ ### Per-Call State Reset
85
+
86
+ Each call to `generate` or `stream` resets `context`, tools, tool runtime, model, skills state, and the interrupted flag via `prepare_run`. Only the message history and cumulative `token_usage` persist across calls. This means `context:` must be passed on every call.
87
+
68
88
  ### Stopping the Loop Early
69
89
 
70
90
  Two mechanisms can stop the agent loop before the LLM finishes naturally:
@@ -75,7 +95,15 @@ Two mechanisms can stop the agent loop before the LLM finishes naturally:
75
95
 
76
96
  ### Resuming After an Interrupt
77
97
 
78
- `agent.resume` or `agent.resume_stream` continues an interrupted loop. Both accept `messages:` for cross-process resume from persisted data.
98
+ Two resume paths:
99
+
100
+ - **In-memory** — call `generate` or `stream` again with a string on the same agent instance. The message history is preserved and the new user message is appended.
101
+ - **Cross-process** — pass persisted messages as an array to a new agent instance. Array input uses messages as-is (no system message prepend). Passing an array to an agent that already has messages raises `Riffer::ArgumentError`.
102
+
103
+ ```ruby
104
+ agent.generate('Continue') # in-memory resume
105
+ MyAgent.new.stream(persisted_messages) # cross-process resume
106
+ ```
79
107
 
80
108
  On resume, `execute_pending_tool_calls` detects tool calls from the last assistant message that lack corresponding tool result messages and executes them before entering the LLM loop. This handles the case where an interrupt fired mid-way through tool execution.
81
109
 
@@ -105,6 +133,17 @@ lib/
105
133
  params.rb # Parameter collection with DSL and validation
106
134
  structured_output.rb # Structured output schema wrapper
107
135
  stream_events.rb # Stream events namespace/module
136
+ skills.rb # Skills namespace/module
137
+ skills/
138
+ config.rb # DSL configuration object
139
+ adapter.rb # Adapter base class (render_catalog, activate_tool)
140
+ markdown_adapter.rb # Default Markdown skill adapter
141
+ xml_adapter.rb # XML skill adapter for Anthropic/Claude
142
+ backend.rb # Backend base class (interface)
143
+ filesystem_backend.rb # Built-in filesystem backend
144
+ frontmatter.rb # Parsed YAML frontmatter value object with .parse
145
+ context.rb # Skills context for a generation cycle
146
+ activate_tool.rb # Default skill_activate tool
108
147
  structured_output/
109
148
  result.rb # Parse/validation result object
110
149
  helpers/
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.17.0"
2
+ ".": "0.18.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.18.0](https://github.com/janeapp/riffer/compare/riffer/v0.17.0...riffer/v0.18.0) (2026-03-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * add support for agent skills ([#151](https://github.com/janeapp/riffer/issues/151)) ([8847d54](https://github.com/janeapp/riffer/commit/8847d54dede875f207d0e0b28bb64039d3c2e69f))
14
+ * Unify generate/stream API with multi-turn support, remove resume methods ([#165](https://github.com/janeapp/riffer/issues/165)) ([58826df](https://github.com/janeapp/riffer/commit/58826df27806bb10afe1c7ad94322cc643d049f9))
15
+
8
16
  ## [0.17.0](https://github.com/janeapp/riffer/compare/riffer/v0.16.1...riffer/v0.17.0) (2026-03-06)
9
17
 
10
18
 
data/docs/03_AGENTS.md CHANGED
@@ -282,27 +282,46 @@ See [Guardrails](09_GUARDRAILS.md) for detailed documentation.
282
282
 
283
283
  ### generate
284
284
 
285
- Generates a response synchronously. Returns a `Riffer::Agent::Response` object:
285
+ Generates a response synchronously. Returns a `Riffer::Agent::Response` object.
286
+
287
+ The behavior depends on what you pass and the agent's current state:
288
+
289
+ | Input | Agent state | Behavior |
290
+ | ---------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
291
+ | **String** | No prior messages | **New conversation.** Builds system messages (instructions + skills), adds user message, calls the LLM. |
292
+ | **String** | Has messages from a prior call | **Continue conversation.** Appends the user message to the existing history and re-enters the LLM loop. Pending tool calls from a prior interrupt are executed first. |
293
+ | **Array** | No prior messages | **Restore from persisted data.** Uses the array as-is (no system messages added). Pending tool calls are executed. This is for cross-process resume. |
294
+ | **Array** | Has messages from a prior call | **Raises `Riffer::ArgumentError`.** Use a string to continue, or a new agent instance to start from a persisted array. |
295
+
296
+ **State reset per call:** Each call to `generate` or `stream` resets `context`, tools, tool runtime, model, skills state, and the interrupted flag. This means `context:` must be passed on every call — it is not carried over from a previous call. The only state that persists across calls is the message history and cumulative `token_usage`.
286
297
 
287
298
  ```ruby
288
- # Class method (recommended for simple calls)
299
+ agent.generate('Hello', context: {user_id: 123})
300
+ agent.generate('Follow up') # context is nil here — pass it again if needed
301
+ agent.generate('More', context: {user_id: 123}) # context is restored
302
+ ```
303
+
304
+ ```ruby
305
+ # New conversation (class method — recommended for simple calls)
289
306
  response = MyAgent.generate('Hello')
290
307
  puts response.content # Access the response text
291
308
  puts response.blocked? # Check if guardrail blocked (always false without guardrails)
292
309
  puts response.interrupted? # Check if a callback interrupted the loop
293
310
 
294
- # Instance method (when you need message history or callbacks)
311
+ # New conversation (instance method when you need message history or callbacks)
295
312
  agent = MyAgent.new
296
313
  agent.on_message { |msg| log(msg) }
297
314
  response = agent.generate('Hello')
298
315
  agent.messages # Access message history
299
316
 
300
- # With message objects/hashes
301
- response = MyAgent.generate([
302
- {role: 'user', content: 'Hello'},
303
- {role: 'assistant', content: 'Hi there!'},
304
- {role: 'user', content: 'How are you?'}
305
- ])
317
+ # Multi-turn conversation
318
+ agent = MyAgent.new
319
+ agent.generate('Hello')
320
+ agent.generate('Tell me more') # continues with full history
321
+
322
+ # Restore from persisted messages (cross-process resume)
323
+ agent = MyAgent.new
324
+ response = agent.generate(persisted_messages, context: {user_id: 123})
306
325
 
307
326
  # With context
308
327
  response = MyAgent.generate('Look up my orders', context: {user_id: 123})
@@ -322,10 +341,10 @@ response = MyAgent.generate([
322
341
 
323
342
  ### stream
324
343
 
325
- Streams a response as an Enumerator:
344
+ Streams a response as an Enumerator. Follows the same input rules as `generate` — a string starts a new conversation or continues an existing one, an array restores from persisted data.
326
345
 
327
346
  ```ruby
328
- # Class method (recommended for simple calls)
347
+ # New conversation (class method recommended for simple calls)
329
348
  MyAgent.stream('Tell me a story').each do |event|
330
349
  case event
331
350
  when Riffer::StreamEvents::TextDelta
@@ -337,12 +356,17 @@ MyAgent.stream('Tell me a story').each do |event|
337
356
  end
338
357
  end
339
358
 
340
- # Instance method (when you need message history or callbacks)
359
+ # New conversation (instance method when you need message history or callbacks)
341
360
  agent = MyAgent.new
342
361
  agent.on_message { |msg| persist_message(msg) }
343
362
  agent.stream('Tell me a story').each { |event| handle(event) }
344
363
  agent.messages # Access message history
345
364
 
365
+ # Multi-turn conversation
366
+ agent = MyAgent.new
367
+ agent.stream('Hello').each { |event| handle(event) }
368
+ agent.stream('Tell me more').each { |event| handle(event) }
369
+
346
370
  # With files
347
371
  MyAgent.stream('What is in this image?', files: [{data: base64_data, media_type: 'image/jpeg'}]).each do |event|
348
372
  print event.content if event.is_a?(Riffer::StreamEvents::TextDelta)
@@ -428,7 +452,9 @@ end
428
452
 
429
453
  #### Resuming an Interrupted Loop
430
454
 
431
- Use `resume` (or `resume_stream`) to continue after an interrupt. On resume, the agent automatically detects and executes any pending tool calls (tool calls from the last assistant message that lack a corresponding tool result) before re-entering the LLM loop.
455
+ There are two ways to resume after an interrupt, depending on whether the agent is still in memory or you're restoring from persisted data.
456
+
457
+ **In-memory resume** — call `generate` (or `stream`) again with a string. The agent keeps its message history, so a new string appends a user message and continues the loop. Pending tool calls from the interrupt are automatically executed first.
432
458
 
433
459
  ```ruby
434
460
  agent = MyAgent.new
@@ -438,53 +464,43 @@ response = agent.generate('Do something risky')
438
464
 
439
465
  if response.interrupted?
440
466
  approve_action(agent.messages)
441
- response = agent.resume # executes pending tools, then calls the LLM
467
+ response = agent.generate('Approved, go ahead') # executes pending tools, then calls the LLM
442
468
  end
443
469
  ```
444
470
 
445
- For cross-process resume (e.g., after a process restart or async approval), pass persisted messages via the `messages:` keyword. Accepts both message objects and hashes:
471
+ You can also resume without adding a new user message by passing a continuation like `'Continue'` the LLM will pick up from the existing context.
472
+
473
+ **Cross-process resume** — when the agent is gone (process restart, async approval, etc.), create a new agent and pass the persisted messages as an array. Array input uses messages as-is (no system messages added) and executes any pending tool calls.
446
474
 
447
475
  ```ruby
448
- # Persist messages during generation (e.g., via on_message callback)
476
+ # During generation, persist messages via on_message callback
449
477
  # Later, in a new process:
450
478
  agent = MyAgent.new
451
- response = agent.resume(messages: persisted_messages, context: {user_id: 123})
479
+ response = agent.generate(persisted_messages, context: {user_id: 123})
452
480
 
453
481
  # Or resume in streaming mode:
454
- agent.resume_stream(messages: persisted_messages).each do |event|
482
+ agent = MyAgent.new
483
+ agent.stream(persisted_messages).each do |event|
455
484
  # handle stream events
456
485
  end
457
486
  ```
458
487
 
459
- When called without `messages:`, resumes from in-memory state. When called with `messages:`, reconstructs state from persisted data. No prior interruption is required in either case.
460
-
461
- ### resume
462
-
463
- Continues an agent loop synchronously. Returns a `Riffer::Agent::Response` object:
464
-
465
- ```ruby
466
- # In-memory resume after an interrupt
467
- response = agent.resume
488
+ **Important:** You cannot pass an array to an agent that already has messages. This raises `Riffer::ArgumentError` because it would silently discard the existing history. Use a string to continue, or create a new agent instance for cross-process resume.
468
489
 
469
- # Cross-process resume from persisted messages
470
- response = agent.resume(messages: persisted_messages, context: {user_id: 123})
471
- ```
490
+ #### Building System Messages for Persistence
472
491
 
473
- ### resume_stream
492
+ Use `generate_instruction_message` and `generate_skills_message` to generate system messages independently. This is useful for database persistence workflows where you need to store and later reconstruct message histories.
474
493
 
475
- Continues an agent loop as a streaming Enumerator. Accepts the same arguments as `resume`:
494
+ Both methods return a `Riffer::Messages::System` or `nil` (when unconfigured). They accept an optional `context:` keyword, just like `generate`.
476
495
 
477
496
  ```ruby
478
- # In-memory resume
479
- agent.resume_stream.each do |event|
480
- # handle stream events
481
- end
482
-
483
- # Cross-process resume
484
497
  agent = MyAgent.new
485
- agent.resume_stream(messages: persisted_messages).each do |event|
486
- # handle stream events
487
- end
498
+ sys = agent.generate_instruction_message(context: ctx) # => Riffer::Messages::System or nil
499
+ skills = agent.generate_skills_message(context: ctx) # => Riffer::Messages::System or nil
500
+
501
+ # Store in DB, then later resume in a new process:
502
+ messages = [sys, skills, user_msg].compact
503
+ MyAgent.new.generate(messages, context: ctx)
488
504
  ```
489
505
 
490
506
  ### interrupt!
@@ -516,18 +532,18 @@ Returns `nil` if the provider doesn't report usage, or a `Riffer::TokenUsage` ob
516
532
 
517
533
  ## Response Attributes
518
534
 
519
- `Riffer::Agent::Response` is returned by `generate` and `resume`:
535
+ `Riffer::Agent::Response` is returned by `generate`:
520
536
 
521
- | Attribute | Type | Description |
522
- | ------------------ | --------------------------- | -------------------------------------------------- |
523
- | `content` | `String` | The response text |
537
+ | Attribute | Type | Description |
538
+ | ------------------- | --------------------------- | -------------------------------------------------- |
539
+ | `content` | `String` | The response text |
524
540
  | `structured_output` | `Hash` / `nil` | Parsed and validated structured output (see below) |
525
- | `blocked?` | `Boolean` | `true` if a guardrail tripwire fired |
526
- | `tripwire` | `Tripwire` / `nil` | The guardrail tripwire that blocked the request |
527
- | `modified?` | `Boolean` | `true` if a guardrail modified the content |
528
- | `modifications` | `Array` | List of guardrail modifications applied |
529
- | `interrupted?` | `Boolean` | `true` if the loop was interrupted |
530
- | `interrupt_reason` | `String` / `Symbol` / `nil` | The reason passed to `throw :riffer_interrupt` |
541
+ | `blocked?` | `Boolean` | `true` if a guardrail tripwire fired |
542
+ | `tripwire` | `Tripwire` / `nil` | The guardrail tripwire that blocked the request |
543
+ | `modified?` | `Boolean` | `true` if a guardrail modified the content |
544
+ | `modifications` | `Array` | List of guardrail modifications applied |
545
+ | `interrupted?` | `Boolean` | `true` if the loop was interrupted |
546
+ | `interrupt_reason` | `String` / `Symbol` / `nil` | The reason passed to `throw :riffer_interrupt` |
531
547
 
532
548
  ### response.structured_output
533
549
 
@@ -629,7 +645,7 @@ Callbacks registered with `on_message` can call `agent.interrupt!` (or `throw :r
629
645
  - **When to use:** Flow control that depends on runtime decisions — human-in-the-loop approval, budget tracking, conditional pausing.
630
646
  - **Response:** `response.interrupted?` returns `true`, `response.interrupt_reason` contains the optional reason.
631
647
  - **Streaming:** Yields an `Interrupt` event with a `reason` attribute.
632
- - **Resumable:** Yes. Call `resume` or `resume_stream` to continue. Pending tool calls are automatically executed before the LLM loop resumes.
648
+ - **Resumable:** Yes. Call `generate('Continue')` or `stream('Continue')` on the same agent instance to resume. For cross-process resume, pass persisted messages as an array to a new agent. Pending tool calls are automatically executed before the LLM loop resumes.
633
649
 
634
650
  ```ruby
635
651
  agent = MyAgent.new
@@ -640,7 +656,7 @@ end
640
656
  response = agent.generate('Do something risky')
641
657
  response.interrupted? # => true
642
658
  response.interrupt_reason # => "approval needed"
643
- response = agent.resume # continues where it left off
659
+ response = agent.generate('Approved, continue') # continues where it left off
644
660
  ```
645
661
 
646
662
  ### Max Steps Limit
@@ -650,7 +666,7 @@ The `max_steps` class method caps the number of LLM call steps in the tool-use l
650
666
  - **When to use:** Safety net to prevent runaway tool-use loops — useful when agents have access to many tools or operate autonomously.
651
667
  - **Response:** `response.interrupted?` returns `true`, `response.interrupt_reason` is `:max_steps`.
652
668
  - **Streaming:** Yields an `Interrupt` event with `reason: :max_steps`.
653
- - **Resumable:** Yes. Call `resume` or `resume_stream` to continue. Pending tool calls are automatically executed before the LLM loop resumes.
669
+ - **Resumable:** Yes. Call `generate('Continue')` or `stream('Continue')` on the same agent instance to resume. For cross-process resume, pass persisted messages as an array to a new agent. Pending tool calls are automatically executed before the LLM loop resumes.
654
670
 
655
671
  ```ruby
656
672
  class MyAgent < Riffer::Agent
@@ -669,11 +685,11 @@ If a guardrail, provider call, or other internal code raises an exception, it pr
669
685
 
670
686
  ### Comparison
671
687
 
672
- | | Guardrail Tripwire | Callback Interrupt | Max Steps Limit |
673
- | ------------- | ------------------------------------ | -------------------------------- | -------------------------------- |
674
- | Defined | At class level (`guardrail :before`) | At instance level (`on_message`) | At class level (`max_steps 8`) |
675
- | Fires | Automatically on every request | When callback logic decides | When step count reaches limit |
676
- | Resumable | No | Yes (`resume` / `resume_stream`) | Yes (`resume` / `resume_stream`) |
677
- | Response flag | `blocked?` | `interrupted?` | `interrupted?` |
678
- | Stream event | `GuardrailTripwire` | `Interrupt` | `Interrupt` |
679
- | Purpose | Policy enforcement | Flow control | Runaway loop prevention |
688
+ | | Guardrail Tripwire | Callback Interrupt | Max Steps Limit |
689
+ | ------------- | ------------------------------------ | ------------------------------------ | ------------------------------------ |
690
+ | Defined | At class level (`guardrail :before`) | At instance level (`on_message`) | At class level (`max_steps 8`) |
691
+ | Fires | Automatically on every request | When callback logic decides | When step count reaches limit |
692
+ | Resumable | No | Yes (call `generate`/`stream` again) | Yes (call `generate`/`stream` again) |
693
+ | Response flag | `blocked?` | `interrupted?` | `interrupted?` |
694
+ | Stream event | `GuardrailTripwire` | `Interrupt` | `Interrupt` |
695
+ | Purpose | Policy enforcement | Flow control | Runaway loop prevention |
data/docs/04_TOOLS.md CHANGED
@@ -380,9 +380,9 @@ By default, tool calls are executed sequentially in the current thread using `Ri
380
380
 
381
381
  ### Built-in Runtimes
382
382
 
383
- | Runtime | Description |
384
- |---------|-------------|
385
- | `Riffer::ToolRuntime::Inline` | Executes tool calls sequentially (default) |
383
+ | Runtime | Description |
384
+ | ------------------------------- | ---------------------------------------------- |
385
+ | `Riffer::ToolRuntime::Inline` | Executes tool calls sequentially (default) |
386
386
  | `Riffer::ToolRuntime::Threaded` | Executes tool calls concurrently using threads |
387
387
 
388
388
  ### Per-Agent Configuration
@@ -218,7 +218,7 @@ agent.stream("Hello").each do |event|
218
218
  end
219
219
  ```
220
220
 
221
- After an interrupt, use `resume_stream` to continue the loop. See [Agents - Interrupting the Agent Loop](03_AGENTS.md#interrupting-the-agent-loop) for details.
221
+ After an interrupt, call `stream` again with a string to continue the loop. See [Agents Resuming an Interrupted Loop](03_AGENTS.md#resuming-an-interrupted-loop) for details.
222
222
 
223
223
  ### TokenUsageDone
224
224
 
@@ -84,11 +84,11 @@ Riffer.configure do |config|
84
84
  end
85
85
  ```
86
86
 
87
- | Value | Description |
88
- |-------|-------------|
87
+ | Value | Description |
88
+ | ------------------------------ | ------------------------------------------------------------------------------------------------- |
89
89
  | `Riffer::ToolRuntime` subclass | Instantiated automatically (e.g., `Riffer::ToolRuntime::Inline`, `Riffer::ToolRuntime::Threaded`) |
90
- | `Riffer::ToolRuntime` instance | Custom runtime with specific options |
91
- | `Proc` | Dynamic resolution |
90
+ | `Riffer::ToolRuntime` instance | Custom runtime with specific options |
91
+ | `Proc` | Dynamic resolution |
92
92
 
93
93
  Per-agent configuration overrides this global default. See [Tools — Tool Runtime](04_TOOLS.md#tool-runtime-experimental) for details.
94
94
 
@@ -145,10 +145,10 @@ end
145
145
 
146
146
  Options are passed through to the [Bedrock Converse API](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/BedrockRuntime/Client.html#converse-instance_method).
147
147
 
148
- | Option | Description |
149
- | ------------------------------------ | ----------------------------------------------------------------- |
150
- | `inference_config` | Hash with `max_tokens`, `temperature`, `top_p`, `stop_sequences` |
151
- | `additional_model_request_fields` | Hash for model-specific params (e.g., `top_k` for Claude) |
148
+ | Option | Description |
149
+ | --------------------------------- | ---------------------------------------------------------------- |
150
+ | `inference_config` | Hash with `max_tokens`, `temperature`, `top_p`, `stop_sequences` |
151
+ | `additional_model_request_fields` | Hash for model-specific params (e.g., `top_k` for Claude) |
152
152
 
153
153
  ```ruby
154
154
  class MyAgent < Riffer::Agent
data/docs/10_SKILLS.md ADDED
@@ -0,0 +1,166 @@
1
+ # Skills
2
+
3
+ Skills are packaged AI agent capabilities per the [Agent Skills spec](https://agentskills.io/). Each skill is a directory containing a `SKILL.md` file with YAML frontmatter and Markdown instructions. The framework discovers skills through a pluggable backend, injects a compact catalog into the system prompt (~50 tokens/skill), and provides a tool for the LLM to activate skills on demand.
4
+
5
+ ## Creating a Skill
6
+
7
+ Create a directory with a `SKILL.md` file:
8
+
9
+ ```
10
+ .skills/
11
+ code-review/
12
+ SKILL.md
13
+ data-analysis/
14
+ SKILL.md
15
+ ```
16
+
17
+ Each `SKILL.md` has YAML frontmatter and a Markdown body:
18
+
19
+ ```markdown
20
+ ---
21
+ name: code-review
22
+ description: Reviews code for quality, style, and potential issues.
23
+ ---
24
+
25
+ You are a code review assistant.
26
+
27
+ Review the code for:
28
+
29
+ - Style issues
30
+ - Potential bugs
31
+ - Performance problems
32
+ ```
33
+
34
+ **Required frontmatter fields:**
35
+
36
+ - `name` — lowercase alphanumeric with hyphens, 1-64 chars (must match directory name)
37
+ - `description` — 1-1024 chars, helps the LLM decide when to activate
38
+
39
+ Additional frontmatter keys are passed through as metadata.
40
+
41
+ ## Configuring an Agent
42
+
43
+ Use the `skills` block DSL to configure skills:
44
+
45
+ ```ruby
46
+ class MyAgent < Riffer::Agent
47
+ model "openai/gpt-4o"
48
+ instructions "You are a helpful assistant."
49
+ skills do
50
+ backend Riffer::Skills::FilesystemBackend.new(".skills")
51
+ end
52
+ end
53
+ ```
54
+
55
+ Multiple directories can be scanned (first-path-wins for duplicates):
56
+
57
+ ```ruby
58
+ skills do
59
+ backend Riffer::Skills::FilesystemBackend.new(".skills", "~/.riffer/skills")
60
+ end
61
+ ```
62
+
63
+ ### Dynamic Backend via Proc
64
+
65
+ ```ruby
66
+ skills do
67
+ backend ->(context) { tenant_backend(context[:tenant_id]) }
68
+ end
69
+ ```
70
+
71
+ ### Custom Adapter
72
+
73
+ The adapter controls how the skill catalog is rendered in the system prompt and which tool the LLM calls to activate a skill. The adapter is auto-selected by provider (Markdown for most, XML for Anthropic). Override with:
74
+
75
+ ```ruby
76
+ skills do
77
+ backend Riffer::Skills::FilesystemBackend.new(".skills")
78
+ adapter Riffer::Skills::XmlAdapter
79
+ end
80
+ ```
81
+
82
+ ### Activated Skills
83
+
84
+ Load skill instructions into the system prompt at startup (no tool call needed):
85
+
86
+ ```ruby
87
+ skills do
88
+ backend Riffer::Skills::FilesystemBackend.new(".skills")
89
+ activate ["code-review"]
90
+ end
91
+ ```
92
+
93
+ Accepts a Proc for dynamic resolution:
94
+
95
+ ```ruby
96
+ skills do
97
+ backend Riffer::Skills::FilesystemBackend.new(".skills")
98
+ activate ->(context) { context[:active_skills] || [] }
99
+ end
100
+ ```
101
+
102
+ ## How It Works
103
+
104
+ 1. **Discovery** — At the start of `generate`/`stream`, the backend's `list_skills` returns frontmatter for all available skills.
105
+ 2. **Catalog injection** — The adapter formats the catalog and appends it to the system prompt.
106
+ 3. **Activation** — When the LLM matches a task to a skill, it calls the `skill_activate` tool with the skill name. The tool returns the full SKILL.md body.
107
+ 4. **Execution** — The LLM follows the skill's instructions to complete the task.
108
+
109
+ ## Custom Backends
110
+
111
+ Implement `Riffer::Skills::Backend` for non-filesystem storage:
112
+
113
+ ```ruby
114
+ class DatabaseBackend < Riffer::Skills::Backend
115
+ def list_skills
116
+ # Return Array[Riffer::Skills::Frontmatter]
117
+ end
118
+
119
+ def read_skill(name)
120
+ # Return String (skill body)
121
+ # Raise Riffer::ArgumentError if not found
122
+ end
123
+ end
124
+ ```
125
+
126
+ ## Custom Adapters
127
+
128
+ Subclass `Riffer::Skills::Adapter` to customize how the skill catalog is rendered and which tool the LLM uses to activate skills:
129
+
130
+ ```ruby
131
+ class CustomAdapter < Riffer::Skills::Adapter
132
+ def render_catalog(skills)
133
+ # Return String (skill catalog for the system prompt)
134
+ # Use `activate_tool.name` to reference the activation tool
135
+ end
136
+
137
+ def activate_tool
138
+ # Return a Riffer::Tool subclass
139
+ # Defaults to Riffer::Skills::ActivateTool
140
+ Riffer::Skills::ActivateTool
141
+ end
142
+ end
143
+ ```
144
+
145
+ The built-in adapters are `Riffer::Skills::MarkdownAdapter` (default) and `Riffer::Skills::XmlAdapter` (used by Anthropic).
146
+
147
+ ## Accessing Skills in Tools
148
+
149
+ The skills context (`Riffer::Skills::Context`) is available via `context[:skills]` during execution:
150
+
151
+ ```ruby
152
+ class SkillSearchTool < Riffer::Tool
153
+ identifier "skill_search"
154
+ description "Searches available skills."
155
+
156
+ params do
157
+ required :query, String
158
+ end
159
+
160
+ def call(context:, query:)
161
+ skills_context = context[:skills] # Riffer::Skills::Context
162
+ matches = skills_context.skills.values.select { |s| s.description.include?(query) }
163
+ json(matches.map { |s| {name: s.name, description: s.description} })
164
+ end
165
+ end
166
+ ```