theway-core 0.1.21

theway core — stateful agent runtime + harness (Agent loop, skills, prompt templates, sessions, compaction) on top of theway-llm-provider.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! Tool-call preparation (permission gates, hooks) and execution for the agent loop.

use std::sync::Arc;

use theway_llm_provider::{
    AssistantMessage as PiAssistantMessage, ToolResultMessage, UserContentBlock,
};
use tokio_util::sync::CancellationToken;

use crate::agent::AgentInner;
use crate::types::*;

use super::run_one;
use super::utils::{compute_args_hash, default_prompt_payload, emit, snapshot_context};

/// Execute every tool-call block in the assistant's content. Returns the per-call results
/// (in assistant content order) and `all_terminate = true` when every result hints early
/// termination.
#[cfg(test)]
pub(super) async fn execute_tools(
    inner: &Arc<AgentInner>,
    assistant: &PiAssistantMessage,
    cancel: &CancellationToken,
) -> (Vec<ToolResultMessage>, bool) {
    let tools_snapshot = inner.state.lock().tools.clone();
    execute_tools_with_snapshot(inner, assistant, &tools_snapshot, cancel).await
}

/// Execute against the immutable catalog accepted for the model request that
/// produced `assistant`; later registry mutations cannot enter this lookup.
pub(super) async fn execute_tools_with_snapshot(
    inner: &Arc<AgentInner>,
    assistant: &PiAssistantMessage,
    tools_snapshot: &[Arc<dyn AgentTool>],
    cancel: &CancellationToken,
) -> (Vec<ToolResultMessage>, bool) {
    // Gather the tool calls + matched AgentTool implementations in assistant content order.
    let tool_calls: Vec<&theway_llm_provider::ToolCall> = assistant
        .content
        .iter()
        .filter_map(|b| match b {
            theway_llm_provider::ContentBlock::ToolCall(tc) => Some(tc),
            _ => None,
        })
        .collect();
    if tool_calls.is_empty() {
        return (Vec::new(), false);
    }
    // Decide per-call execution mode (parallel default unless any tool requests sequential).
    let mode = inner.options.tool_execution;
    let any_sequential = tool_calls.iter().any(|tc| {
        let matched = tools_snapshot
            .iter()
            .find(|t| t.definition().name == tc.name);
        matched
            .and_then(|t| t.execution_mode())
            .map(|m| matches!(m, ToolExecutionMode::Sequential))
            .unwrap_or(false)
    });
    let mode = if any_sequential {
        ToolExecutionMode::Sequential
    } else {
        mode
    };

    // Pre-flight: run `before_tool_call` for every call. If a hook blocks, synthesize an error
    // result and skip the actual execute. Returns Vec<Option<execute_input>> in call order.
    let mut prepared: Vec<PreparedCall> = Vec::with_capacity(tool_calls.len());
    let agent_context = snapshot_context(inner);
    for tc in &tool_calls {
        let tool_id = tc.id.clone();
        let tool_name = tc.name.clone();
        let raw_args = serde_json::Value::Object(tc.arguments.clone());

        // Resolve the tool from the accepted request snapshot before normalizing args so we can
        // run its `prepare_arguments` compatibility shim. Missing names are rejected before any
        // permission hook or execution lifecycle event.
        let tool = tools_snapshot
            .iter()
            .find(|t| t.definition().name == tool_name)
            .cloned();
        let Some(tool) = tool else {
            prepared.push(PreparedCall::Blocked {
                id: tool_id,
                name: tool_name,
                args: raw_args,
                result: AgentToolResult {
                    content: vec![UserContentBlock::text(
                        "tool is not available in this model request",
                    )],
                    details: serde_json::json!({
                        "errorCode": "tool_not_in_request_catalog",
                    }),
                    terminate: None,
                },
            });
            continue;
        };
        let args = tool.prepare_arguments(raw_args);

        // Per-tool classification runs first (issue #110 design v0.2 Artifact A). The
        // classifier sees the prepared args and decides Allow / Prompt / Block before the
        // user-configured `before_tool_call` hook gets a chance. `Block` short-circuits
        // immediately (no `before_tool_call`, no prompt); `Prompt` synthesizes a default
        // `BeforeToolCallResult::prompt` that the user hook can override; `Allow` falls
        // through to the existing `before_tool_call` path with no synthesized prompt.
        let classification = tool.permission_classification(&args);
        if let PermissionClassification::Block { reason } = &classification {
            let result = AgentToolResult {
                content: vec![UserContentBlock::text(reason.clone())],
                details: serde_json::Value::Null,
                terminate: None,
            };
            prepared.push(PreparedCall::Blocked {
                id: tool_id,
                name: tool_name,
                args,
                result,
            });
            continue;
        }

        // The classifier's `Prompt` is the authoritative source: a user-configured
        // `before_tool_call` hook MUST NOT silently erase a control-plane prompt requirement
        // by returning `BeforeToolCallResult::default()`. We preserve the synthesized prompt
        // unless the hook either explicitly hard-blocks (`block=true` wins, classifier
        // intent honored — Block-stronger-than-Prompt) or supplies its own richer
        // `BeforeToolCallResult::prompt` payload (which the runtime then re-binds to the
        // authoritative `tool_call_id` / `tool_name` / `args_hash` below — the hook may
        // only enrich `label` and `payload`, never spoof binding fields).
        //
        // The hook still sees the prepared args on BOTH `ctx.args` and
        // `ctx.tool_call.arguments` (matched semantics from the legacy code path). If the
        // tool's `prepare_arguments` returns a non-Object shape we clear the map so the
        // hook author has only one truthy source.
        let synthesized_prompt: Option<ControlPlanePromptRequest> = match &classification {
            PermissionClassification::Prompt { reason } => Some(ControlPlanePromptRequest {
                tool_call_id: tool_id.clone(),
                tool_name: tool_name.clone(),
                args_hash: compute_args_hash(&args),
                label: format!("Control-plane write: {tool_name}"),
                payload: default_prompt_payload(&tool_name, &args),
                reason: reason.clone(),
            }),
            PermissionClassification::Allow => None,
            // Block already handled by the early-return above; kept for exhaustiveness.
            PermissionClassification::Block { .. } => unreachable!(),
        };

        let mut hook_result = BeforeToolCallResult {
            block: false,
            reason: None,
            prompt: synthesized_prompt.clone(),
        };
        if let Some(hook) = inner.options.before_tool_call.clone() {
            let mut hook_tc = (*tc).clone();
            hook_tc.arguments = match &args {
                serde_json::Value::Object(map) => map.clone(),
                _ => serde_json::Map::new(),
            };
            let ctx = BeforeToolCallContext {
                assistant_message: assistant.clone(),
                tool_call: hook_tc,
                args: args.clone(),
                context: agent_context.clone(),
            };
            hook_result = hook(ctx, cancel.clone()).await;
        }
        if hook_result.block {
            let reason = hook_result
                .reason
                .unwrap_or_else(|| "tool call blocked by before_tool_call hook".to_string());
            let result = AgentToolResult {
                content: vec![UserContentBlock::text(reason)],
                details: serde_json::Value::Null,
                terminate: None,
            };
            prepared.push(PreparedCall::Blocked {
                id: tool_id,
                name: tool_name,
                args,
                result,
            });
            continue;
        }
        // Merge: if the classifier requested a Prompt, ensure the runtime still routes
        // through the prompt channel even if the hook returned `prompt = None`. If the hook
        // supplied its own prompt, accept it as the embedder's richer card BUT re-bind
        // `tool_call_id` / `tool_name` / `args_hash` to the runtime-authoritative values so
        // a hook cannot lie about binding fields (forgery resistance).
        let effective_prompt: Option<ControlPlanePromptRequest> =
            match (synthesized_prompt, hook_result.prompt.take()) {
                // Classifier said Prompt, hook didn't supply one → keep the classifier's.
                (Some(synth), None) => Some(synth),
                // Classifier said Allow but hook supplied a prompt → accept it (hook is
                // raising the bar). Runtime still owns binding fields.
                (None, Some(hook_supplied)) => Some(ControlPlanePromptRequest {
                    tool_call_id: tool_id.clone(),
                    tool_name: tool_name.clone(),
                    args_hash: compute_args_hash(&args),
                    label: hook_supplied.label,
                    payload: hook_supplied.payload,
                    reason: hook_supplied.reason,
                }),
                // Classifier said Prompt AND hook supplied a custom payload → use hook's
                // label/payload (richer card) BUT re-bind authoritative fields. Hook cannot
                // override the classifier's `reason` (it's the reason the gate exists), but
                // can supply additional context via `payload`.
                (Some(synth), Some(hook_supplied)) => Some(ControlPlanePromptRequest {
                    tool_call_id: synth.tool_call_id,
                    tool_name: synth.tool_name,
                    args_hash: synth.args_hash,
                    label: hook_supplied.label,
                    payload: hook_supplied.payload,
                    reason: synth.reason,
                }),
                // Neither classifier nor hook required a prompt → no gate.
                (None, None) => None,
            };
        // Prompt path: ask the embedder, map decision to allow/block. Fail-closed when no
        // prompt channel is configured.
        if let Some(prompt_req) = effective_prompt {
            let decision = match inner.options.on_control_plane_prompt.clone() {
                Some(prompt_hook) => prompt_hook(prompt_req.clone(), cancel.clone()).await,
                None => ControlPlanePromptDecision::Deny {
                    reason: Some(
                        "control-plane prompt required but no on_control_plane_prompt hook \
                         configured (fail-closed deny — see issue #110 design v0.2)"
                            .to_string(),
                    ),
                },
            };
            emit(
                inner,
                LoopEvent::ControlPlanePromptResolved {
                    tool_call_id: prompt_req.tool_call_id.clone(),
                    tool_name: prompt_req.tool_name.clone(),
                    args_hash: prompt_req.args_hash.clone(),
                    label: prompt_req.label.clone(),
                    decision: decision.as_audit_str().to_string(),
                    reason: match &decision {
                        ControlPlanePromptDecision::Deny { reason } => reason.clone(),
                        _ => None,
                    },
                },
                cancel,
            )
            .await;
            match decision {
                ControlPlanePromptDecision::Allow => {
                    // fall through to dispatch
                }
                ControlPlanePromptDecision::Deny { reason } => {
                    let reason = reason.unwrap_or_else(|| {
                        "tool call denied by user via control-plane prompt".to_string()
                    });
                    let result = AgentToolResult {
                        content: vec![UserContentBlock::text(reason)],
                        details: serde_json::Value::Null,
                        terminate: None,
                    };
                    prepared.push(PreparedCall::Blocked {
                        id: tool_id,
                        name: tool_name,
                        args,
                        result,
                    });
                    continue;
                }
                ControlPlanePromptDecision::Timeout => {
                    let result = AgentToolResult {
                        content: vec![UserContentBlock::text(
                            "control-plane prompt timed out — tool call denied".to_string(),
                        )],
                        details: serde_json::Value::Null,
                        terminate: None,
                    };
                    prepared.push(PreparedCall::Blocked {
                        id: tool_id,
                        name: tool_name,
                        args,
                        result,
                    });
                    continue;
                }
            }
        }

        prepared.push(PreparedCall::Run {
            id: tool_id,
            name: tool_name,
            args,
            tool: Some(tool),
        });
    }

    // Execute. For sequential we await one at a time; for parallel we spawn and join.
    let outcomes = match mode {
        ToolExecutionMode::Sequential => {
            let mut out = Vec::with_capacity(prepared.len());
            for call in prepared {
                out.push(run_one(inner.clone(), call, cancel.clone()).await);
            }
            out
        }
        ToolExecutionMode::Parallel => {
            let handles: Vec<_> = prepared
                .into_iter()
                .map(|call| {
                    let cancel = cancel.clone();
                    let inner = inner.clone();
                    tokio::spawn(async move { run_one(inner, call, cancel).await })
                })
                .collect();
            let mut out = Vec::with_capacity(handles.len());
            for h in handles {
                out.push(h.await.unwrap_or_else(|e| ToolOutcome {
                    id: String::new(),
                    name: String::new(),
                    args: serde_json::Value::Null,
                    result: AgentToolResult {
                        content: vec![UserContentBlock::text(format!("tool task join: {e}"))],
                        details: serde_json::Value::Null,
                        terminate: None,
                    },
                    is_error: true,
                    executed: true,
                }));
            }
            out
        }
    };

    // Post-process: run after_tool_call hooks (which may override), emit tool_execution_end,
    // build tool-result messages.
    let mut results = Vec::with_capacity(outcomes.len());
    let mut all_terminate = !outcomes.is_empty();
    let agent_context = snapshot_context(inner);
    for outcome in outcomes {
        let ToolOutcome {
            id,
            name,
            args,
            mut result,
            mut is_error,
            executed,
        } = outcome;

        if let Some(hook) = inner.options.after_tool_call.clone() {
            let ctx = AfterToolCallContext {
                assistant_message: assistant.clone(),
                tool_call: theway_llm_provider::ToolCall {
                    id: id.clone(),
                    name: name.clone(),
                    arguments: args.as_object().cloned().unwrap_or_default(),
                    thought_signature: None,
                },
                args: args.clone(),
                result: result.clone(),
                is_error,
                context: agent_context.clone(),
            };
            let patch = hook(ctx, cancel.clone()).await;
            apply_tool_result_patch(&mut result, &mut is_error, patch);
        }

        if executed {
            emit(
                inner,
                LoopEvent::ToolExecutionEnd {
                    tool_call_id: id.clone(),
                    tool_name: name.clone(),
                    result: result.clone(),
                    is_error,
                },
                cancel,
            )
            .await;
        }

        if let Some(transform) = inner.options.transform_tool_result.clone() {
            let ctx = AfterToolCallContext {
                assistant_message: assistant.clone(),
                tool_call: theway_llm_provider::ToolCall {
                    id: id.clone(),
                    name: name.clone(),
                    arguments: args.as_object().cloned().unwrap_or_default(),
                    thought_signature: None,
                },
                args: args.clone(),
                result: result.clone(),
                is_error,
                context: agent_context.clone(),
            };
            let patch = transform(ctx, cancel.clone()).await;
            apply_tool_result_patch(&mut result, &mut is_error, patch);
        }

        if !result.terminate.unwrap_or(false) {
            all_terminate = false;
        }

        results.push(ToolResultMessage {
            role: theway_llm_provider::ToolResultRole::ToolResult,
            tool_call_id: id,
            tool_name: name,
            content: result.content,
            details: Some(result.details),
            is_error,
            timestamp: chrono::Utc::now().timestamp_millis(),
        });
    }
    (results, all_terminate)
}

