mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: store mcp tool call, result and reasoning on flow conversation rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a9ec0aec3a
commit
d08a324d54
+21
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -58,6 +58,21 @@
|
||||
"ordinal": 8,
|
||||
"name": "success",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "tool_arguments",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tool_result",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "reasoning",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +91,11 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051"
|
||||
"hash": "318ed7a45d8326e3ecbc9727ee3812adaeeead8b39b007a32badadf67ec5ca17"
|
||||
}
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -21,10 +21,13 @@
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1"
|
||||
"hash": "55dbe12954489532644b91e67b2a9df7ede61e050d3e634b795aaf7f60b3b05d"
|
||||
}
|
||||
+21
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -58,6 +58,21 @@
|
||||
"ordinal": 8,
|
||||
"name": "success",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "tool_arguments",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tool_result",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "reasoning",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +91,11 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082"
|
||||
"hash": "d6c65ce1d443d1e1783818d36c7eabd9633bbe5219bfd9683f336de19763cb58"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE flow_conversation_message DROP COLUMN tool_arguments;
|
||||
ALTER TABLE flow_conversation_message DROP COLUMN tool_result;
|
||||
ALTER TABLE flow_conversation_message DROP COLUMN reasoning;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- A chat is rebuilt from its rows without reading jobs. A tool row for a script or flow
|
||||
-- names the tool's own job, which holds its call; an MCP tool and a provider-native tool
|
||||
-- run inside the agent's job, whose result lists every call of the turn with nothing tying
|
||||
-- one to a row. For those the row carries the call itself.
|
||||
ALTER TABLE flow_conversation_message ADD COLUMN tool_arguments TEXT;
|
||||
ALTER TABLE flow_conversation_message ADD COLUMN tool_result TEXT;
|
||||
|
||||
-- The thinking behind this row. The agent job keeps the turn's thinking as one string;
|
||||
-- the rows keep it per iteration, next to the answer or tool call it led to.
|
||||
ALTER TABLE flow_conversation_message ADD COLUMN reasoning TEXT;
|
||||
@@ -36,6 +36,13 @@ pub struct FlowConversationMessage {
|
||||
pub created_seq: i64,
|
||||
pub step_name: Option<String>,
|
||||
pub success: bool,
|
||||
/// The call behind a tool row whose tool has no job of its own — an MCP tool, or a
|
||||
/// provider-native one. Read back from the job otherwise, and null here.
|
||||
pub tool_arguments: Option<String>,
|
||||
pub tool_result: Option<String>,
|
||||
/// The thinking of the iteration that produced this row. The agent job keeps the
|
||||
/// turn's thinking as one string.
|
||||
pub reasoning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -178,7 +185,7 @@ async fn list_messages(
|
||||
let messages = if let Some(after_seq) = query.after_seq {
|
||||
sqlx::query_as!(
|
||||
FlowConversationMessage,
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
|
||||
FROM flow_conversation_message
|
||||
WHERE conversation_id = $1
|
||||
AND created_seq > $2
|
||||
@@ -195,9 +202,9 @@ async fn list_messages(
|
||||
// Fetch messages for this conversation, oldest first, but reverse the order of the messages for easy rendering on the frontend
|
||||
sqlx::query_as!(
|
||||
FlowConversationMessage,
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
|
||||
FROM (
|
||||
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success
|
||||
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
|
||||
FROM flow_conversation_message
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY created_seq DESC
|
||||
|
||||
@@ -706,6 +706,7 @@ pub async fn handle_chat_conversation_messages(
|
||||
MessageType::User,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -28367,6 +28367,26 @@ components:
|
||||
success:
|
||||
type: boolean
|
||||
description: Whether the message is a success
|
||||
tool_arguments:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
The call, for a tool that runs inside the agent's job and so has none of its
|
||||
own: an MCP tool, or a provider-native one such as web search. A Windmill tool
|
||||
is a script or flow with its own job, and its call is read from there instead.
|
||||
tool_result:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
What that same call returned, including the citations of a provider-native web
|
||||
search, and what it failed with when it failed — the row's own text names the
|
||||
tool rather than the reason. Null for a tool whose job holds the answer.
|
||||
reasoning:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
The thinking behind this row, for the iteration that produced it. The agent
|
||||
job's result keeps the turn's thinking as a single string.
|
||||
|
||||
EndpointTool:
|
||||
type: object
|
||||
|
||||
@@ -73,6 +73,17 @@ pub async fn get_or_create_conversation_with_id(
|
||||
Ok(conversation)
|
||||
}
|
||||
|
||||
/// What a row carries beyond its text. A chat is rebuilt from its rows alone, without
|
||||
/// reading jobs. An MCP or provider-native tool runs inside the agent's job, whose result
|
||||
/// holds every call of the turn with nothing tying one to a row, and that job's
|
||||
/// `reasoning` is one string for the whole turn where the rows keep it per iteration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MessageExtras {
|
||||
pub tool_arguments: Option<String>,
|
||||
pub tool_result: Option<String>,
|
||||
pub reasoning: Option<String>,
|
||||
}
|
||||
|
||||
/// Add a message to a conversation using an existing transaction
|
||||
/// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown)
|
||||
/// This allows memory_id to be used for agent memory without requiring a conversation
|
||||
@@ -84,6 +95,7 @@ pub async fn add_message_to_conversation_tx(
|
||||
message_type: MessageType,
|
||||
step_name: Option<&str>,
|
||||
success: bool,
|
||||
extras: Option<&MessageExtras>,
|
||||
) -> Result<()> {
|
||||
// Check if conversation exists first
|
||||
let conversation_exists = sqlx::query!(
|
||||
@@ -104,14 +116,17 @@ pub async fn add_message_to_conversation_tx(
|
||||
|
||||
// Insert the message
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
conversation_id,
|
||||
message_type as MessageType,
|
||||
content,
|
||||
job_id,
|
||||
step_name,
|
||||
success
|
||||
success,
|
||||
extras.and_then(|e| e.tool_arguments.as_deref()),
|
||||
extras.and_then(|e| e.tool_result.as_deref()),
|
||||
extras.and_then(|e| e.reasoning.as_deref())
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
@@ -33,7 +33,7 @@ use windmill_common::{
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
error::Error,
|
||||
flow_conversations::MessageType,
|
||||
flow_conversations::{MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::FlowModuleValue,
|
||||
worker::{to_raw_value, Connection},
|
||||
@@ -235,9 +235,24 @@ async fn execute_mcp_tool_call(
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, true).await?;
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled
|
||||
// An MCP tool runs inside the agent's job, whose result holds every call of the
|
||||
// turn and nothing tying one of them to this row: same job id for all of them,
|
||||
// no call id on the row. Kept here so the card shows this call — and the row
|
||||
// names that job, so retention sweeps it with every other row of the turn.
|
||||
let content = format!("Used {} tool", tool_call.function.name);
|
||||
add_tool_message_to_chat(ctx, None, &content, true).await;
|
||||
let agent_job_id = ctx.job.id;
|
||||
add_tool_message_to_chat(
|
||||
ctx,
|
||||
Some(agent_job_id),
|
||||
&content,
|
||||
true,
|
||||
Some(MessageExtras {
|
||||
tool_arguments: Some(tool_call.function.arguments.clone()),
|
||||
tool_result: Some(result_str),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("MCP tool error: {}", e);
|
||||
@@ -271,8 +286,23 @@ async fn execute_mcp_tool_call(
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled
|
||||
add_tool_message_to_chat(ctx, None, &error_msg, false).await;
|
||||
// Add tool message to conversation if chat_input_enabled. The row is worded from
|
||||
// the tool, like every other tool row, and the error it failed with is its result
|
||||
// — the one field a call that produced nothing else still has something to put in.
|
||||
let agent_job_id = ctx.job.id;
|
||||
let content = format!("Error executing {}", tool_name);
|
||||
add_tool_message_to_chat(
|
||||
ctx,
|
||||
Some(agent_job_id),
|
||||
&content,
|
||||
false,
|
||||
Some(MessageExtras {
|
||||
tool_arguments: Some(tool_call.function.arguments.clone()),
|
||||
tool_result: Some(error_msg.clone()),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,8 +710,13 @@ async fn handle_tool_execution_error(
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled (error case)
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await;
|
||||
// Add tool message to conversation if chat_input_enabled (error case). Worded from the
|
||||
// tool like every other tool row; nothing is put on the row because the tool's own job
|
||||
// holds it — `handle_non_flow_job_error` above completed that job with this error, and it
|
||||
// was pushed with the arguments the step's input transforms produced rather than the raw
|
||||
// ones the model supplied.
|
||||
let content = format!("Error executing {}", tool_call.function.name);
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, false, None).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -782,13 +817,15 @@ async fn handle_tool_execution_success(
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Stream tool result (success case)
|
||||
// The job ran; whether it ran successfully is `success`, and the row stored below is
|
||||
// worded from it. The stream has to carry the same value, or the card the reader watches
|
||||
// and the row that replaces it describe the same call differently.
|
||||
if let Some(stream_event_processor) = ctx.stream_event_processor {
|
||||
let tool_result_event = StreamingEvent::ToolResult {
|
||||
call_id: tool_call.id.clone(),
|
||||
function_name: tool_call.function.name.clone(),
|
||||
result: tool_result,
|
||||
success: true,
|
||||
success,
|
||||
};
|
||||
stream_event_processor
|
||||
.send(tool_result_event, final_events_str)
|
||||
@@ -806,7 +843,7 @@ async fn handle_tool_execution_success(
|
||||
format!("Error executing {}", tool_call.function.name)
|
||||
};
|
||||
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success).await;
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success, None).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -814,9 +851,16 @@ async fn handle_tool_execution_success(
|
||||
/// Add tool message to conversation if chat is enabled
|
||||
async fn add_tool_message_to_chat(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
// The job this row belongs to: the tool's own where it has one, else the agent's, which
|
||||
// is the job it ran inside. Every row names one so that retention collects the whole
|
||||
// turn — `delete_jobs` removes messages by `job_id = ANY(..)` (there is no FK on the
|
||||
// column; `drop_v2_job_side_table_cascades` dropped it), and a row naming no job would
|
||||
// survive every purge and leave a conversation that can never become empty.
|
||||
tool_job_id: Option<Uuid>,
|
||||
content: &str,
|
||||
success: bool,
|
||||
// Only for a tool with no job of its own; a Windmill tool's call is read from its job.
|
||||
extras: Option<MessageExtras>,
|
||||
) {
|
||||
if ctx.omit_output_from_conversation {
|
||||
return;
|
||||
@@ -852,6 +896,7 @@ async fn add_tool_message_to_chat(
|
||||
MessageType::Tool,
|
||||
&step_name,
|
||||
success,
|
||||
extras.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ use windmill_common::flows::FlowModuleValue;
|
||||
use windmill_common::{
|
||||
db::DB,
|
||||
error::Error,
|
||||
flow_conversations::{add_message_to_conversation_tx, MessageType},
|
||||
flow_conversations::{add_message_to_conversation_tx, MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::{InputTransform, Step},
|
||||
jobs::JobKind,
|
||||
@@ -209,6 +209,7 @@ pub async fn add_message_to_conversation(
|
||||
message_type: MessageType,
|
||||
step_name: &Option<String>,
|
||||
success: bool,
|
||||
extras: Option<&MessageExtras>,
|
||||
) -> Result<(), Error> {
|
||||
let mut tx = db.begin().await?;
|
||||
add_message_to_conversation_tx(
|
||||
@@ -219,6 +220,7 @@ pub async fn add_message_to_conversation(
|
||||
message_type,
|
||||
step_name.as_deref(),
|
||||
success,
|
||||
extras,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -44,7 +44,7 @@ use windmill_common::{
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
error::{self, Error},
|
||||
flow_conversations::MessageType,
|
||||
flow_conversations::{MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
|
||||
get_latest_hash_for_path,
|
||||
@@ -1526,6 +1526,13 @@ pub async fn run_agent(
|
||||
let db_clone = db.clone();
|
||||
let message_content = "Used websearch tool successfully".to_string();
|
||||
let step_name = step_name.clone();
|
||||
// The search ran inside the provider's call, so this job's args
|
||||
// describe the agent, not the search: its sources reach the row
|
||||
// only if they are written here.
|
||||
let extras = (!annotations.is_empty()).then(|| MessageExtras {
|
||||
tool_result: serde_json::to_string(&annotations).ok(),
|
||||
..Default::default()
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = add_message_to_conversation(
|
||||
&db_clone,
|
||||
@@ -1535,6 +1542,7 @@ pub async fn run_agent(
|
||||
MessageType::Tool,
|
||||
&step_name,
|
||||
true,
|
||||
extras.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1578,6 +1586,11 @@ pub async fn run_agent(
|
||||
let db_clone = db.clone();
|
||||
let message_content = response_content.clone();
|
||||
let step_name = step_name.clone();
|
||||
// This iteration's thinking goes on the answer's row; the job
|
||||
// result only keeps the turn's thinking as one string.
|
||||
let extras = response_reasoning.clone().map(|reasoning| {
|
||||
MessageExtras { reasoning: Some(reasoning), ..Default::default() }
|
||||
});
|
||||
|
||||
// Spawn task because we do not need to wait for the result
|
||||
tokio::spawn(async move {
|
||||
@@ -1589,6 +1602,7 @@ pub async fn run_agent(
|
||||
MessageType::Assistant,
|
||||
&step_name,
|
||||
true,
|
||||
extras.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1603,6 +1617,44 @@ pub async fn run_agent(
|
||||
}
|
||||
}
|
||||
|
||||
// An iteration that answered with tool calls has no message row to carry its
|
||||
// thinking, and the next iteration's row holds only its own. Stored on a row
|
||||
// of its own so a reader sees what led to the call.
|
||||
if persist_output_to_conversation
|
||||
&& response_content.as_deref().unwrap_or("").is_empty()
|
||||
{
|
||||
if let (Some(memory_id), Some(reasoning)) =
|
||||
(memory_id, response_reasoning.clone())
|
||||
{
|
||||
let agent_job_id = job.id;
|
||||
let db_clone = db.clone();
|
||||
let step_name = step_name.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = add_message_to_conversation(
|
||||
&db_clone,
|
||||
&memory_id,
|
||||
Some(agent_job_id),
|
||||
"",
|
||||
MessageType::Assistant,
|
||||
&step_name,
|
||||
true,
|
||||
Some(&MessageExtras {
|
||||
reasoning: Some(reasoning),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to add reasoning message to conversation {}: {}",
|
||||
memory_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
break;
|
||||
} else if i == max_iterations - 1 {
|
||||
@@ -1716,6 +1768,7 @@ pub async fn run_agent(
|
||||
MessageType::Assistant,
|
||||
&step_name,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -2236,6 +2236,7 @@ async fn add_tool_message_to_conversation(
|
||||
MessageType::Assistant,
|
||||
None,
|
||||
success,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
+22
-6
@@ -1,5 +1,10 @@
|
||||
import type { ChatTransport, UIMessage, UIMessageChunk, UIMessagePart } from 'ai'
|
||||
import { WindmillApiError, WindmillChatApi, type WindmillChatApiOptions } from './api'
|
||||
import {
|
||||
WindmillApiError,
|
||||
WindmillChatApi,
|
||||
type FlowConversationMessage,
|
||||
type WindmillChatApiOptions
|
||||
} from './api'
|
||||
import { followJob } from './follow'
|
||||
import type { AgentStreamEvent } from './stream'
|
||||
import type { ChatMessage, Conversation } from './types'
|
||||
@@ -101,7 +106,8 @@ export function createWindmillChatTransport<UI_MESSAGE extends UIMessage = UIMes
|
||||
stepName: row.step_name ?? undefined,
|
||||
pending: false,
|
||||
seq: row.created_seq,
|
||||
tool: toolFromRowContent(row.message_type, row.content, row.success ?? true)
|
||||
reasoning: row.reasoning ?? undefined,
|
||||
tool: toolFromRow(row)
|
||||
}))
|
||||
) as UI_MESSAGE[]
|
||||
},
|
||||
@@ -123,10 +129,20 @@ export function createWindmillChatTransport<UI_MESSAGE extends UIMessage = UIMes
|
||||
}
|
||||
}
|
||||
|
||||
function toolFromRowContent(role: string, content: string, success: boolean): ChatMessage['tool'] {
|
||||
if (role !== 'tool') return undefined
|
||||
const name = /^Used (.+) tool$/.exec(content)?.[1] ?? /^Error executing (.+)$/.exec(content)?.[1]
|
||||
return name ? { name, status: success ? 'success' : 'error' } : undefined
|
||||
/** The call a stored tool row carries: its tool, named by the sentence the worker words
|
||||
* every tool row from, and the arguments and result the row keeps when its job cannot be
|
||||
* asked for them — for a failed tool, the result is what it failed with. */
|
||||
function toolFromRow(row: FlowConversationMessage): ChatMessage['tool'] {
|
||||
if (row.message_type !== 'tool') return undefined
|
||||
const name =
|
||||
/^Used (.+) tool$/.exec(row.content)?.[1] ?? /^Error executing (.+)$/.exec(row.content)?.[1]
|
||||
if (!name) return undefined
|
||||
return {
|
||||
name,
|
||||
status: (row.success ?? true) ? 'success' : 'error',
|
||||
arguments: row.tool_arguments ?? undefined,
|
||||
result: row.tool_result ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams a job's answer as AI SDK chunks; resumes from `entry.offset` when the job is already running. */
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface FlowConversationMessage {
|
||||
created_seq: number
|
||||
step_name?: string | null
|
||||
success?: boolean
|
||||
/** The call a tool row carries itself, for a tool whose job cannot be asked for it. */
|
||||
tool_arguments?: string | null
|
||||
tool_result?: string | null
|
||||
/** The thinking behind an answer, which is streamed and stored nowhere else. */
|
||||
reasoning?: string | null
|
||||
}
|
||||
|
||||
export type JobUpdateEvent =
|
||||
|
||||
+12
-1
@@ -725,7 +725,18 @@ function fromRow(row: FlowConversationMessage): ChatMessage {
|
||||
stepName: row.step_name ?? undefined,
|
||||
pending: false,
|
||||
seq: row.created_seq,
|
||||
tool: toolName ? { name: toolName, status: success ? 'success' : 'error' } : undefined
|
||||
reasoning: row.reasoning ?? undefined,
|
||||
// The call the row carries, which is all there is of it for a tool that ran inside the
|
||||
// agent's job or failed before it had one of its own. For a failed tool the result is
|
||||
// what it failed with, and the row's text names the tool rather than the reason.
|
||||
tool: toolName
|
||||
? {
|
||||
name: toolName,
|
||||
status: success ? 'success' : 'error',
|
||||
arguments: row.tool_arguments ?? undefined,
|
||||
result: row.tool_result ?? undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
||||
import type { UIMessage, UIMessageChunk } from 'ai'
|
||||
import { createWindmillChatTransport, toUIMessages } from '../src/ai-sdk'
|
||||
import type { ChatMessage } from '../src/types'
|
||||
import { fetchMock, json, ndjson, sse, text, type Route } from './support'
|
||||
import { fetchMock, json, messageRow, ndjson, sse, text, type Route } from './support'
|
||||
|
||||
const FLOW = 'f/chat/agent'
|
||||
const run: Route = (c) =>
|
||||
@@ -141,6 +141,23 @@ describe('createWindmillChatTransport', () => {
|
||||
expect(second[1]).toMatchObject({ errorText: 'ExecutionErr: boom' })
|
||||
})
|
||||
|
||||
test('loads the call an MCP tool row carries and the reasoning behind an answer', async () => {
|
||||
const { fetch } = fetchMock((c) =>
|
||||
c.method === 'GET' && c.url.pathname.endsWith('/messages')
|
||||
? json([
|
||||
messageRow(1, 'user', 'hi'),
|
||||
messageRow(2, 'tool', 'Used lookup tool', { job_id: 'agent-job', tool_arguments: '{"q":1}', tool_result: '42' }),
|
||||
messageRow(3, 'assistant', 'The answer is 42', { reasoning: 'hmm' })
|
||||
])
|
||||
: undefined
|
||||
)
|
||||
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, fetch })
|
||||
const ui = await transport.loadMessages('c')
|
||||
expect(ui.map((m) => m.parts.map((p) => p.type))).toEqual([['text'], ['dynamic-tool', 'reasoning', 'text']])
|
||||
expect(ui[1].parts[0]).toMatchObject({ toolName: 'lookup', state: 'output-available', input: { q: 1 }, output: 42 })
|
||||
expect(ui[1].parts[1]).toMatchObject({ type: 'reasoning', text: 'hmm' })
|
||||
})
|
||||
|
||||
test('refuses attachments with a clear error', async () => {
|
||||
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, fetch: fetchMock().fetch })
|
||||
await expect(
|
||||
|
||||
@@ -327,6 +327,38 @@ describe('createChat with server history', () => {
|
||||
expect(messagesCall.headers.authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
test('a persisted row brings back its reasoning and the call an MCP tool row carries', async () => {
|
||||
const { fetch } = fetchMock(
|
||||
(c) =>
|
||||
c.method === 'GET' && c.url.pathname === '/api/w/ws/flow_conversations/conv-1/messages'
|
||||
? json([
|
||||
messageRow(1, 'user', 'hi'),
|
||||
messageRow(2, 'tool', 'Used lookup tool', {
|
||||
job_id: 'agent-job',
|
||||
tool_arguments: '{"q":1}',
|
||||
tool_result: '42'
|
||||
}),
|
||||
messageRow(3, 'tool', 'Error executing lookup', {
|
||||
job_id: 'agent-job',
|
||||
success: false,
|
||||
tool_arguments: '{"q":2}',
|
||||
tool_result: 'MCP tool error: boom'
|
||||
}),
|
||||
messageRow(4, 'assistant', 'The answer is 42', { reasoning: 'hmm' }),
|
||||
messageRow(5, 'assistant', 'Hello')
|
||||
])
|
||||
: undefined
|
||||
)
|
||||
const chat = createChat(options({ history: 'server' }, fetch))
|
||||
await chat.selectConversation('conv-1')
|
||||
|
||||
const [, used, failed, answer, plain] = chat.getState().messages
|
||||
expect(used.tool).toEqual({ name: 'lookup', status: 'success', arguments: '{"q":1}', result: '42' })
|
||||
expect(failed.tool).toEqual({ name: 'lookup', status: 'error', arguments: '{"q":2}', result: 'MCP tool error: boom' })
|
||||
expect(answer.reasoning).toBe('hmm')
|
||||
expect(plain.reasoning).toBeUndefined()
|
||||
})
|
||||
|
||||
test('keeps the streamed answer until its row lands, even when a tool row lands first', async () => {
|
||||
let messageFetches = 0
|
||||
const { fetch } = fetchMock(
|
||||
|
||||
Reference in New Issue
Block a user