You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/16 02:17:23 UTC
(maka) branch rust-preview updated: feat(agent): add configurable shell command titles
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/rust-preview by this push:
new e0206c05f3 feat(agent): add configurable shell command titles
e0206c05f3 is described below
commit e0206c05f3d580aa7bdb96f1dedf232774592b12
Author: Wang <m4...@gmail.com>
AuthorDate: Fri Oct 16 10:16:52 2026 +0800
feat(agent): add configurable shell command titles
Generated-by: Codex
---
packages/plugin-sdk/src/configurations.ts | 28 +++++-
packages/plugin-sdk/src/contracts.ts | 18 ++++
packages/plugin-sdk/src/generated/runtime.ts | 7 +-
plugins/agent/api/src/contract.rs | 2 +
plugins/agent/assistant-preferences/index.ts | 18 ++++
plugins/agent/assistant-preferences/locale.ts | 4 +
plugins/agent/facts/src/configuration/policy.rs | 1 +
.../agent/facts/src/configuration/policy/codec.rs | 2 +-
.../facts/src/configuration/policy/settings.rs | 1 +
plugins/agent/facts/src/display.rs | 11 +++
plugins/agent/native/README.md | 2 +
plugins/agent/native/README.zh-CN.md | 2 +
plugins/agent/native/src/owner.rs | 1 +
plugins/agent/native/src/owner/tests/processes.rs | 100 +++++++++++++++++++++
.../agent/native/src/owner/tests/service_tools.rs | 8 +-
plugins/agent/native/src/owner/tests/settings.rs | 17 ++++
plugins/agent/native/src/owner/tests/skills.rs | 4 +-
plugins/agent/native/src/tools.rs | 38 +++++++-
plugins/agent/native/src/tools/computer.rs | 12 +--
plugins/agent/native/src/tools/shell.rs | 97 ++++++++++++++++++++
plugins/agent/tool-catalog/src/request.rs | 49 +++++++---
plugins/settings/native/src/preferences.rs | 10 +++
.../store/migrations/0002_shell_titles.sql | 24 +++++
plugins/settings/store/src/policy.rs | 11 +++
plugins/settings/store/tests/runtime_policy.rs | 40 +++++++++
plugins/shared/tui-messages.ts | 11 +++
products/maka/bundle/src/composition.ts | 2 +
.../bundle/tests/assistant-preferences.test.mjs | 75 ++++++++++++++++
28 files changed, 561 insertions(+), 34 deletions(-)
diff --git a/packages/plugin-sdk/src/configurations.ts b/packages/plugin-sdk/src/configurations.ts
index 05c657e7c9..9ed1af5308 100644
--- a/packages/plugin-sdk/src/configurations.ts
+++ b/packages/plugin-sdk/src/configurations.ts
@@ -419,8 +419,18 @@ export const configurations = {
required: ['result', 'confirmation', 'name', 'description', 'import', 'method'],
},
AgentToolInput: {
- type: 'string',
- enum: ['json', 'skill', 'web'],
+ oneOf: [
+ {
+ type: 'string',
+ enum: ['json', 'skill', 'web'],
+ },
+ {
+ description:
+ 'Display-only command title; recorded in history, not forwarded to the process service.',
+ type: 'string',
+ const: 'shell',
+ },
+ ],
},
DisplayText: {
description: 'Presentation text is distinct from the stable identity used for navigation.',
@@ -907,8 +917,18 @@ export const configurations = {
required: ['result', 'confirmation', 'name', 'description', 'import', 'method'],
},
AgentToolInput: {
- type: 'string',
- enum: ['json', 'skill', 'web'],
+ oneOf: [
+ {
+ type: 'string',
+ enum: ['json', 'skill', 'web'],
+ },
+ {
+ type: 'string',
+ const: 'shell',
+ description:
+ 'Display-only command title; recorded in history, not forwarded to the process service.',
+ },
+ ],
},
DisplayText: {
type: 'object',
diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts
index 66dea75adf..f1cbebd431 100644
--- a/packages/plugin-sdk/src/contracts.ts
+++ b/packages/plugin-sdk/src/contracts.ts
@@ -36675,6 +36675,9 @@ export const contracts = {
incognitoActive: {
type: 'boolean',
},
+ shellTitles: {
+ type: 'boolean',
+ },
},
additionalProperties: false,
required: [
@@ -36683,6 +36686,7 @@ export const contracts = {
'assistantTone',
'workspaceInstructions',
'incognitoActive',
+ 'shellTitles',
],
},
resources: {},
@@ -39987,6 +39991,20 @@ export const contracts = {
additionalProperties: false,
required: ['kind', 'incognitoActive'],
},
+ {
+ type: 'object',
+ properties: {
+ enabled: {
+ type: 'boolean',
+ },
+ kind: {
+ type: 'string',
+ const: 'shell_titles',
+ },
+ },
+ additionalProperties: false,
+ required: ['kind', 'enabled'],
+ },
],
},
},
diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts
index abfeb9050b..cc40e0eee6 100644
--- a/packages/plugin-sdk/src/generated/runtime.ts
+++ b/packages/plugin-sdk/src/generated/runtime.ts
@@ -1459,7 +1459,7 @@ export type EditorCapture =
* Session sandbox policy. Full access explicitly disables filesystem and process isolation.
*/
export type AgentWorkspaceAccess = 'read_only' | 'workspace_write' | 'danger_full_access';
-export type AgentToolInput = 'json' | 'skill' | 'web';
+export type AgentToolInput = ('json' | 'skill' | 'web') | 'shell';
export type AgentToolResult = ('json' | 'image') | 'computer';
export type AgentToolConfirmation = 'required' | 'preapproved';
export type Network =
@@ -2369,6 +2369,10 @@ export type SettingsPreferencesMutation =
| {
incognitoActive: boolean;
kind: 'privacy';
+ }
+ | {
+ enabled: boolean;
+ kind: 'shell_titles';
};
export type SettingsPreferencesUpdated =
| {
@@ -8197,6 +8201,7 @@ export interface SettingsAssistantPreferences {
assistantTone: string;
workspaceInstructions: boolean;
incognitoActive: boolean;
+ shellTitles: boolean;
}
export interface SettingsNetworkSnapshot {
revision: number;
diff --git a/plugins/agent/api/src/contract.rs b/plugins/agent/api/src/contract.rs
index bf033dabf6..bcec5b63a7 100644
--- a/plugins/agent/api/src/contract.rs
+++ b/plugins/agent/api/src/contract.rs
@@ -165,6 +165,8 @@ pub enum ToolInput {
Json,
Skill,
Web,
+ /// Display-only command title; recorded in history, not forwarded to the process service.
+ Shell,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
diff --git a/plugins/agent/assistant-preferences/index.ts b/plugins/agent/assistant-preferences/index.ts
index a4ff597197..da20a081a7 100644
--- a/plugins/agent/assistant-preferences/index.ts
+++ b/plugins/agent/assistant-preferences/index.ts
@@ -236,6 +236,19 @@ export function assistantPreferences<N extends Node>(
false,
{ background: 0, align: 'start' },
),
+ ui.text(
+ 'assistant-shell-titles-help',
+ t('assistant-shell-titles-help'),
+ 13,
+ props.theme.muted,
+ ),
+ ui.button(
+ 'shell-titles',
+ `${t('assistant-shell-titles')}: ${state.saved?.shellTitles ? text.settingOn : text.settingOff}`,
+ busy || !state.saved,
+ false,
+ { background: 0, align: 'start' },
+ ),
ui.text('assistant-privacy-help', t('assistant-privacy-help'), 13, props.theme.muted),
ui.button(
'privacy',
@@ -468,6 +481,11 @@ export function assistantPreferences<N extends Node>(
expectedRevision: state.saved.revision,
mutation: { kind: 'privacy', incognitoActive: !state.saved.incognitoActive },
});
+ else if (input.key === 'shell-titles' && state.saved)
+ mutate(state, actions, {
+ expectedRevision: state.saved.revision,
+ mutation: { kind: 'shell_titles', enabled: !state.saved.shellTitles },
+ });
else if (input.key === 'save' && state.saved) {
const name = state.fields.name.observation,
tone = state.fields.tone.observation;
diff --git a/plugins/agent/assistant-preferences/locale.ts b/plugins/agent/assistant-preferences/locale.ts
index 7c0280994b..639139c42d 100644
--- a/plugins/agent/assistant-preferences/locale.ts
+++ b/plugins/agent/assistant-preferences/locale.ts
@@ -31,6 +31,8 @@ import {
tui_assistant_conflict,
tui_assistant_instructions,
tui_assistant_instructions_help,
+ tui_assistant_shell_titles,
+ tui_assistant_shell_titles_help,
tui_assistant_invalid,
tui_assistant_name,
tui_assistant_privacy,
@@ -61,6 +63,8 @@ const terminalMessages = {
'assistant-conflict': tui_assistant_conflict,
'assistant-instructions': tui_assistant_instructions,
'assistant-instructions-help': tui_assistant_instructions_help,
+ 'assistant-shell-titles': tui_assistant_shell_titles,
+ 'assistant-shell-titles-help': tui_assistant_shell_titles_help,
'assistant-invalid': tui_assistant_invalid,
'assistant-name': tui_assistant_name,
'assistant-privacy': tui_assistant_privacy,
diff --git a/plugins/agent/facts/src/configuration/policy.rs b/plugins/agent/facts/src/configuration/policy.rs
index 815fb5713a..4e664c03c0 100644
--- a/plugins/agent/facts/src/configuration/policy.rs
+++ b/plugins/agent/facts/src/configuration/policy.rs
@@ -106,6 +106,7 @@ impl Default for RuntimePolicy {
shell: ShellPolicy {
preference: ShellPreference::Auto,
executable: String::new(),
+ titles: true,
},
external_agents: ExternalAgents::default(),
}
diff --git a/plugins/agent/facts/src/configuration/policy/codec.rs b/plugins/agent/facts/src/configuration/policy/codec.rs
index e36178bdbc..e1206444e8 100644
--- a/plugins/agent/facts/src/configuration/policy/codec.rs
+++ b/plugins/agent/facts/src/configuration/policy/codec.rs
@@ -204,7 +204,7 @@ mod tests {
// Host rejects controls before trimming; shell checks after trimming.
assert!(
normalize_mutation(json!({"expectedRevision":0,"operation":{
- "kind":"set_shell","value":{"preference":"auto","executable":"\n x \n"}}}))
+ "kind":"set_shell","value":{"preference":"auto","executable":"\n x \n","titles":true}}}))
.is_ok()
);
}
diff --git a/plugins/agent/facts/src/configuration/policy/settings.rs b/plugins/agent/facts/src/configuration/policy/settings.rs
index 22bb9618d4..450340dfc6 100644
--- a/plugins/agent/facts/src/configuration/policy/settings.rs
+++ b/plugins/agent/facts/src/configuration/policy/settings.rs
@@ -73,6 +73,7 @@ pub enum ShellPreference {
pub struct ShellPolicy {
pub preference: ShellPreference,
pub executable: String,
+ pub titles: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/plugins/agent/facts/src/display.rs b/plugins/agent/facts/src/display.rs
index dbad7a0460..52cdf92bf1 100644
--- a/plugins/agent/facts/src/display.rs
+++ b/plugins/agent/facts/src/display.rs
@@ -31,6 +31,17 @@ pub struct Text {
pub translations: BTreeMap<String, String>,
}
impl Text {
+ /// A bounded display line from model-authored text, never executable input.
+ pub fn single_line(value: &str) -> Option<Self> {
+ let mut value = value.split_whitespace().collect::<Vec<_>>().join(" ");
+ value.retain(|c| {
+ !c.is_control() && !matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
+ });
+ value.truncate(value.floor_char_boundary(value.len().min(256)));
+ let title = Self::plain(value);
+ title.validate().ok().map(|()| title)
+ }
+
pub fn plain(value: impl Into<String>) -> Self {
Self {
fallback: value.into(),
diff --git a/plugins/agent/native/README.md b/plugins/agent/native/README.md
index 9ab3304c76..3115bbe475 100644
--- a/plugins/agent/native/README.md
+++ b/plugins/agent/native/README.md
@@ -37,6 +37,8 @@ The default Maka composition enables `toolDiscovery`: optional tool schemas load
The independent `applyPatch` model override selects either patch editing or structured Write/Edit tools. Like Code Mode, it defaults on for GPT and DeepSeek models and the DeepSeek endpoint, and is frozen for queued work and continuations. Selection follows the bound Files contract and method even when a composition renames its tools. Read tools remain available, and all file operations retain their live permission checks. The connection settings model-overrides JSON accepts `codeMode` a [...]
+Shell tools accept an optional model-authored `title`, enabled by default. Assistant preferences can disable it for subsequent model requests; existing calls retain their complete arguments in model history. The title is recorded for display and removed only from the process-service request. Missing titles use the command as their display label.
+
The default Maka composition enables `todo`: `todo_read` and `todo_write` maintain a persistent checklist for the current session, including in Code Mode and read-only workspaces. Writes replace the whole list with revision checks. The UI shows reported progress and a read-only Checklist panel, with bounded pages covering up to 200 items. Checklist completion is model-reported progress, not proof that an operation succeeded.
The default Maka composition enables `automaticTitles`. Conversations created with `automaticTitle: true` receive one title attempt after their first admitted message, using the accepted model with a bounded, separately accounted request. Explicit titles, manual renames (including the unchanged placeholder), archived and managed sessions retain their names. Retries and Host restarts do not repeat an admitted title attempt; Host shutdown waits for its physical cleanup.
diff --git a/plugins/agent/native/README.zh-CN.md b/plugins/agent/native/README.zh-CN.md
index a255e16d1e..f2d6255110 100644
--- a/plugins/agent/native/README.zh-CN.md
+++ b/plugins/agent/native/README.zh-CN.md
@@ -37,6 +37,8 @@ Maka 默认组合启用 `toolDiscovery`:可选工具通过 `tool_search` 加
独立的模型覆盖项 `applyPatch` 选择补丁编辑或 Write/Edit 结构化编辑。与 Code Mode 一样,GPT、DeepSeek 模型及 DeepSeek 端点默认启用;排队消息和恢复运行保留接收时的选择。筛选依据是实际绑定的 Files 契约和方法,工具改名不影响策略。读取工具保留,文件操作仍检查实时权限。连接设置的模型覆盖 JSON 可分别设置 `codeMode` 与 `applyPatch`。
+Shell 工具默认接受模型生成的可选 `title`。助手偏好可关闭后续模型请求中的标题参数;已有调用的完整参数仍保留在模型历史中。标题用于历史展示,仅在转发给进程服务时移除;未提供标题时显示命令。
+
Maka 默认组合启用 `todo`:`todo_read` 和 `todo_write` 维护当前会话的持久清单,也可用于 Code Mode 和只读工作区。写入会校验版本并替换整个列表。UI 显示已报告的进度和只读「清单」面板,以有界分页浏览最多 200 项。清单完成状态是模型报告的进度,不代表操作已成功的执行证据。
Maka 默认组合启用 `automaticTitles`。通过 `automaticTitle: true` 创建的会话在首条消息获准后,使用该消息选定的模型发起一次有界、独立计费的标题请求。显式命名、手动改名(包括保留原占位名称)、归档及托管会话保留原名称。重试和 Host 重启不会重复已接收的标题尝试;Host 关闭会等待请求实际清理完成。
diff --git a/plugins/agent/native/src/owner.rs b/plugins/agent/native/src/owner.rs
index 625e7b32fc..219f021082 100644
--- a/plugins/agent/native/src/owner.rs
+++ b/plugins/agent/native/src/owner.rs
@@ -324,6 +324,7 @@ impl Owner {
let prepared_tools = crate::tools::catalog(
context.services(),
persistence.store().clone(),
+ configuration.clone(),
&settings.tools,
additional,
workers.clone(),
diff --git a/plugins/agent/native/src/owner/tests/processes.rs b/plugins/agent/native/src/owner/tests/processes.rs
index 7bfe31f2da..7fcb797ec6 100644
--- a/plugins/agent/native/src/owner/tests/processes.rs
+++ b/plugins/agent/native/src/owner/tests/processes.rs
@@ -92,6 +92,106 @@ pub(super) async fn close(f: &Fixture) {
fn execute(command: &str) -> Value {
json!({"command":command,"login":false,"timeout_ms":5000})
}
+#[tokio::test]
+async fn shell_titles_preserve_model_history_and_change_only_future_request_schemas() {
+ tokio::time::timeout(Duration::from_secs(60), async {
+ let (listener, f, session) = fixture().await;
+ f.send(&session).await.unwrap();
+ let (mut socket, _) = listener.accept().await.unwrap();
+ let sent = request(&mut socket).await;
+ let schema = |sent: &Value| {
+ sent["tools"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|tool| tool["function"]["name"] == "Execute")
+ .unwrap()["function"]["parameters"]
+ .clone()
+ };
+ assert_eq!(schema(&sent)["properties"]["title"]["type"], "string");
+ assert!(
+ !schema(&sent)["required"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("title"))
+ );
+ // A request already sent to the model keeps its advertised input contract.
+ let policy = f.config.runtime_policy().await.unwrap();
+ f.config
+ .set_shell_titles(policy.revision, false)
+ .await
+ .unwrap();
+ let mut input = execute("printf actual");
+ input["title"] = json!(" 检查\n输出 ");
+ service_tools::named_tool_response(&mut socket, "Execute", input.clone()).await;
+ let (mut socket, _) = listener.accept().await.unwrap();
+ let sent = request(&mut socket).await;
+ assert!(schema(&sent)["properties"].get("title").is_none());
+ let messages = sent["messages"].as_array().unwrap();
+ let model_call = messages
+ .iter()
+ .flat_map(|m| m["tool_calls"].as_array().into_iter().flatten())
+ .find(|call| call["function"]["name"] == "Execute")
+ .unwrap();
+ assert_eq!(
+ serde_json::from_str::<Value>(model_call["function"]["arguments"].as_str().unwrap())
+ .unwrap(),
+ input
+ );
+ let result: Value = serde_json::from_str(
+ messages.iter().rev().find(|m| m["role"] == "tool").unwrap()["content"]
+ .as_str()
+ .unwrap(),
+ )
+ .unwrap();
+ assert_eq!(result["value"]["output"]["stdout"], "actual", "{result}");
+ response(&mut socket).await;
+ f.drained().await;
+ let recorded = service_tools::facts(&f, &session).await;
+ assert!(recorded.iter().any(
+ |fact| matches!(fact, maka_agent_facts::event::Fact::ToolDispatched {
+ title: Some(title), input: recorded, ..
+ } if title.fallback == "检查 输出" && recorded == &input)
+ ));
+ let mut invalid = execute("printf forbidden > disabled-title");
+ invalid["title"] = json!("Must reject");
+ call(&listener, &f, &session, "Execute", invalid).await;
+ let (mut socket, _) = listener.accept().await.unwrap();
+ let sent = request(&mut socket).await;
+ let rejected = sent["messages"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .rev()
+ .find(|m| m["role"] == "tool")
+ .unwrap();
+ assert_eq!(
+ rejected["content"],
+ "invalid tool input: arguments do not match the declared schema"
+ );
+ assert!(!f.workspace.join("disabled-title").exists());
+ response(&mut socket).await;
+ f.drained().await;
+ let result = roundtrip(
+ &listener,
+ &f,
+ &session,
+ "Execute",
+ execute("printf fallback"),
+ )
+ .await;
+ assert_eq!(result["value"]["output"]["stdout"], "fallback");
+ let recorded = service_tools::facts(&f, &session).await;
+ assert!(recorded.iter().any(
+ |fact| matches!(fact, maka_agent_facts::event::Fact::ToolDispatched {
+ title: Some(title), ..
+ } if title.fallback == "printf fallback")
+ ));
+ close(&f).await;
+ })
+ .await
+ .unwrap();
+}
fn permissions(f: &Fixture) -> Value {
json!({"filesystem":[
{"path":f.workspace.join("first.txt"),"scope":"exact","access":"write"},
diff --git a/plugins/agent/native/src/owner/tests/service_tools.rs b/plugins/agent/native/src/owner/tests/service_tools.rs
index fdd8ded096..1125c5760c 100644
--- a/plugins/agent/native/src/owner/tests/service_tools.rs
+++ b/plugins/agent/native/src/owner/tests/service_tools.rs
@@ -411,7 +411,11 @@ impl Setup {
methods
.into_iter()
.map(|(method, name)| contract::ServiceTool {
- input: contract::ToolInput::Json,
+ input: if method == "execute" {
+ contract::ToolInput::Shell
+ } else {
+ contract::ToolInput::Json
+ },
title: None,
name: name.into(),
method: method.into(),
@@ -2321,7 +2325,7 @@ async fn code_mode_wait_observes_original_native_process_and_its_workspace_effec
fixture.send(&session).await.unwrap();
let (mut socket, _) = listener.accept().await.unwrap();
request(&mut socket).await;
- code_response(&mut socket, "// @exec: {\"yield_time_ms\":0}\nconst result = await tools.Execute({command:'printf started > process-started; while [ ! -f process-release ]; do sleep 0.02; done; printf native-code-mode > process-result; cat process-result',login:false,timeout_ms:30000}); if (result.kind !== 'completed' || result.value.status !== 'completed') throw Error(JSON.stringify(result)); text(result.value.output.stdout);").await;
+ code_response(&mut socket, "// @exec: {\"yield_time_ms\":0}\nconst result = await tools.Execute({title:'验证后台进程',command:'printf started > process-started; while [ ! -f process-release ]; do sleep 0.02; done; printf native-code-mode > process-result; cat process-result',login:false,timeout_ms:30000}); if (result.kind !== 'completed' || result.value.status !== 'completed') throw Error(JSON.stringify(result)); text(result.value.output.stdout);").await;
let (mut socket, _) = listener.accept().await.unwrap();
let prompt = request(&mut socket).await;
let message = prompt["messages"].as_array().unwrap().iter().rev().find(|message| message["role"] == "tool").unwrap();
diff --git a/plugins/agent/native/src/owner/tests/settings.rs b/plugins/agent/native/src/owner/tests/settings.rs
index 1c7626f987..81be5b01e7 100644
--- a/plugins/agent/native/src/owner/tests/settings.rs
+++ b/plugins/agent/native/src/owner/tests/settings.rs
@@ -512,6 +512,23 @@ async fn assistant_preferences_and_capability_guidance_freeze_at_message_accepta
"mutation":{"kind":"privacy","incognitoActive":false}
}), None).await;
assert_eq!(stale["kind"], "revision_conflict");
+ let current = query(&fixture, "assistantPreferences", json!({})).await;
+ assert_eq!(current["shellTitles"], true);
+ let (disabled, reference) = mutate(&fixture, "updateAssistantPreferences", json!({
+ "expectedRevision":current["revision"],
+ "mutation":{"kind":"shell_titles","enabled":false}
+ }), None).await;
+ assert_eq!(disabled["kind"], "committed");
+ assert_eq!(query(&fixture, "assistantPreferences", json!({})).await["shellTitles"], false);
+ assert!(!fixture.config.runtime_policy().await.unwrap().policy.shell.titles);
+ assert_eq!(fixture.store.operation_observation(&reference).await.unwrap().unwrap().outcome,
+ OperationOutcome::Returned { value: disabled });
+ let (stale, _) = mutate(&fixture, "updateAssistantPreferences", json!({
+ "expectedRevision":current["revision"],
+ "mutation":{"kind":"shell_titles","enabled":true}
+ }), None).await;
+ assert_eq!(stale["kind"], "revision_conflict");
+ assert_eq!(query(&fixture, "assistantPreferences", json!({})).await["shellTitles"], false);
fixture.app.shutdown().await.unwrap();
fixture.config.shutdown().await.unwrap();
}).await.unwrap();
diff --git a/plugins/agent/native/src/owner/tests/skills.rs b/plugins/agent/native/src/owner/tests/skills.rs
index 8d6e986a9f..42b73fb993 100644
--- a/plugins/agent/native/src/owner/tests/skills.rs
+++ b/plugins/agent/native/src/owner/tests/skills.rs
@@ -69,7 +69,7 @@ async fn selected_skill_tools_use_actual_agent_context_and_original_grants_over_
"binding" => invalid[0].import = "absent".into(),
_ => unreachable!(),
}
- assert_eq!(crate::tools::catalog(f.services.clone(), f.store.clone(), &invalid, vec![], Default::default()).err().as_deref(), Some(expected));
+ assert_eq!(crate::tools::catalog(f.services.clone(), f.store.clone(), f.config.clone(), &invalid, vec![], Default::default()).err().as_deref(), Some(expected));
}
let session = f.create("Skill tools").await;
f.send(&session).await.unwrap();
@@ -147,7 +147,7 @@ async fn selected_skill_tools_use_actual_agent_context_and_original_grants_over_
let f = Fixture::with_services("http://127.0.0.1:9/v1", None, None, Some(service_tools::Setup::forms())).await;
let mut invalid = f.owner.settings.tools.clone();
invalid[0].input = contract::ToolInput::Skill;
- assert_eq!(crate::tools::catalog(f.services.clone(), f.store.clone(), &invalid, vec![], Default::default()).err().as_deref(), Some("agent_skill_tool_contract_invalid"));
+ assert_eq!(crate::tools::catalog(f.services.clone(), f.store.clone(), f.config.clone(), &invalid, vec![], Default::default()).err().as_deref(), Some("agent_skill_tool_contract_invalid"));
f.app.shutdown().await.unwrap();
f.config.shutdown().await.unwrap();
}).await.unwrap();
diff --git a/plugins/agent/native/src/tools.rs b/plugins/agent/native/src/tools.rs
index 30ae5cabba..85fa9f4a28 100644
--- a/plugins/agent/native/src/tools.rs
+++ b/plugins/agent/native/src/tools.rs
@@ -21,6 +21,7 @@
mod approval;
mod computer;
pub(crate) mod image;
+mod shell;
mod web;
use crate::contract::{ServiceTool, ToolConfirmation, ToolInput, ToolResult};
use futures_util::FutureExt;
@@ -73,6 +74,7 @@ pub(crate) struct Catalog {
pub(crate) fn catalog(
services: NativeServices,
store: Arc<EventLog>,
+ configuration: Arc<maka_config::ConfigurationStore>,
selected: &[ServiceTool],
additional: Vec<ToolRegistration>,
workers: tokio_util::task::TaskTracker,
@@ -104,6 +106,7 @@ pub(crate) fn catalog(
let web_contract = maka_web_api::contract();
let mut web_bindings = Vec::new();
let mut computer_bindings = Vec::new();
+ let mut shell_bindings = Vec::new();
let skill_contract = maka_skills_api::contract();
let tool_names: Arc<Vec<(String, Option<EditingTools>)>> = Arc::new(
selected
@@ -174,6 +177,15 @@ pub(crate) fn catalog(
.map_err(|_| "agent_tool_title_invalid".to_owned())?;
}
let input_schema = match selection.input {
+ ToolInput::Shell => {
+ if binding.contract() != &maka_workspace_api::processes::contract()
+ || selection.method != "execute"
+ || selection.result != ToolResult::Json
+ {
+ return Err("agent_shell_tool_contract_invalid".into());
+ }
+ shell::schema(method.input.clone(), true)
+ }
ToolInput::Json => {
if binding.contract() == &maka_web_api::contract()
&& maka_web_api::contract::model_input_schema(&selection.method).is_some()
@@ -240,6 +252,16 @@ pub(crate) fn catalog(
}),
));
}
+ if selection.input == ToolInput::Shell {
+ shell_bindings.push((
+ selection.name.clone(),
+ Arc::new(shell::Binding {
+ service: service.clone(),
+ configuration: configuration.clone(),
+ input: method.input.clone(),
+ }),
+ ));
+ }
if selection.input == ToolInput::Web {
web_bindings.push((
selection.name.clone(),
@@ -287,6 +309,11 @@ pub(crate) fn catalog(
.with_request_binding([name].into(), binding)
.map_err(|_| "agent_computer_binding_invalid")?;
}
+ for (name, binding) in shell_bindings {
+ catalog = catalog
+ .with_request_binding([name].into(), binding)
+ .map_err(|_| "agent_shell_binding_invalid")?;
+ }
Ok(Catalog {
tools: catalog,
basis,
@@ -333,7 +360,9 @@ impl ToolPreparer for Service {
}
// No request or effect is admitted until the existing ToolJournal T1.
let title = selection.title.clone().or_else(|| {
- if selection.input == ToolInput::Skill {
+ if selection.input == ToolInput::Shell {
+ shell::title(&input)
+ } else if selection.input == ToolInput::Skill {
maka_skills_api::model_title(&selection.method, &input)
} else {
(selection.result == ToolResult::Computer)
@@ -362,6 +391,13 @@ impl ToolPreparer for Service {
}
let input = match selection.input {
ToolInput::Json => input,
+ ToolInput::Shell => {
+ let mut input = input;
+ if let Some(fields) = input.as_object_mut() {
+ fields.remove("title");
+ }
+ input
+ }
ToolInput::Web => {
let basis = web_basis.ok_or_else(|| {
ToolError::Failed("Web tool has no request capture".into())
diff --git a/plugins/agent/native/src/tools/computer.rs b/plugins/agent/native/src/tools/computer.rs
index c4f918c3e3..d3ae4c641d 100644
--- a/plugins/agent/native/src/tools/computer.rs
+++ b/plugins/agent/native/src/tools/computer.rs
@@ -37,16 +37,8 @@ pub(super) fn title(method: &str, input: &Value) -> maka_agent_facts::display::T
"重設電腦操作環境",
);
}
- if let Some(value) = input["title"].as_str() {
- let mut value = value.split_whitespace().collect::<Vec<_>>().join(" ");
- value.retain(|c| {
- !c.is_control() && !matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
- });
- value.truncate(value.floor_char_boundary(value.len().min(256)));
- let title = Text::plain(value);
- if title.validate().is_ok() {
- return title;
- }
+ if let Some(title) = input["title"].as_str().and_then(Text::single_line) {
+ return title;
}
Text::localized("Use computer", "操作电脑", "操作電腦")
}
diff --git a/plugins/agent/native/src/tools/shell.rs b/plugins/agent/native/src/tools/shell.rs
new file mode 100644
index 0000000000..e6d50173fa
--- /dev/null
+++ b/plugins/agent/native/src/tools/shell.rs
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_agent_facts::display::Text;
+use maka_tools::request::{BindingRequest, BoundTool, RequestBinding};
+use std::collections::BTreeMap;
+
+pub(super) fn schema(mut input: Value, titles: bool) -> Value {
+ if titles {
+ input["properties"]["title"] = serde_json::json!({
+ "type": "string",
+ "maxLength": 256,
+ "description": "Short, user-facing description of what this command does, in the user's language. Describe the action, not an assumed result."
+ });
+ }
+ input
+}
+
+pub(super) fn title(input: &Value) -> Option<Text> {
+ input["title"]
+ .as_str()
+ .and_then(Text::single_line)
+ .or_else(|| input["command"].as_str().and_then(Text::single_line))
+}
+
+pub(super) struct Binding {
+ pub service: Service,
+ pub configuration: Arc<maka_config::ConfigurationStore>,
+ pub input: Value,
+}
+impl RequestBinding for Binding {
+ fn bind(&self, _: BindingRequest) -> maka_tools::request::BindingFuture {
+ let service = self.service.clone();
+ let configuration = self.configuration.clone();
+ let input = self.input.clone();
+ Box::pin(async move {
+ let titles = configuration
+ .runtime_policy()
+ .await
+ .map_err(|error| ToolError::Failed(error.to_string()))?
+ .policy
+ .shell
+ .titles;
+ Ok(BTreeMap::from([(
+ service.selection.name.clone(),
+ BoundTool::Input {
+ handler: Arc::new(service),
+ schema: schema(input, titles),
+ },
+ )]))
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn command_labels_are_bounded_display_text_with_command_fallback() {
+ assert_eq!(title(&serde_json::json!({"command":"printf actual", "title":" 检查\n构建\u{202e}\u{0000} "})).unwrap().fallback, "检查 构建");
+ assert_eq!(
+ title(&serde_json::json!({"command":"printf actual", "title":" \n "}))
+ .unwrap()
+ .fallback,
+ "printf actual"
+ );
+ assert_eq!(
+ title(&serde_json::json!({"command":"printf actual"}))
+ .unwrap()
+ .fallback,
+ "printf actual"
+ );
+ assert_eq!(
+ title(&serde_json::json!({"command":"printf actual", "title":"界".repeat(100)}))
+ .unwrap()
+ .fallback,
+ "界".repeat(85)
+ );
+ }
+}
diff --git a/plugins/agent/tool-catalog/src/request.rs b/plugins/agent/tool-catalog/src/request.rs
index e108ab25ca..827f5170cd 100644
--- a/plugins/agent/tool-catalog/src/request.rs
+++ b/plugins/agent/tool-catalog/src/request.rs
@@ -40,6 +40,11 @@ pub enum BoundTool {
handler: Arc<dyn ToolPreparer>,
description: String,
},
+ /// A request-scoped model input projection; execution retains its registered binding.
+ Input {
+ handler: Arc<dyn ToolPreparer>,
+ schema: serde_json::Value,
+ },
Provider(ProviderTool),
}
pub type BindingFuture =
@@ -101,21 +106,39 @@ impl ToolCatalog {
let entries = Arc::make_mut(&mut self.entries);
let entry = entries.get(&name).expect("selected entry");
let mut registration = entry.registration.clone();
- let projection = if let BoundTool::Described {
- handler,
- description,
- } = projection
- {
- if description.trim().is_empty() || description.len() > 16 * 1024 {
- return Err(ToolError::Failed("request tool description invalid".into()));
+ let mut validator = entry.validator.clone();
+ let projection = match projection {
+ BoundTool::Described {
+ handler,
+ description,
+ } => {
+ if description.trim().is_empty() || description.len() > 16 * 1024 {
+ return Err(ToolError::Failed(
+ "request tool description invalid".into(),
+ ));
+ }
+ registration.definition.description = description;
+ BoundTool::Prepared(handler)
+ }
+ BoundTool::Input { handler, schema } => {
+ if schema != registration.definition.input_schema {
+ validator = Arc::new(
+ jsonschema::options()
+ .offline()
+ .should_validate_formats(false)
+ .build(&schema)
+ .map_err(|e| ToolError::Failed(e.to_string()))?,
+ );
+ registration.definition.input_schema = schema;
+ }
+ BoundTool::Prepared(handler)
}
- registration.definition.description = description;
- BoundTool::Prepared(handler)
- } else {
- projection
+ projection => projection,
};
match projection {
- BoundTool::Described { .. } => unreachable!("description already applied"),
+ BoundTool::Described { .. } | BoundTool::Input { .. } => {
+ unreachable!("projection already applied")
+ }
BoundTool::Unavailable => {
entries.remove(&name);
continue;
@@ -146,7 +169,7 @@ impl ToolCatalog {
Arc::new(RegisteredTool {
always_visible: entry.always_visible,
registration,
- validator: entry.validator.clone(),
+ validator,
bytes,
}),
);
diff --git a/plugins/settings/native/src/preferences.rs b/plugins/settings/native/src/preferences.rs
index ebb2f4db2b..828a3e175b 100644
--- a/plugins/settings/native/src/preferences.rs
+++ b/plugins/settings/native/src/preferences.rs
@@ -41,6 +41,7 @@ pub struct Preferences {
pub assistant_tone: String,
pub workspace_instructions: bool,
pub incognito_active: bool,
+ pub shell_titles: bool,
}
#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -71,6 +72,9 @@ pub enum Mutation {
Privacy {
incognito_active: bool,
},
+ ShellTitles {
+ enabled: bool,
+ },
}
#[derive(Serialize, JsonSchema)]
#[serde(
@@ -106,6 +110,7 @@ pub(crate) async fn read(
assistant_tone: snapshot.policy.personalization.assistant_tone,
workspace_instructions: snapshot.policy.workspace_instructions.enabled,
incognito_active: snapshot.policy.privacy.incognito_active,
+ shell_titles: snapshot.policy.shell.titles,
})
}
pub(crate) async fn update(
@@ -116,6 +121,11 @@ pub(crate) async fn update(
return super::connections::encode(Updated::Rejected);
};
let result = match input.mutation {
+ Mutation::ShellTitles { enabled } => {
+ store
+ .set_shell_titles(input.expected_revision, enabled)
+ .await
+ }
Mutation::Privacy { incognito_active } => {
store
.set_privacy(input.expected_revision, PrivacyPolicy { incognito_active })
diff --git a/plugins/settings/store/migrations/0002_shell_titles.sql b/plugins/settings/store/migrations/0002_shell_titles.sql
new file mode 100644
index 0000000000..15aea2ef9d
--- /dev/null
+++ b/plugins/settings/store/migrations/0002_shell_titles.sql
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+-- Upgrade the existing canonical policy in place; fresh Roots use runtime defaults.
+UPDATE runtime_policy
+SET document = json_set(document, '$.policy.shell.titles', json('true'))
+WHERE json_type(document, '$.policy.shell') = 'object'
+ AND json_type(document, '$.policy.shell.titles') IS NULL;
diff --git a/plugins/settings/store/src/policy.rs b/plugins/settings/store/src/policy.rs
index 635fba35a1..83deb190af 100644
--- a/plugins/settings/store/src/policy.rs
+++ b/plugins/settings/store/src/policy.rs
@@ -93,6 +93,17 @@ impl ConfigurationStore {
.await
}
+ pub async fn set_shell_titles(
+ &self,
+ expected_revision: u64,
+ enabled: bool,
+ ) -> Result<RuntimePolicyMutationResult> {
+ self.update_policy(expected_revision, move |policy| {
+ policy.shell.titles = enabled
+ })
+ .await
+ }
+
async fn update_policy(
&self,
expected_revision: u64,
diff --git a/plugins/settings/store/tests/runtime_policy.rs b/plugins/settings/store/tests/runtime_policy.rs
index ad1cfccff6..a828c87136 100644
--- a/plugins/settings/store/tests/runtime_policy.rs
+++ b/plugins/settings/store/tests/runtime_policy.rs
@@ -50,6 +50,46 @@ fn count(database: &rusqlite::Connection) -> i64 {
.unwrap()
}
+#[tokio::test]
+async fn shell_titles_migrate_existing_policy_and_persist_explicit_disable() {
+ let temp = tempfile::tempdir().unwrap();
+ let owner = root(temp.path());
+ let store = ConfigurationStore::for_root(owner.clone()).await.unwrap();
+ store
+ .set_privacy(
+ 0,
+ PrivacyPolicy {
+ incognito_active: true,
+ },
+ )
+ .await
+ .unwrap();
+ let expected = store.runtime_policy().await.unwrap();
+ store.close().await.unwrap();
+ let db = database(&owner);
+ db.execute_batch("UPDATE runtime_policy SET document = json_remove(document, '$.policy.shell.titles'); DELETE FROM _sqlx_migrations WHERE version = 2;").unwrap();
+ let store = ConfigurationStore::for_root(owner.clone()).await.unwrap();
+ assert_eq!(store.runtime_policy().await.unwrap(), expected);
+ assert_eq!(
+ store.set_shell_titles(1, false).await.unwrap(),
+ RuntimePolicyMutationResult::Committed { revision: 2 }
+ );
+ assert_eq!(
+ store.set_shell_titles(1, true).await.unwrap(),
+ RuntimePolicyMutationResult::RevisionConflict {
+ expected_revision: 1,
+ actual_revision: 2
+ }
+ );
+ store.close().await.unwrap();
+ let store = ConfigurationStore::for_root(owner).await.unwrap();
+ let snapshot = store.runtime_policy().await.unwrap();
+ assert_eq!(snapshot.revision, 2);
+ assert!(!snapshot.policy.shell.titles);
+ assert!(snapshot.policy.privacy.incognito_active);
+ store.close().await.unwrap();
+}
+
#[tokio::test]
async fn proxy_policy_and_password_commit_together_with_cas_noops_and_rollback() {
use maka_agent_facts::configuration::CredentialState;
diff --git a/plugins/shared/tui-messages.ts b/plugins/shared/tui-messages.ts
index d8bf50b02b..baa0d1779e 100644
--- a/plugins/shared/tui-messages.ts
+++ b/plugins/shared/tui-messages.ts
@@ -1492,6 +1492,17 @@ export const tui_settings_assistant: Message = ['Assistant preferences', '助手
export const tui_assistant_name: Message = ['Your name', '你的称呼', '你的稱呼'];
export const tui_assistant_tone: Message = ['Assistant tone', '助手语气', '助理語氣'];
export const tui_assistant_save: Message = ['Save personalization', '保存个性偏好', '儲存個人偏好'];
+export const tui_assistant_shell_titles: Message = [
+ 'Shell command titles',
+ 'Shell 命令标题',
+ 'Shell 命令標題',
+];
+export const tui_assistant_shell_titles_help: Message = [
+ 'Let the model add short command titles. Turning this off removes the title option from future tool calls; existing titles remain in history.',
+ '让模型为命令添加简短标题。关闭后不再向模型提供命令标题选项;历史标题保留。',
+ '讓模型為命令加入簡短標題。關閉後不再向模型提供命令標題選項;歷史標題保留。',
+];
+
export const tui_assistant_privacy: Message = ['Privacy mode', '隐私模式', '隱私模式'];
export const tui_assistant_privacy_help: Message = [
'Pauses Recall and web tools. Conversations are still saved and messages are still sent to your selected model.',
diff --git a/products/maka/bundle/src/composition.ts b/products/maka/bundle/src/composition.ts
index bac0d7fae9..9a486940e1 100644
--- a/products/maka/bundle/src/composition.ts
+++ b/products/maka/bundle/src/composition.ts
@@ -732,6 +732,8 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
tools: configuration.agent.tools.map((tool) => {
if (tool.import === 'web') return { ...tool, input: 'web' as const };
if (tool.import === 'skills') return { ...tool, input: 'skill' as const };
+ if (tool.import === 'processes' && tool.method === 'execute')
+ tool = { ...tool, input: 'shell' };
if (tool.presentation !== undefined) return tool;
const contract =
tool.import === 'files' &&
diff --git a/products/maka/bundle/tests/assistant-preferences.test.mjs b/products/maka/bundle/tests/assistant-preferences.test.mjs
index 8a2a3d4e4a..f3dcca6281 100644
--- a/products/maka/bundle/tests/assistant-preferences.test.mjs
+++ b/products/maka/bundle/tests/assistant-preferences.test.mjs
@@ -41,6 +41,7 @@ for (const target of ['tui', 'desktop'])
assistantTone: '',
workspaceInstructions: true,
incognitoActive: false,
+ shellTitles: true,
};
finish([]);
finish(saved);
@@ -86,3 +87,77 @@ for (const target of ['tui', 'desktop'])
'a policy toggle must retain the existing editor state',
);
});
+
+for (const target of ['tui', 'desktop'])
+ test(`Assistant ${target} shell titles toggle saves the observed preference revision`, async () => {
+ const { component } = await import(`../dist/packages/assistant-preferences/${target}.js`);
+ const props = { theme, locale: 'en', open: true, busy: false, shell: { back: 0 } };
+ let out = component.mount(props, {});
+ const finish = (value) => {
+ out = component.event(
+ out.frame.state,
+ { kind: 'action', key: out.actions[0].key, event: { status: 'succeeded', value } },
+ props,
+ {},
+ );
+ };
+ finish([]);
+ const saved = {
+ revision: 7,
+ displayName: '',
+ assistantTone: '',
+ workspaceInstructions: true,
+ incognitoActive: false,
+ shellTitles: true,
+ };
+ finish(saved);
+ const toggle = () => {
+ out = component.event(
+ out.frame.state,
+ { kind: 'raw', key: 'shell-titles', event: {} },
+ props,
+ {},
+ );
+ };
+ toggle();
+ assert.deepEqual(
+ out.actions.map(({ method, input }) => ({ method, input })),
+ [
+ {
+ method: 'updateAssistantPreferences',
+ input: { expectedRevision: 7, mutation: { kind: 'shell_titles', enabled: false } },
+ },
+ ],
+ );
+ assert.ok(valid(out.actions[0].input), JSON.stringify(valid.errors));
+ out = component.event(
+ out.frame.state,
+ {
+ kind: 'action',
+ key: out.actions[0].key,
+ event: {
+ status: 'operation',
+ reply: {
+ reference: {
+ contract: { name: 'maka.settings.Settings', version: 1 },
+ method: 'updateAssistantPreferences',
+ operation: 'shell-title-toggle',
+ },
+ receipt: {
+ kind: 'recorded',
+ cleanup: { kind: 'confirmed' },
+ outcome: { kind: 'returned', value: { kind: 'committed', revision: 8 } },
+ },
+ },
+ },
+ },
+ props,
+ {},
+ );
+ finish({ ...saved, revision: 8, shellTitles: false });
+ toggle();
+ assert.deepEqual(out.actions[0].input, {
+ expectedRevision: 8,
+ mutation: { kind: 'shell_titles', enabled: true },
+ });
+ });
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org