feat: パターンベースのツール権限制御を追加
This commit is contained in:
@@ -52,6 +52,11 @@ pub enum PreToolAction {
|
||||
Continue,
|
||||
/// Skip this tool call (do not execute).
|
||||
Skip,
|
||||
/// Do not execute the tool call; commit this synthetic result instead.
|
||||
///
|
||||
/// This preserves provider-visible `tool_use` / `tool_result` pairing
|
||||
/// without aborting the whole turn.
|
||||
SyntheticResult(ToolResult),
|
||||
/// Abort the entire run.
|
||||
Abort(String),
|
||||
/// Pause execution (can be resumed later).
|
||||
|
||||
@@ -14,6 +14,10 @@ use crate::llm_client::{
|
||||
|
||||
use super::AnthropicScheme;
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
/// Anthropic API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct AnthropicRequest {
|
||||
@@ -104,6 +108,8 @@ pub(crate) enum AnthropicContentPart {
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
},
|
||||
@@ -141,10 +147,11 @@ impl AnthropicContentPart {
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_result(tool_use_id: String, content: String) -> Self {
|
||||
fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self {
|
||||
Self::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
@@ -321,6 +328,7 @@ impl AnthropicScheme {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
flush_pending(
|
||||
@@ -333,8 +341,10 @@ impl AnthropicScheme {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
pending_user
|
||||
.push((i, AnthropicContentPart::tool_result(call_id.clone(), text)));
|
||||
pending_user.push((
|
||||
i,
|
||||
AnthropicContentPart::tool_result(call_id.clone(), text, *is_error),
|
||||
));
|
||||
}
|
||||
|
||||
Item::Reasoning {
|
||||
@@ -355,13 +365,10 @@ impl AnthropicScheme {
|
||||
// 素の reasoning text。Anthropic に投げる意味も
|
||||
// round-trip の根拠も無いので drop。
|
||||
if let Some(sig) = signature.clone() {
|
||||
pending_assistant.push((
|
||||
i,
|
||||
AnthropicContentPart::thinking(text.clone(), sig),
|
||||
));
|
||||
} else if let Some(data) = encrypted_content.clone() {
|
||||
pending_assistant
|
||||
.push((i, AnthropicContentPart::redacted_thinking(data)));
|
||||
.push((i, AnthropicContentPart::thinking(text.clone(), sig)));
|
||||
} else if let Some(data) = encrypted_content.clone() {
|
||||
pending_assistant.push((i, AnthropicContentPart::redacted_thinking(data)));
|
||||
}
|
||||
// どちらも None なら何も pend せず、本 item は無視。
|
||||
}
|
||||
@@ -828,7 +835,9 @@ mod tests {
|
||||
assert_eq!(thinking_parts.len(), 1);
|
||||
match thinking_parts[0] {
|
||||
AnthropicContentPart::Thinking {
|
||||
thinking, signature, ..
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "step-by-step");
|
||||
assert_eq!(signature, "SIG-A");
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Item - The core unit of conversation
|
||||
// ============================================================================
|
||||
@@ -79,6 +83,9 @@ pub enum Item {
|
||||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
/// Whether the tool result represents an execution error.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
},
|
||||
|
||||
/// Reasoning/thinking item
|
||||
@@ -198,11 +205,27 @@ impl Item {
|
||||
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, false)
|
||||
}
|
||||
|
||||
/// Create an error tool result item with summary only (no content).
|
||||
pub fn tool_result_error(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, true)
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary, optional content, and error flag.
|
||||
pub fn tool_result_item(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content: None,
|
||||
content,
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,12 +235,7 @@ impl Item {
|
||||
summary: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content: Some(content.into()),
|
||||
}
|
||||
Self::tool_result_item(call_id, summary, Some(content.into()), false)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
@@ -275,7 +275,7 @@ pub struct ToolCall {
|
||||
///
|
||||
/// Intermediate representation between tool execution and history.
|
||||
/// Carries `summary` + optional `content` from [`ToolOutput`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ToolResult {
|
||||
/// Corresponding tool call ID
|
||||
pub tool_use_id: String,
|
||||
|
||||
@@ -742,8 +742,9 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
// Map from tool call ID to (ToolCall, Meta, Tool)
|
||||
// Retained because it's needed for PostToolCall hooks
|
||||
let mut call_info_map = HashMap::new();
|
||||
let mut synthetic_results = Vec::new();
|
||||
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort)
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
||||
let mut approved_calls = Vec::new();
|
||||
for mut tool_call in tool_calls {
|
||||
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
|
||||
@@ -758,6 +759,15 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
PreToolAction::Skip => {
|
||||
continue;
|
||||
}
|
||||
PreToolAction::SyntheticResult(result) => {
|
||||
let tool_call = info.call;
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call, info.meta.clone(), info.tool.clone()),
|
||||
);
|
||||
synthetic_results.push(result);
|
||||
continue;
|
||||
}
|
||||
PreToolAction::Abort(reason) => {
|
||||
self.last_run_interrupted = true;
|
||||
return Err(WorkerError::Aborted(reason));
|
||||
@@ -809,6 +819,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
return Err(WorkerError::Cancelled);
|
||||
}
|
||||
};
|
||||
results.extend(synthetic_results);
|
||||
|
||||
// Phase 3: Apply post_tool_call interceptor
|
||||
for tool_result in &mut results {
|
||||
@@ -1124,16 +1135,12 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
}
|
||||
Ok(ToolExecutionResult::Completed(results)) => {
|
||||
for result in results {
|
||||
if let Some(ref content) = result.content {
|
||||
self.history.push(Item::tool_result_with_content(
|
||||
&result.tool_use_id,
|
||||
&result.summary,
|
||||
content,
|
||||
));
|
||||
} else {
|
||||
self.history
|
||||
.push(Item::tool_result(&result.tool_use_id, &result.summary));
|
||||
}
|
||||
self.history.push(Item::tool_result_item(
|
||||
&result.tool_use_id,
|
||||
&result.summary,
|
||||
result.content,
|
||||
result.is_error,
|
||||
));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use llm_worker::interceptor::{
|
||||
Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
|
||||
};
|
||||
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
|
||||
|
||||
mod common;
|
||||
use common::MockLlmClient;
|
||||
@@ -268,3 +268,59 @@ async fn test_post_tool_call_modification() {
|
||||
"Result should be modified"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
|
||||
#[tokio::test]
|
||||
async fn test_before_tool_call_synthetic_result_committed() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "blocked_tool"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
events,
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Denied."),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut worker = Worker::new(client);
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
let blocked_clone = blocked_tool.clone();
|
||||
worker.register_tool(blocked_tool.definition());
|
||||
|
||||
struct SyntheticPolicy;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for SyntheticPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::SyntheticResult(ToolResult::error(
|
||||
info.call.id.clone(),
|
||||
"permission denied",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
worker.set_interceptor(SyntheticPolicy);
|
||||
|
||||
let result = worker.run("Test synthetic result").await.unwrap();
|
||||
|
||||
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
|
||||
assert!(result.worker.history().iter().any(|item| matches!(
|
||||
item,
|
||||
llm_worker::Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
is_error: true,
|
||||
..
|
||||
} if call_id == "call_1" && summary == "permission denied"
|
||||
)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user