active_agent.cr
active_agent.cr
AI agents for business applications for CRails — an original CRails component, not a port: the interface is designed from scratch (the Ruby active_agent gem is less than a year old and is not mirrored). An agent is a typed loop "LLM + tools" (Anthropic, Building Effective Agents): the model picks a tool from a developer-owned allowlist and proposes JSON arguments; execution is ordinary application code with AR-style validations — the attack surface is the tool set, same as a REST API. Coding agents that need exec/sandboxing are deliberately out of scope. One ActiveAgent::Base subclass declares provider, system, tools, max_steps; SupportAgent.ask("...") returns a Run with every step (tool calls, arguments, results, usage) recorded. Providers: OpenAI, ZAI, Kimi/Moonshot, DeepSeek, LM Studio, vLLM, Ollama (all over one OpenAI-compatible engine) plus a scripted Providers::Test. Suite: 21/21 specs, including a 6-case byte-for-byte wire oracle.
⚠️ EXPERIMENTAL FRAMEWORK, DO NOT USE IN PRODUCTION YET.
Installation
dependencies:
active_agent_cr:
github: OrelSokolov/active_agent.cr
shards install
Depends on active_support.cr (Notifications).
Usage
class CustomerSearch
include ActiveAgent::Tool
description "Find a customer by phone or name." # mandatory — the model's manual
input phone : String?, name : String? # typed fields → JSON Schema
def call : ToolResult
ToolResult.ok(Customer.find_by(phone: phone).to_public_json)
end
end
class SupportAgent < ActiveAgent::Base
provider :zai, model: "glm-4.6" # :openai | :kimi | :deepseek | :lmstudio |
system "You are a CRM operator..." # :vllm | :ollama | :test
tools CustomerSearch
max_steps 12
end
run = SupportAgent.ask("Where is my order 4311?")
run.output # => "Order 4311 is in delivery"
run.tool_steps # every tool call with arguments and result
run.usage # input/output tokens across the whole run
# streaming: text deltas as they are generated
SupportAgent.ask("Summarize...", stream: ->(delta : String) { print delta })
Errors inside a tool never escape the loop — the model sees ToolResult.error and can correct itself.
Feature comparison
There is no mirrored original, so the table compares with the de-facto stack this shard replaces: a hand-rolled OpenAI-compatible HTTP client
- ad-hoc tool dispatch + prompt strings in code.
Agents & tools
| Feature | Hand-rolled client | active_agent.cr | Status |
|---|---|---|---|
Agent loop (messages → chat → tools → repeat), max_steps stopping condition |
write it yourself | ActiveAgent::Base#ask |
✅ |
| Typed tools with compile-time JSON Schema for function calling | manual schema JSON | include ActiveAgent::Tool, description + input macros |
✅ |
| Mandatory tool docstring (ACI: "write it like a docstring for a junior") | n/a | enforced at runtime (NotImplementedError without description) |
✅ |
| Multimodal messages (text + images), system/user/assistant/tool roles, tool_calls | assemble by hand | Message.user(text, images) etc., OpenAI/Anthropic wire shape |
✅ |
| Tool errors reported back to the model instead of crashing | manual | ToolResult.error, loop never raises through |
✅ |
| Memory across runs, structured output, ECR prompt templates | P1 | not implemented | ❌ |
| Interrupt/resume (HITL), handoffs | P2 | not implemented (Run#interrupted? is a placeholder) |
❌ |
Providers
| Feature | Hand-rolled client | active_agent.cr | Status |
|---|---|---|---|
| OpenAI chat-completions wire (tools, multimodal, tool history, SSE parsing) | your code | shared Providers::OpenAICompatible engine, 6-case byte-for-byte wire oracle |
✅ |
| Streaming text deltas | your code | StreamingProvider#stream_chat, stream: argument on ask |
✅ |
| OpenAI / ZAI / Kimi / DeepSeek / LM Studio / vLLM / Ollama presets (ENV keys, endpoints, default models) | copy-paste per app | thin provider classes + DSL registry (provider :zai) |
✅ |
| Deterministic offline testing | mock the HTTP layer | Providers::Test (scripted, no network) |
✅ |
| Anthropic native messages API / tool-use blocks | — | ❌ following iteration (Anthropic works only via OpenAI-compat gateways for now) | ❌ |
| API errors with status + API message | bare KeyErrors |
ProviderError |
✅ |
Observability & integration
| Feature | Hand-rolled client | active_agent.cr | Status |
|---|---|---|---|
Audit trail: every step (tool_call, arguments, result) + token Usage |
manual logging | Run#steps, Run#usage |
✅ |
| Instrumentation | none | agent.step ActiveSupport::Notification |
✅ |
| Stream bridge to chat UIs (ActionBot/SSE) | manual | Proc(String, Nil) emitter — no cross-shard dependency |
✅ |
ask_later (ActiveJob), cost/runs observability |
— | not implemented (bridge pending) | ❌ |
MCP client (McpToolSource) |
— | ❌ P2 (server side lives in action_mcp.cr) |
❌ |
Deviations & Crystal-specific notes
- Workflow patterns (chaining/routing/parallelization) are plain Crystal code — no graph DSL, by research conclusion.
- The tool registry is a compile-time
casedispatcher generated by thetoolsmacro: no metaclass arrays, no runtime reflection. - Wire tool name = last segment of the class name in snake_case:
Oracle::CustomerSearch→"customer_search"(MCP-compatible). - Tool class methods are lifted into the includer via
macro included(module class methods are not inherited throughinclude). - OpenAI wire: an assistant message always writes
"content":nullwhen carrying tool_calls (documented shape). - Ollama goes through its official OpenAI-compatible endpoint; a native
/api/chatprovider would be a separate class. - Terminology is Provider/ProviderRequest/ProviderResponse, not "Brain" (author's decision).
Full design rationale: PLAN.md (Russian).
Testing
crystal spec # no prerequisites: Test provider + fake HTTP server
Oracle: 6 OpenAI wire cases (tools request, multimodal, tool-history request, tool_calls/final response parsing, SSE chunks) recorded from the real API shape (oracle/fixtures/openai/ → oracle/expected.yml); the Crystal specs replay them byte-for-byte. A live ZAI smoke test runs only when ZAI_API_KEY is set, else prints a skip note.
active_agent.cr
- 0
- 0
- 0
- 0
- 0
- about 5 hours ago
- October 12, 2026
Mon, 12 Oct 2026 06:14:28 GMT