Compare commits
2 Commits
88e1bfd9f2
...
60cc8e507c
| Author | SHA1 | Date | |
|---|---|---|---|
| 60cc8e507c | |||
| c83d697f93 |
@ -33,6 +33,7 @@ const MANAGED_AGENT_MEMORY_TITLE: &str = "## 用户记忆摘要";
|
||||
const MEMORY_MAINTENANCE_SYSTEM_PROMPT: &str =
|
||||
include_str!("memory_maintenance_system_prompt.md");
|
||||
const MEMORY_MAINTENANCE_RETRY_DELAYS_MS: &[u64] = &[1_000, 3_000];
|
||||
const SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT: &str = "系统说明:当前输入来自一次已经触发的定时任务执行。你现在需要执行任务内容本身,而不是创建、修改、恢复、暂停或查询新的定时任务。除非当前任务内容明确要求管理调度器,否则不要调用任何定时任务管理工具;像“每小时”、“每天”、“cron”、“定时”等词,只应视为任务背景,不应再解释为新的建任务请求。";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MemoryMaintenanceCategory {
|
||||
@ -1481,15 +1482,15 @@ impl SessionManager {
|
||||
session_guard.ensure_chat_loaded(chat_id)?;
|
||||
session_guard.ensure_agent_prompt_before_user_message(chat_id)?;
|
||||
|
||||
if let Some(system_prompt) = options.system_prompt.as_deref() {
|
||||
session_guard.append_persisted_message(
|
||||
chat_id,
|
||||
ChatMessage::system_with_context(
|
||||
system_prompt,
|
||||
Some(SYSTEM_CONTEXT_SCHEDULED_PROMPT.to_string()),
|
||||
),
|
||||
)?;
|
||||
}
|
||||
let scheduled_system_prompt =
|
||||
compose_scheduled_task_system_prompt(options.system_prompt.as_deref());
|
||||
session_guard.append_persisted_message(
|
||||
chat_id,
|
||||
ChatMessage::system_with_context(
|
||||
&scheduled_system_prompt,
|
||||
Some(SYSTEM_CONTEXT_SCHEDULED_PROMPT.to_string()),
|
||||
),
|
||||
)?;
|
||||
|
||||
let user_message = session_guard.create_user_message(prompt, Vec::new());
|
||||
let user_message_id = user_message.id.clone();
|
||||
@ -1575,6 +1576,17 @@ fn should_display_message_to_user(show_tool_results: bool, message: &ChatMessage
|
||||
)
|
||||
}
|
||||
|
||||
fn compose_scheduled_task_system_prompt(system_prompt: Option<&str>) -> String {
|
||||
match system_prompt.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(system_prompt) => format!(
|
||||
"{}\n\n任务专属要求:{}",
|
||||
SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT,
|
||||
system_prompt
|
||||
),
|
||||
None => SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_provider_config(
|
||||
default_provider_config: &LLMProviderConfig,
|
||||
provider_configs: &HashMap<String, LLMProviderConfig>,
|
||||
@ -1936,6 +1948,66 @@ mod tests {
|
||||
assert!(default_outbound[0].content.contains("default-model"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_run_scheduled_agent_task_persists_execution_guard_prompt() {
|
||||
let base_url = start_mock_openai_server().await;
|
||||
let provider_config = LLMProviderConfig {
|
||||
provider_type: "openai".to_string(),
|
||||
name: "default-provider".to_string(),
|
||||
base_url,
|
||||
api_key: "test-key".to_string(),
|
||||
extra_headers: HashMap::new(),
|
||||
model_id: "default-model".to_string(),
|
||||
temperature: Some(0.0),
|
||||
max_tokens: Some(32),
|
||||
model_extra: HashMap::new(),
|
||||
max_tool_iterations: 1,
|
||||
llm_timeout_secs: 30,
|
||||
tool_result_max_chars: 20_000,
|
||||
context_tool_result_trim_chars: 20_000,
|
||||
};
|
||||
|
||||
let session_manager = SessionManager::new(
|
||||
4,
|
||||
100,
|
||||
false,
|
||||
provider_config.clone(),
|
||||
HashMap::from([("default".to_string(), provider_config)]),
|
||||
Arc::new(SkillRuntime::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
session_manager
|
||||
.run_scheduled_agent_task(
|
||||
"feishu",
|
||||
"chat-guard",
|
||||
"每小时执行以下流程:检查邮箱并同步待办",
|
||||
ScheduledAgentTaskOptions {
|
||||
fresh_session: true,
|
||||
system_prompt: Some("你是邮箱待办同步助手。".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let session = session_manager.get("feishu").await.unwrap();
|
||||
let session_guard = session.lock().await;
|
||||
let persisted_messages = session_guard
|
||||
.store
|
||||
.load_messages(&session_guard.persistent_session_id("chat-guard"))
|
||||
.unwrap();
|
||||
|
||||
let scheduled_prompt = persisted_messages
|
||||
.iter()
|
||||
.find(|message| message.has_system_context(SYSTEM_CONTEXT_SCHEDULED_PROMPT))
|
||||
.expect("missing scheduled system prompt");
|
||||
|
||||
assert!(scheduled_prompt.content.contains("已经触发的定时任务执行"));
|
||||
assert!(scheduled_prompt.content.contains("不要调用任何定时任务管理工具"));
|
||||
assert!(scheduled_prompt.content.contains("你是邮箱待办同步助手。"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_summarize_memory_maintenance_for_scope_uses_model_output() {
|
||||
let base_url = start_mock_openai_server().await;
|
||||
|
||||
@ -36,6 +36,13 @@ pub struct OpenAIProvider {
|
||||
model_extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OAIFunctionArguments {
|
||||
Json(Value),
|
||||
String(String),
|
||||
}
|
||||
|
||||
impl OpenAIProvider {
|
||||
pub fn new(
|
||||
name: String,
|
||||
@ -67,6 +74,23 @@ impl OpenAIProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_json_tool_arguments(&self) -> bool {
|
||||
self.model_extra
|
||||
.get("tool_call_arguments_json")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn serialize_tool_arguments(&self, arguments: &Value) -> Value {
|
||||
if self.uses_json_tool_arguments() {
|
||||
arguments.clone()
|
||||
} else {
|
||||
Value::String(
|
||||
serde_json::to_string(arguments).unwrap_or_else(|_| "null".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
|
||||
let mut body = json!({
|
||||
"model": self.model_id,
|
||||
@ -88,7 +112,7 @@ impl OpenAIProvider {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": call.name,
|
||||
"arguments": serde_json::to_string(&call.arguments).unwrap_or_else(|_| "null".to_string())
|
||||
"arguments": self.serialize_tool_arguments(&call.arguments)
|
||||
}
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
@ -170,7 +194,7 @@ struct OpenAIToolCall {
|
||||
#[derive(Deserialize)]
|
||||
struct OAIFunction {
|
||||
name: String,
|
||||
arguments: String,
|
||||
arguments: OAIFunctionArguments,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@ -260,7 +284,12 @@ impl LLMProvider for OpenAIProvider {
|
||||
.map(|tc| ToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
arguments: serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null),
|
||||
arguments: match &tc.function.arguments {
|
||||
OAIFunctionArguments::Json(arguments) => arguments.clone(),
|
||||
OAIFunctionArguments::String(arguments) => {
|
||||
serde_json::from_str(arguments).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -339,6 +368,48 @@ mod tests {
|
||||
assert_eq!(tool_calls[0]["function"]["arguments"], "{\"expression\":\"1+1\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_body_uses_json_tool_arguments_when_enabled() {
|
||||
let provider = OpenAIProvider::new(
|
||||
"test".to_string(),
|
||||
"key".to_string(),
|
||||
"https://example.com/v1".to_string(),
|
||||
HashMap::new(),
|
||||
120,
|
||||
"gpt-test".to_string(),
|
||||
None,
|
||||
None,
|
||||
HashMap::from([(
|
||||
"tool_call_arguments_json".to_string(),
|
||||
Value::Bool(true),
|
||||
)]),
|
||||
);
|
||||
|
||||
let request = ChatCompletionRequest {
|
||||
messages: vec![Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::text("calling tool")],
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "calculator".to_string(),
|
||||
arguments: json!({"expression": "1+1"}),
|
||||
}]),
|
||||
}],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
tools: None,
|
||||
};
|
||||
|
||||
let body = provider.build_request_body(&request);
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
let tool_calls = messages[0]["tool_calls"].as_array().unwrap();
|
||||
|
||||
assert_eq!(tool_calls[0]["function"]["arguments"], json!({"expression": "1+1"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_body_includes_assistant_reasoning_content() {
|
||||
let provider = OpenAIProvider::new(
|
||||
@ -395,4 +466,37 @@ mod tests {
|
||||
|
||||
assert_eq!(response.choices[0].message.reasoning_content.as_deref(), Some("hidden reasoning"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_response_parses_json_tool_arguments() {
|
||||
let response: OpenAIResponse = serde_json::from_value(json!({
|
||||
"id": "resp_1",
|
||||
"model": "gpt-test",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"function": {
|
||||
"name": "scheduler_manage",
|
||||
"arguments": {"action": "list"}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
match &response.choices[0].message.tool_calls[0].function.arguments {
|
||||
OAIFunctionArguments::Json(arguments) => {
|
||||
assert_eq!(arguments, &json!({"action": "list"}));
|
||||
}
|
||||
OAIFunctionArguments::String(_) => panic!("expected JSON tool arguments"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,10 +31,25 @@ impl Tool for SchedulerManageTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Manage DB-backed scheduled jobs. Supports actions: list, get, put, delete, pause, resume. Jobs persist in SQLite and are executed by the scheduler runtime."
|
||||
"Manage DB-backed scheduled jobs. Supports actions: list, get, put, delete, pause, resume. Jobs persist in SQLite and are executed by the scheduler runtime. When creating agent_task jobs, keep prompt/system_prompt focused on the work to perform; do not restate execution times unless the task logic truly depends on them, because the trigger already controls timing."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
let mut allowed_agents = self
|
||||
.known_agents
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
allowed_agents.sort();
|
||||
let agent_hint = if allowed_agents.is_empty() {
|
||||
"agent_task payload.agent may be omitted or set to 'default'.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"agent_task payload.agent may be omitted, set to 'default', or use one of configured agents: {}.",
|
||||
allowed_agents.join(", ")
|
||||
)
|
||||
};
|
||||
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -63,7 +78,7 @@ impl Tool for SchedulerManageTool {
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "Job payload. agent_task supports prompt, agent, fresh_session, system_prompt, sender_id, metadata. outbound_message expects content. internal_event expects event."
|
||||
"description": format!("Job payload. agent_task supports prompt, agent, fresh_session, system_prompt, sender_id, metadata. For agent_task, write prompt/system_prompt as execution instructions and avoid repeating schedule phrases or execution times such as 每天9点 or 每小时 unless the task itself must reason about time. {} outbound_message expects content. internal_event expects event.", agent_hint)
|
||||
},
|
||||
"max_runs": {
|
||||
"type": ["integer", "null"]
|
||||
@ -83,6 +98,18 @@ impl Tool for SchedulerManageTool {
|
||||
context: &crate::tools::ToolContext,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
if args.is_null() {
|
||||
return Ok(error_result(
|
||||
"Missing required parameters: scheduler_manage expects a JSON object like {\"action\":\"list\"}",
|
||||
));
|
||||
}
|
||||
|
||||
if !args.is_object() {
|
||||
return Ok(error_result(
|
||||
"Invalid parameters: scheduler_manage expects a JSON object",
|
||||
));
|
||||
}
|
||||
|
||||
let action = match args.get("action").and_then(|value| value.as_str()) {
|
||||
Some(action) => action,
|
||||
None => return Ok(error_result("Missing required parameter: action")),
|
||||
@ -263,7 +290,28 @@ fn validate_agent_task_payload(payload: &serde_json::Value, known_agents: &HashS
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
anyhow::bail!("Unknown agent '{}' for agent_task payload.agent", normalized)
|
||||
anyhow::bail!(unknown_agent_message(normalized, known_agents))
|
||||
}
|
||||
|
||||
fn unknown_agent_message(agent_name: &str, known_agents: &HashSet<String>) -> String {
|
||||
let mut configured_agents = known_agents.iter().cloned().collect::<Vec<_>>();
|
||||
configured_agents.sort();
|
||||
|
||||
let configured_hint = if configured_agents.is_empty() {
|
||||
"No named agents are configured; use payload.agent='default' or omit payload.agent.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"payload.agent must be omitted, set to 'default', or use one of configured agents: default, {}.",
|
||||
configured_agents.join(", ")
|
||||
)
|
||||
};
|
||||
|
||||
format!(
|
||||
"Unknown agent '{}' for agent_task payload.agent. {} '{}' is not an agent. If you mean a skill, do not put it in payload.agent.",
|
||||
agent_name,
|
||||
configured_hint,
|
||||
agent_name,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_outbound_message_payload(payload: &serde_json::Value) -> anyhow::Result<()> {
|
||||
@ -524,6 +572,66 @@ mod tests {
|
||||
|
||||
assert!(put_result.is_err());
|
||||
let error = put_result.err().unwrap().to_string();
|
||||
assert!(error.contains("Unknown agent 'missing-agent'"));
|
||||
assert!(error.contains("Unknown agent 'missing-agent' for agent_task payload.agent"));
|
||||
assert!(error.contains("payload.agent must be omitted, set to 'default', or use one of configured agents: default, planner"));
|
||||
assert!(error.contains("If you mean a skill, do not put it in payload.agent"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scheduler_manage_accepts_default_agent() {
|
||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||
let tool = SchedulerManageTool::new(store, HashSet::from(["planner".to_string()]));
|
||||
|
||||
let put_result = tool
|
||||
.execute(json!({
|
||||
"action": "put",
|
||||
"id": "agent.default_summary",
|
||||
"kind": "agent_task",
|
||||
"schedule": {
|
||||
"type": "cron",
|
||||
"expression": "0 9 * * *"
|
||||
},
|
||||
"target": {
|
||||
"channel": "feishu",
|
||||
"chat_id": "oc_demo"
|
||||
},
|
||||
"payload": {
|
||||
"prompt": "请总结今天待办",
|
||||
"agent": "default"
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(put_result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scheduler_manage_rejects_null_args_locally() {
|
||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||
let tool = SchedulerManageTool::new(store, HashSet::new());
|
||||
|
||||
let result = tool.execute(serde_json::Value::Null).await.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(
|
||||
result.error.as_deref(),
|
||||
Some("Missing required parameters: scheduler_manage expects a JSON object like {\"action\":\"list\"}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_manage_schema_advises_against_repeating_schedule_in_agent_task_prompt() {
|
||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||
let tool = SchedulerManageTool::new(store, HashSet::new());
|
||||
|
||||
let schema = tool.parameters_schema();
|
||||
let payload_description = schema["properties"]["payload"]["description"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
assert!(payload_description.contains("avoid repeating schedule phrases or execution times"));
|
||||
assert!(payload_description.contains("每天9点"));
|
||||
assert!(payload_description.contains("每小时"));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user