ask-app-server 0.1.2 → 0.4.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -0
- data/README.md +60 -23
- data/lib/ask/app_server/agent_adapter.rb +239 -57
- data/lib/ask/app_server/cli.rb +31 -16
- data/lib/ask/app_server/connection.rb +73 -0
- data/lib/ask/app_server/event_translator.rb +173 -117
- data/lib/ask/app_server/herdr_reporter.rb +201 -0
- data/lib/ask/app_server/server.rb +333 -185
- data/lib/ask/app_server/session_manager.rb +166 -80
- data/lib/ask/app_server/socket_server.rb +116 -0
- data/lib/ask/app_server/version.rb +1 -1
- data/lib/ask/app_server.rb +2 -0
- data/lib/ask-app-server.rb +4 -0
- metadata +20 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58a83cf6f150e43e40a2e188ca05c8d196fc5225c1439f87d1797383836ce764
|
|
4
|
+
data.tar.gz: 41efacd1430104a977fbd05f334fb89f4e8477e23d12cad30f3601dca505ae86
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 250a42b9bfe5a2c4491083a643886fe41931b00c723640ddc56658b4d02880c2000c74e7cc1fce52cdf0e2bc0373ccd70fc4a6c288e2699ecbfc9c78c38402f8
|
|
7
|
+
data.tar.gz: 3d7918f44bc5cd5610faf1be139c3f23e6b739dd87b7d7fe35afe14d9c296deb877220896621dbb8f441f9741623b0178288f09f5ae1c0e8e7fb0c1b4aaf165b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,104 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-08-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Herdr citizenship** — `HerdrReporter` keeps a herdr sidebar accurate
|
|
8
|
+
with first-party state when the host runs inside a herdr pane. Reads
|
|
9
|
+
`HERDR_SOCKET_PATH`/`HERDR_PANE_ID` (injected into every pane), reports
|
|
10
|
+
the aggregate pane state (`working` | `blocked` | `idle`) via
|
|
11
|
+
`pane.report_agent` on change, the ask session id via
|
|
12
|
+
`pane.report_agent_session` (for future resume), and model/session
|
|
13
|
+
tokens via `pane.report_metadata`. `HerdrReporter.attach(session_manager)`
|
|
14
|
+
returns nil outside herdr; the CLI attaches automatically.
|
|
15
|
+
- **Session event observers** — `SessionManager#on_session_event` receives
|
|
16
|
+
every canonical event across all sessions as it happens; the observer
|
|
17
|
+
chain hooks the single emission point (`EventTranslator#on_event` →
|
|
18
|
+
`AgentAdapter#on_event`). Observers attach after store registration so
|
|
19
|
+
the store is settled, and the buffered `session.created` is replayed.
|
|
20
|
+
|
|
21
|
+
## [0.3.0] - 2026-08-11
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Unix-socket transport for multi-client attach** — `SocketServer`
|
|
26
|
+
(`ask-app-server --socket PATH` or `ASK_APP_SERVER_SOCKET`). Any number
|
|
27
|
+
of clients (terminal TUI, web console, bots) connect to the same host
|
|
28
|
+
and share sessions; each connection has its own reader thread, and
|
|
29
|
+
responses are routed back to the requesting connection. The stdio
|
|
30
|
+
transport runs alongside.
|
|
31
|
+
- **Per-connection event delivery cursors** — the host no longer drains a
|
|
32
|
+
shared buffer. Each `Connection` tracks the last delivered seq per
|
|
33
|
+
subscribed session; `session/subscribe` with `afterSeq` replays exactly
|
|
34
|
+
what the client hasn't seen, and each client receives each event
|
|
35
|
+
exactly once (dedup by seq). `session/event` notifications fan out to
|
|
36
|
+
every subscribed connection.
|
|
37
|
+
- **Host-side contract enforcement** — incoming requests on the canonical
|
|
38
|
+
surface are validated against `Ask::SessionProtocol::Methods` before
|
|
39
|
+
dispatch (invalid params → `-32602 invalid_params`). Params are
|
|
40
|
+
normalized to string keys so Ruby clients may send symbols.
|
|
41
|
+
- **`Connection` class** — per-client I/O plus subscription cursors;
|
|
42
|
+
`EventTranslator` retains a capped event log (2000) for replay/polling.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- `Server` is now the transport-agnostic protocol engine: `dispatch(msg,
|
|
47
|
+
connection)` processes one message and writes responses back to the
|
|
48
|
+
sending connection; `push_pending` delivers events to subscribed
|
|
49
|
+
connections. `Server#start` is the stdio transport (one connection).
|
|
50
|
+
- `session/subscribe` now registers the *connection's* cursor (replay
|
|
51
|
+
default) instead of a global store flag.
|
|
52
|
+
|
|
53
|
+
### Removed
|
|
54
|
+
|
|
55
|
+
- `SessionManager#pending_notifications` (superseded by cursor-based
|
|
56
|
+
delivery).
|
|
57
|
+
|
|
58
|
+
## [0.2.0] - 2026-08-11
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **The host now speaks the canonical Ask::SessionProtocol** (ask-session-protocol
|
|
63
|
+
gem). The event vocabulary is the canonical contract — `turn.started`,
|
|
64
|
+
`model.streaming`, `model.thinking`, `tool.use/delta/result`,
|
|
65
|
+
`approval.required/updated`, `plan.proposed/approved/rejected`,
|
|
66
|
+
`todos.updated`, `session.created/ended`, `turn.completed/failed/aborted`,
|
|
67
|
+
`error` — in the validated `{type, seq, payload}` envelope with string-keyed
|
|
68
|
+
payloads. The old app-server vocabulary (`tool.updated`, `message.upserted`,
|
|
69
|
+
symbol-keyed payloads) is gone; `initialize` negotiates
|
|
70
|
+
`Ask::SessionProtocol::PROTOCOL_VERSION` and advertises capabilities.
|
|
71
|
+
- **Approvals are canonical resolvable interactions.** The blocking
|
|
72
|
+
PermissionHandler flow is replaced by the ask-agent approval queue: gated
|
|
73
|
+
tools pause the turn, `approval.required` streams to every client, and any
|
|
74
|
+
client resolves by id via `interaction/approve`/`interaction/reject` (or
|
|
75
|
+
approve/reject-all). `interaction/list` reports pending approvals.
|
|
76
|
+
- **Steer-based mid-execution injection.** `session/send` returns
|
|
77
|
+
`{accepted, status, turnId}` with `steered | queued | stale` semantics:
|
|
78
|
+
idle sessions run the prompt, running sessions queue it for the next turn
|
|
79
|
+
boundary (no abort), and `expectedTurnId` guards staleness. Queued steers
|
|
80
|
+
drain automatically when the running turn completes.
|
|
81
|
+
- **New methods** — `session/close`, `interaction/list|approve|reject|
|
|
82
|
+
approve-all|reject-all`, `plan/approve|reject`. `interaction/respond`
|
|
83
|
+
returns `-32009 not_implemented` until elicitation exists in the runtime.
|
|
84
|
+
- `workspace/readState` returns the canonical `{workspace: {path, name,
|
|
85
|
+
gitBranch, mode}}` shape (legacy `settings` retained for compatibility).
|
|
86
|
+
- `session/subscribe` returns `{subscription: {sessionId, deliveryKind}}`
|
|
87
|
+
and can include a replay snapshot.
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- `session.created` / `session.ended` lifecycle events, emitted on session
|
|
92
|
+
start and close.
|
|
93
|
+
- Approval modes: `mode: "plan"` enables plan mode (read-only until
|
|
94
|
+
`plan/approve`); `"auto"` admits an inspectable queue that never blocks.
|
|
95
|
+
|
|
96
|
+
### Removed
|
|
97
|
+
|
|
98
|
+
- `Server#register_permission_handler` and the SessionManager permission
|
|
99
|
+
handler wiring (the PermissionHandler class remains for direct use).
|
|
100
|
+
- `AgentAdapter#inject_message` (replaced by steer semantics).
|
|
101
|
+
|
|
3
102
|
## [0.1.0] - 2026-07-23
|
|
4
103
|
|
|
5
104
|
### Added
|
data/README.md
CHANGED
|
@@ -2,21 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/ask-app-server)
|
|
4
4
|
|
|
5
|
-
**JSON-RPC/stdio
|
|
6
|
-
behind the
|
|
7
|
-
driving an agent as a service,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
**JSON-RPC/stdio session host for ask-rb agents.** Exposes `Ask::Agent::Session`
|
|
6
|
+
behind the canonical [ask-session-protocol](https://github.com/ask-rb/ask-session-protocol)
|
|
7
|
+
wire contract: one versioned interface for driving an agent as a service,
|
|
8
|
+
with sessions, streamed canonical events, resolvable interactions
|
|
9
|
+
(approvals, plans), and turn lifecycle. Any client that speaks the protocol
|
|
10
|
+
can drive your agent, and the client never needs to know it's talking to
|
|
11
|
+
Ruby. The method surface is app-server compatible — the same shape Codex
|
|
12
|
+
and ZCode expose — while the event vocabulary and interaction model are the
|
|
13
|
+
ask ecosystem's own canonical contract.
|
|
13
14
|
|
|
14
15
|
## What is this?
|
|
15
16
|
|
|
16
|
-
`ask-app-server` turns an ask-rb agent into a **programmable service** that speaks JSON-RPC over stdio. Any client that
|
|
17
|
+
`ask-app-server` turns an ask-rb agent into a **programmable service** that speaks JSON-RPC over stdio. Any client that speaks the canonical session protocol can drive your agent:
|
|
17
18
|
|
|
18
|
-
- **IDE extensions and editors** — stream model deltas
|
|
19
|
-
- **Custom chat UIs and desktop apps** — stream
|
|
19
|
+
- **IDE extensions and editors** — stream model deltas, tool events, and approvals into an editor surface over stdio or a socket
|
|
20
|
+
- **Custom chat UIs and desktop apps** — stream canonical events into your own interface
|
|
20
21
|
- **Bots and assistants** — drive sessions programmatically from any runtime that can spawn a subprocess
|
|
21
22
|
- **Headless automation** — CI/CD pipelines, batch processing, scriptable agent tasks
|
|
22
23
|
|
|
@@ -37,8 +38,30 @@ gem "ask-app-server"
|
|
|
37
38
|
```bash
|
|
38
39
|
# Start the server (reads JSON-RPC from stdin, writes to stdout)
|
|
39
40
|
ask-app-server
|
|
41
|
+
|
|
42
|
+
# Or expose a unix socket for multi-client attach (runs alongside stdio):
|
|
43
|
+
ask-app-server --socket ~/.ask-app-server/app-server.sock
|
|
40
44
|
```
|
|
41
45
|
|
|
46
|
+
Running inside a [herdr](https://herdr.dev) pane? Nothing to configure:
|
|
47
|
+
herdr injects `HERDR_SOCKET_PATH`/`HERDR_PANE_ID` into the pane, the host
|
|
48
|
+
detects them, and keeps the herdr sidebar accurate with first-party state —
|
|
49
|
+
`working` while a turn runs, `blocked` while an approval is pending,
|
|
50
|
+
`idle` otherwise — plus the session id (for future resume) and model
|
|
51
|
+
tokens. No screen scraping needed; the host knows its own state.
|
|
52
|
+
|
|
53
|
+
Clients connect to the socket and speak the same protocol as NDJSON lines
|
|
54
|
+
(`ASK_APP_SERVER_SOCKET` env var works too):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
nc -U ~/.ask-app-server/app-server.sock
|
|
58
|
+
{"id":1, "method":"initialize", "params":{}}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Any number of clients can attach to the same sessions: each connection
|
|
62
|
+
tracks its own event-delivery cursor, so `session/subscribe` replays what
|
|
63
|
+
that client hasn't seen and every client receives each event exactly once.
|
|
64
|
+
|
|
42
65
|
From another process, send JSON-RPC requests:
|
|
43
66
|
|
|
44
67
|
```json
|
|
@@ -52,27 +75,41 @@ From another process, send JSON-RPC requests:
|
|
|
52
75
|
|
|
53
76
|
| Method | Description |
|
|
54
77
|
|---|---|
|
|
55
|
-
| `initialize` | Handshake
|
|
78
|
+
| `initialize` | Handshake; negotiates `protocolVersion` and capabilities |
|
|
56
79
|
| `session/create` | Create a new agent session |
|
|
57
80
|
| `session/list` | List active sessions |
|
|
58
81
|
| `session/resume` | Resume an existing session |
|
|
59
|
-
| `session/subscribe` | Subscribe to
|
|
60
|
-
| `session/send` |
|
|
82
|
+
| `session/subscribe` | Subscribe to the event stream (with replay snapshot) |
|
|
83
|
+
| `session/send` | Prompt an idle session or inject mid-run (`steered`/`queued`/`stale`) |
|
|
61
84
|
| `session/events` | Poll for events after a sequence number |
|
|
62
85
|
| `session/abort` | Abort the current turn |
|
|
63
|
-
| `
|
|
86
|
+
| `session/close` | Close the session |
|
|
87
|
+
| `session/artifacts` · `session/artifact/get` | List and fetch tool artifacts |
|
|
88
|
+
| `interaction/list` | List pending approval interactions |
|
|
89
|
+
| `interaction/approve` · `interaction/reject` | Resolve an approval by id |
|
|
90
|
+
| `interaction/approve-all` · `interaction/reject-all` | Resolve all approvals |
|
|
91
|
+
| `interaction/respond` | Answer user-input elicitation (not yet implemented) |
|
|
92
|
+
| `plan/approve` · `plan/reject` | Approve/reject the pending plan proposal |
|
|
93
|
+
| `workspace/readState` | Read workspace and approval-mode state |
|
|
64
94
|
|
|
65
95
|
### Events (server → client notifications)
|
|
66
96
|
|
|
97
|
+
Every event is a canonical `{type, seq, payload}` envelope, delivered as a
|
|
98
|
+
`session/event` notification on subscribed sessions:
|
|
99
|
+
|
|
67
100
|
| Event | When |
|
|
68
101
|
|---|---|
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
| `session.created` · `session.ended` | Session lifecycle |
|
|
103
|
+
| `turn.started` · `turn.completed` · `turn.failed` · `turn.aborted` | Turn lifecycle |
|
|
104
|
+
| `model.streaming` · `model.thinking` | Model output deltas |
|
|
105
|
+
| `tool.use` · `tool.delta` · `tool.result` | Tool execution |
|
|
106
|
+
| `approval.required` · `approval.updated` | Resolvable approval interactions |
|
|
107
|
+
| `plan.proposed` · `plan.approved` · `plan.rejected` | Plan mode |
|
|
108
|
+
| `todos.updated` | Todo list changes |
|
|
109
|
+
| `error` | Non-fatal errors |
|
|
110
|
+
|
|
111
|
+
The full vocabulary, payload shapes, method specs, and versioning live in
|
|
112
|
+
the ask-session-protocol gem (JSON Schema artifact included).
|
|
76
113
|
|
|
77
114
|
## Clients
|
|
78
115
|
|
|
@@ -95,7 +132,7 @@ Environment variables:
|
|
|
95
132
|
|---|---|---|
|
|
96
133
|
| `ASK_APP_SERVER_CONFIG` | auto-detected | Path to the config file |
|
|
97
134
|
| `ASK_APP_SERVER_MODEL` | `opencode_go/deepseek-v4-flash` | Model identifier (overrides config file) |
|
|
98
|
-
| `ASK_APP_SERVER_PERMISSIONS` | `on_request` | Permission mode (`on_request`, `never`) |
|
|
135
|
+
| `ASK_APP_SERVER_PERMISSIONS` | `on_request` | Permission mode (`on_request`, `never`, `auto`) |
|
|
99
136
|
| `DEBUG` | unset | Set to `1` for debug logging |
|
|
100
137
|
|
|
101
138
|
## Development
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Ask
|
|
4
4
|
module AppServer
|
|
5
|
-
# Wraps an Ask::Agent::Session and translates its events into
|
|
6
|
-
#
|
|
5
|
+
# Wraps an Ask::Agent::Session and translates its events into canonical
|
|
6
|
+
# Ask::SessionProtocol events via EventTranslator.
|
|
7
7
|
#
|
|
8
8
|
# Each wrapper is associated with one session and maintains an
|
|
9
|
-
# EventTranslator that
|
|
9
|
+
# EventTranslator that clients poll or subscribe to. The adapter also
|
|
10
|
+
# owns the approval queue wiring (approval events surface as
|
|
11
|
+
# approval.required / approval.updated) and the interaction controls
|
|
12
|
+
# (approve/reject by id, plan approve/reject) that any client can call.
|
|
10
13
|
class AgentAdapter
|
|
11
14
|
# The underlying ask-agent session.
|
|
12
15
|
attr_reader :session
|
|
@@ -23,14 +26,27 @@ module Ask
|
|
|
23
26
|
# When the session was created.
|
|
24
27
|
attr_reader :created_at
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
# @param model [String] model identifier
|
|
30
|
+
# @param tools [Array<String, Class>] tool names or classes
|
|
31
|
+
# @param system_prompt [String, nil]
|
|
32
|
+
# @param agent_dir [String, nil] workspace path
|
|
33
|
+
# @param approval [Symbol] :off, :require, or :auto
|
|
34
|
+
# @param require_approval [Array<String>, nil] tool names gated behind
|
|
35
|
+
# human approval when approval is :require
|
|
36
|
+
# @param session_opts [Hash] remaining options passed to
|
|
37
|
+
# Ask::Agent::Session.new (hooks, plan_mode, todos, ...)
|
|
38
|
+
def initialize(model:, tools: nil, system_prompt: nil, agent_dir: nil,
|
|
39
|
+
approval: :off, require_approval: nil, **session_opts)
|
|
27
40
|
@model = model
|
|
28
41
|
@system_prompt = system_prompt
|
|
29
42
|
@tools = resolve_tools(tools)
|
|
30
43
|
@session_opts = session_opts
|
|
44
|
+
@approval = approval
|
|
45
|
+
@require_approval = require_approval
|
|
31
46
|
@agent_dir = agent_dir
|
|
32
47
|
@session = nil
|
|
33
48
|
@translator = nil
|
|
49
|
+
@on_event_block = nil
|
|
34
50
|
@session_id = nil
|
|
35
51
|
@running = false
|
|
36
52
|
@running_mutex = Mutex.new
|
|
@@ -43,22 +59,12 @@ module Ask
|
|
|
43
59
|
# Start a new ask-agent session.
|
|
44
60
|
# Returns the session ID.
|
|
45
61
|
def start_session
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
hooks = opts.delete(:hooks) || {}
|
|
50
|
-
|
|
51
|
-
@session = Ask::Agent::Session.new(
|
|
52
|
-
model: @model,
|
|
53
|
-
tools: @tools,
|
|
54
|
-
system_prompt: @system_prompt,
|
|
55
|
-
agent_dir: @agent_dir,
|
|
56
|
-
hooks: hooks,
|
|
57
|
-
**opts
|
|
58
|
-
)
|
|
62
|
+
@translator = EventTranslator.new
|
|
63
|
+
@translator.on_event = @on_event_block if @on_event_block
|
|
64
|
+
@session = build_session
|
|
59
65
|
@session_id = @session.id
|
|
60
|
-
@translator = EventTranslator.new(@session_id)
|
|
61
66
|
@session.on_event { |event| handle_agent_event(event) }
|
|
67
|
+
@translator.session_created(@session_id)
|
|
62
68
|
@session_id
|
|
63
69
|
end
|
|
64
70
|
|
|
@@ -66,34 +72,45 @@ module Ask
|
|
|
66
72
|
def resume(session)
|
|
67
73
|
@session = session
|
|
68
74
|
@session_id = session.id
|
|
69
|
-
@translator = EventTranslator.new
|
|
75
|
+
@translator = EventTranslator.new
|
|
76
|
+
@translator.on_event = @on_event_block if @on_event_block
|
|
70
77
|
@session.on_event { |event| handle_agent_event(event) }
|
|
71
78
|
@session_id
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@
|
|
81
|
+
# Register an observer for every canonical event this session emits
|
|
82
|
+
# (translations plus approval/plan/session-lifecycle emissions).
|
|
83
|
+
# May be called before start_session; the block is applied when the
|
|
84
|
+
# translator exists so the observer sees session.created.
|
|
85
|
+
def on_event(&block)
|
|
86
|
+
if @translator
|
|
87
|
+
@translator.on_event = block
|
|
88
|
+
else
|
|
89
|
+
@on_event_block = block
|
|
83
90
|
end
|
|
91
|
+
end
|
|
84
92
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
# Send a message and start processing (idle session) or inject it
|
|
94
|
+
# mid-run (running session). Uses ask-agent's steer semantics:
|
|
95
|
+
#
|
|
96
|
+
# steered — the message was added to the conversation immediately
|
|
97
|
+
# queued — the session is running; the message is queued for the
|
|
98
|
+
# next turn boundary (no abort)
|
|
99
|
+
# stale — the caller's expected_turn_id no longer matches
|
|
100
|
+
#
|
|
101
|
+
# @param content [String]
|
|
102
|
+
# @param expected_turn_id [String, nil] staleness guard
|
|
103
|
+
# @return [Hash] { status: "steered"|"queued"|"stale", turn_id: }
|
|
104
|
+
def send_message(content, expected_turn_id: nil)
|
|
105
|
+
raise "Session not started" unless @session
|
|
95
106
|
|
|
96
|
-
|
|
107
|
+
if @running
|
|
108
|
+
result = @session.steer(content, expected_turn_id: expected_turn_id)
|
|
109
|
+
{ status: result[:status].to_s, turn_id: result[:turn_id] }
|
|
110
|
+
else
|
|
111
|
+
start_run(content)
|
|
112
|
+
{ status: "steered", turn_id: @session.turn_id }
|
|
113
|
+
end
|
|
97
114
|
end
|
|
98
115
|
|
|
99
116
|
# Request abort of the current turn.
|
|
@@ -117,23 +134,84 @@ module Ask
|
|
|
117
134
|
!@running
|
|
118
135
|
end
|
|
119
136
|
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
#
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
# ── Interactions (approvals) ───────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
# Pending approval interactions, as canonical Interaction objects.
|
|
140
|
+
def pending_interactions
|
|
141
|
+
queue = @session&.approval_queue
|
|
142
|
+
return [] unless queue
|
|
143
|
+
|
|
144
|
+
queue.pending_actions.map do |action|
|
|
145
|
+
payload = { "toolName" => action.tool_name.to_s }
|
|
146
|
+
payload["args"] = action.args if action.args
|
|
147
|
+
payload["message"] = action.message if action.message
|
|
148
|
+
payload["autoApprovable"] = action.auto_approvable unless action.auto_approvable.nil?
|
|
149
|
+
Ask::SessionProtocol::Interactions.interaction(
|
|
150
|
+
id: "act_#{action.id}", kind: "approval", status: "pending", payload: payload
|
|
151
|
+
)
|
|
133
152
|
end
|
|
134
153
|
end
|
|
135
154
|
|
|
136
|
-
#
|
|
155
|
+
# Approve a pending approval interaction by canonical id ("act_N").
|
|
156
|
+
# @return [Boolean] whether an action was approved
|
|
157
|
+
def approve_interaction(interaction_id)
|
|
158
|
+
apply_interaction(interaction_id) { |queue, id| queue.approve(id) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Reject a pending approval interaction by canonical id ("act_N").
|
|
162
|
+
# @return [Boolean] whether an action was rejected
|
|
163
|
+
def reject_interaction(interaction_id)
|
|
164
|
+
apply_interaction(interaction_id) { |queue, id| queue.reject(id) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Approve every pending approval interaction.
|
|
168
|
+
# @return [Integer] number approved
|
|
169
|
+
def approve_all_interactions
|
|
170
|
+
queue = @session&.approval_queue
|
|
171
|
+
return 0 unless queue
|
|
172
|
+
|
|
173
|
+
queue.approve_all.size
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Reject every pending approval interaction.
|
|
177
|
+
# @return [Integer] number rejected
|
|
178
|
+
def reject_all_interactions
|
|
179
|
+
queue = @session&.approval_queue
|
|
180
|
+
return 0 unless queue
|
|
181
|
+
|
|
182
|
+
queue.reject_all.size
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ── Plan mode ───────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
# Approve the pending plan proposal.
|
|
188
|
+
# @return [Boolean]
|
|
189
|
+
def plan_approve
|
|
190
|
+
queue = @session&.plan_queue
|
|
191
|
+
return false unless queue
|
|
192
|
+
|
|
193
|
+
queue.approve_all.any?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Reject the pending plan proposal; the agent stays in plan mode.
|
|
197
|
+
# @return [Boolean]
|
|
198
|
+
def plan_reject
|
|
199
|
+
queue = @session&.plan_queue
|
|
200
|
+
return false unless queue
|
|
201
|
+
|
|
202
|
+
queue.reject_all.any?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# ── Lifecycle ───────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
# Close the session: delete its state and emit session.ended.
|
|
208
|
+
def close!
|
|
209
|
+
@session&.delete if @session.respond_to?(:delete)
|
|
210
|
+
@translator&.session_ended(@session_id, reason: "closed")
|
|
211
|
+
true
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The accumulated streaming text from the current/last turn.
|
|
137
215
|
def streaming_text
|
|
138
216
|
@translator&.instance_variable_get(:@streaming_text).to_s
|
|
139
217
|
end
|
|
@@ -155,15 +233,79 @@ module Ask
|
|
|
155
233
|
|
|
156
234
|
# Events after a given sequence number.
|
|
157
235
|
def events_after(after_seq)
|
|
158
|
-
pending_events.select { |e| e
|
|
236
|
+
pending_events.select { |e| e.seq > after_seq }
|
|
159
237
|
end
|
|
160
238
|
|
|
161
239
|
private
|
|
162
240
|
|
|
241
|
+
def build_session
|
|
242
|
+
opts = @session_opts.dup
|
|
243
|
+
hooks = opts.delete(:hooks) || {}
|
|
244
|
+
approval_option = build_approval_option
|
|
245
|
+
opts[:approval] = approval_option if approval_option
|
|
246
|
+
opts[:plan_mode] = false unless opts.key?(:plan_mode)
|
|
247
|
+
|
|
248
|
+
Ask::Agent::Session.new(
|
|
249
|
+
model: @model,
|
|
250
|
+
tools: @tools,
|
|
251
|
+
system_prompt: @system_prompt,
|
|
252
|
+
agent_dir: @agent_dir,
|
|
253
|
+
hooks: hooks,
|
|
254
|
+
**opts
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Build the approval option for Ask::Agent::Session: an
|
|
259
|
+
# EmittingApprovalQueue whose events stream to the translator.
|
|
260
|
+
def build_approval_option
|
|
261
|
+
return nil if @approval == :off
|
|
262
|
+
|
|
263
|
+
queue = EmittingApprovalQueue.new(
|
|
264
|
+
on_submit: ->(action) { @translator.approval_required(action) },
|
|
265
|
+
on_status: ->(action) { @translator.approval_updated(action) }
|
|
266
|
+
)
|
|
267
|
+
return { queue: queue } if @approval == :auto
|
|
268
|
+
|
|
269
|
+
{ queue: queue, require_approval: @require_approval }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def apply_interaction(interaction_id)
|
|
273
|
+
id = interaction_id.to_s[/\Aact_(\d+)\z/, 1]
|
|
274
|
+
return false unless id
|
|
275
|
+
|
|
276
|
+
queue = @session&.approval_queue
|
|
277
|
+
return false unless queue
|
|
278
|
+
|
|
279
|
+
yield(queue, id.to_i).any?
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def start_run(content)
|
|
283
|
+
@running_mutex.synchronize do
|
|
284
|
+
@abort_requested = false
|
|
285
|
+
@running = true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
@run_thread = Thread.new do
|
|
289
|
+
begin
|
|
290
|
+
@session.run(content, reset: false)
|
|
291
|
+
# Drain steers queued while the turn was running (mid-execution
|
|
292
|
+
# injection): each subsequent run processes one queued message.
|
|
293
|
+
while @session.queued_steers.positive?
|
|
294
|
+
@session.run("", reset: false)
|
|
295
|
+
end
|
|
296
|
+
rescue => e
|
|
297
|
+
# Agent may have been aborted — that's fine
|
|
298
|
+
@logger.debug("Agent run error: #{e.message}") if ENV["DEBUG"]
|
|
299
|
+
ensure
|
|
300
|
+
@running_mutex.synchronize { @running = false }
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
true
|
|
305
|
+
end
|
|
306
|
+
|
|
163
307
|
def handle_agent_event(event)
|
|
164
|
-
|
|
165
|
-
# Translated events are already stored in the translator's buffer.
|
|
166
|
-
# Nothing else to do here — clients poll or subscribe to get them.
|
|
308
|
+
@translator.translate(event)
|
|
167
309
|
end
|
|
168
310
|
|
|
169
311
|
def resolve_tools(tool_list)
|
|
@@ -202,5 +344,45 @@ module Ask
|
|
|
202
344
|
]
|
|
203
345
|
end
|
|
204
346
|
end
|
|
347
|
+
|
|
348
|
+
# Approval queue that emits canonical approval events through the
|
|
349
|
+
# session's EventTranslator whenever actions are submitted or change
|
|
350
|
+
# status, so clients can stream approval state in real time.
|
|
351
|
+
#
|
|
352
|
+
# The session wires its own apply/reject/submit callbacks onto the
|
|
353
|
+
# queue (see Session#build_approval); this subclass only adds
|
|
354
|
+
# observation hooks on top, using its own listeners so the session's
|
|
355
|
+
# on_submit (pending-tool registration) is never clobbered.
|
|
356
|
+
class EmittingApprovalQueue < Ask::Agent::ApprovalQueue
|
|
357
|
+
# @param on_submit [Proc, nil] called with the new {Action} after
|
|
358
|
+
# submission (and after the auto-approval drain)
|
|
359
|
+
# @param on_status [Proc, nil] called with an {Action} whose status
|
|
360
|
+
# changed to :approved or :rejected
|
|
361
|
+
def initialize(on_submit: nil, on_status: nil, **kwargs)
|
|
362
|
+
@on_action_submitted = on_submit
|
|
363
|
+
@on_status = on_status
|
|
364
|
+
super(**kwargs)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def submit(tool_call_id:, tool_name:, args: {}, auto_approvable: false, message: nil)
|
|
368
|
+
id = super
|
|
369
|
+
@on_action_submitted&.call(self[id])
|
|
370
|
+
id
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
private
|
|
374
|
+
|
|
375
|
+
def apply(action)
|
|
376
|
+
result = super
|
|
377
|
+
@on_status&.call(action.with(status: :approved))
|
|
378
|
+
result
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def reject_action(action)
|
|
382
|
+
result = super
|
|
383
|
+
@on_status&.call(action.with(status: :rejected))
|
|
384
|
+
result
|
|
385
|
+
end
|
|
386
|
+
end
|
|
205
387
|
end
|
|
206
388
|
end
|