Class: AgUi::Middleware::State

Inherits:
Object
  • Object
show all
Defined in:
lib/ag_ui/middleware/state.rb

Overview

Shared state (CoAgents) — the Ruby side of AG-UI's STATE_SNAPSHOT / STATE_DELTA channel (doc 05). Bidirectional state sync between the frontend's agent.state and the running agent.

Way in:

- seeds env[:state] from RunAgentInput.state (the state the frontend
last held, pushed via agent.setState) so downstream middleware,
tools, and the app can READ what the UI currently shows
- injects two tools the model calls to WRITE state:
  AGUISendStateSnapshot({ snapshot })  — replace the whole state
  AGUISendStateDelta({ delta })        — RFC 6902 JSON Patch ops

Way out, per state-tool call the model made:

- snapshot: env[:state] = snapshot; emits STATE_SNAPSHOT
- delta:    patches env[:state] (Hana); emits STATE_DELTA
- both append a {"status":"ok"} :tool message and emit a
TOOL_CALL_RESULT — the same shape a server tool produces — then let
the run CONTINUE (unlike a browser/client tool) so the model can act
on the new state or confirm to the user. State tools are server-side.

The TOOL_CALL_START/ARGS/END chrome is left to ToolRouter (it advertises nothing about these being "client" vs "server" — it just streams the call), exactly as A2ui leaves its render_a2ui call to ToolRouter. Compose State OUTSIDE ToolRouter:

use Loop::ToolResult
use State, state: input.state
use ToolRouter, tools: input.tools

The frontend applies the same snapshot/patch to its own store; env is kept coherent so the agent's own later reads (and further deltas) see the current value.

Constant Summary collapse

SNAPSHOT_TOOL =
"AGUISendStateSnapshot"
DELTA_TOOL =
"AGUISendStateDelta"
TOOL_NAMES =
[SNAPSHOT_TOOL, DELTA_TOOL].freeze
SNAPSHOT_DEFINITION =
{
  "name" => SNAPSHOT_TOOL,
  "description" =>
    "Replace the shared application state with a new snapshot; the " \
    "frontend re-renders from it. Send the COMPLETE next state object, " \
    "not a diff. Prefer AGUISendStateDelta for small targeted changes.",
  "parameters" => {
    "type" => "object",
    "properties" => {
      "snapshot" => {
        "type" => "object",
        "description" => "The complete new application state.",
      },
    },
    "required" => %w[snapshot],
  },
}.freeze
DELTA_DEFINITION =
{
  "name" => DELTA_TOOL,
  "description" =>
    "Apply a JSON Patch (RFC 6902) to the shared application state — an " \
    "array of {op, path, value} operations. Use for small, targeted " \
    "changes. Paths are JSON Pointers, e.g. " \
    "\"/documentEditor/activeTabId\". ops: add, replace, remove, move, " \
    "copy, test.",
  "parameters" => {
    "type" => "object",
    "properties" => {
      "delta" => {
        "type" => "array",
        "description" =>
          "JSON Patch operations, e.g. " \
          "[{\"op\":\"replace\",\"path\":\"/theme\",\"value\":\"dark\"}].",
        "items" => { "type" => "object" },
      },
    },
    "required" => %w[delta],
  },
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(app, state: nil) ⇒ State

Returns a new instance of State.



88
89
90
91
# File 'lib/ag_ui/middleware/state.rb', line 88

def initialize(app, state: nil)
  @app = app
  @initial_state = normalize(state)
end

Instance Method Details

#call(env) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/ag_ui/middleware/state.rb', line 93

def call(env)
  seed_state(env)
  advertise(env)

  before = env[:messages].length
  @app.call(env)

  # Only this iteration's assistant message — seeded history can carry
  # old state-tool turns that must not re-emit.
  appended = env[:messages][before..] || []
  assistant = appended.reverse.find { |m| m.respond_to?(:tool_call?) && m.tool_call? }
  if assistant
    apply_state_calls(env, assistant)
  end

  env
end