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 case dispatcher generated by the tools macro: 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 through include).
  • OpenAI wire: an assistant message always writes "content":null when carrying tool_calls (documented shape).
  • Ollama goes through its official OpenAI-compatible endpoint; a native /api/chat provider 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.

Repository

active_agent.cr

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 5 hours ago
  • October 12, 2026
License

Links
Synced at

Mon, 12 Oct 2026 06:14:28 GMT

Languages