fn apply_tool_result_patch(
    result: &mut AgentToolResult,
    is_error: &mut bool,
    patch: AfterToolCallResult,
) {
    if let Some(content) = patch.content {
        result.content = content;
    }
    if let Some(details) = patch.details {
        result.details = details;
    }
    if let Some(error) = patch.is_error {
        *is_error = error;
    }
    if let Some(terminate) = patch.terminate {
        result.terminate = Some(terminate);
    }
}

pub(super) enum PreparedCall {
    Run {
        id: String,
        name: String,
        args: serde_json::Value,
        tool: Option<Arc<dyn AgentTool>>,
    },
    Blocked {
        id: String,
        name: String,
        args: serde_json::Value,
        result: AgentToolResult,
    },
}

pub(super) struct ToolOutcome {
    pub(super) id: String,
    pub(super) name: String,
    pub(super) args: serde_json::Value,
    pub(super) result: AgentToolResult,
    pub(super) is_error: bool,
    pub(super) executed: bool,
}

#[cfg(test)]
tests_bridge_macro::tests_bridge!("agent/run_loop/tools");

#[cfg(test)]
mod tools_extra_tests {
    tests_bridge_macro::tests_bridge!("agent/run_loop/tools/extra");
}

#[cfg(test)]
mod tools_linecov_tests {
    tests_bridge_macro::tests_bridge!("agent/run_loop/tools/linecov");
}