diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index bd635ef240..43d5d0b9e3 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5c26d781f664b1daa3cef9062f0ad3e776544242b80fbd1fc303d59d4494eed3", - "ownerDigest": "0b2d30952efd57108d9e566dc0bf5a176f2b3d753abfb736bfc32b2c1fa3f7b7", + "digest": "513e184cc2d93f183cdea258516f532caf58ac6400ef76dce9b54f6de0752627", + "ownerDigest": "3cc6d7d869b2c90c2161494a854b5ff7eb72ea69a1a28e40ccd42065fb3382e2", "searchAcceptance": [ { "id": "native-image-generation", @@ -16923,7 +16923,8 @@ "type": "string", "enum": [ "native", - "ptc" + "ptc", + "ptc_python" ] }, "outputSchema": { @@ -30162,8 +30163,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式", + "titleEn": "Choose native, PTC TypeScript, or PTC Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -30359,13 +30360,14 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript/Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript or Python.", "valueSchema": { "type": "string", "enum": [ "native", - "ptc" + "ptc", + "ptc_python" ] } }, @@ -30496,8 +30498,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", - "选择模型使用原生模式还是 PTC TypeScript 模式", - "Choose native mode or PTC TypeScript mode for the model", + "选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式", + "Choose native, PTC TypeScript, or PTC Python mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/docs/interactive-capabilities/capabilities/setting.tools.execution.md b/docs/interactive-capabilities/capabilities/setting.tools.execution.md index e8c2c03973..b83f1db61c 100644 --- a/docs/interactive-capabilities/capabilities/setting.tools.execution.md +++ b/docs/interactive-capabilities/capabilities/setting.tools.execution.md @@ -27,8 +27,8 @@ Manage agent tool permissions, presentation mode, concurrency, timeouts, deferre - Review, revoke, or clear remembered project permission grants and audit records - **智能体可直接控制 / Direct Agent control** · 按需延迟加载工具定义以减少智能体初始上下文 - Deferred tool loading on demand to reduce initial agent context -- **智能体可直接控制 / Direct Agent control** · 选择模型使用原生模式还是 PTC TypeScript 模式 - - Choose native mode or PTC TypeScript mode for the model +- **智能体可直接控制 / Direct Agent control** · 选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式 + - Choose native, PTC TypeScript, or PTC Python mode for the model - **智能体可直接控制 / Direct Agent control** · 设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略 - Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy - **智能体可直接控制 / Direct Agent control** · 设置或取消单次工具调用超时 @@ -69,7 +69,7 @@ Manage agent tool permissions, presentation mode, concurrency, timeouts, deferre | --- | --- | --- | --- | | 显示权限模式选择器 / Show permission-mode selector | `boolean` | 在聊天输入框下方显示或隐藏权限模式快捷选择器。 | Show or hide the permission-mode shortcut below the chat composer. | | 延迟加载工具 / Deferred tool loading | `boolean` | 按需加载大型工具定义,减少智能体初始上下文。 | Load large tool definitions on demand to reduce initial agent context. | -| 工具呈现模式 / Tool presentation mode | `native` / `ptc` | 选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。 | Choose whether the model calls native tools directly or uses PTC TypeScript. | +| 工具呈现模式 / Tool presentation mode | `native` / `ptc` / `ptc_python` | 选择模型直接调用原生工具,或通过 PTC TypeScript/Python 调用工具。 | Choose whether the model calls native tools directly or uses PTC TypeScript or Python. | | 子智能体批量执行策略 / Subagent batch policy | `safe_only` / `force_parallel` / `serial` | 选择仅安全任务并行、强制并行或串行执行。 | Choose safe-only parallelism, forced parallelism, or serial execution. | | 子智能体最大并发 / Maximum subagent concurrency | `integer` (1–32) | 限制同时运行的子智能体数量。 | Limit the number of subagents that may run at once. | | Swarm 最大并发 / Maximum swarm concurrency | `integer` (1–64) | 限制 Swarm 中同时执行的工作单元。 | Limit concurrent work units inside a swarm. | diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 399739a46d..e552bc1c6a 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "5c26d781f664b1daa3cef9062f0ad3e776544242b80fbd1fc303d59d4494eed3", + "catalogDigest": "513e184cc2d93f183cdea258516f532caf58ac6400ef76dce9b54f6de0752627", "count": 204, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 0f2aeb5cf9..d5916091ca 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "5c26d781f664b1daa3cef9062f0ad3e776544242b80fbd1fc303d59d4494eed3", + "catalogDigest": "513e184cc2d93f183cdea258516f532caf58ac6400ef76dce9b54f6de0752627", "commandCount": 709, "coverage": { "commandCount": 709, diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 843f3dcdbd..2866ffcdee 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -291,7 +291,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'openbitfun-agent-workflows', ownerFeatures: ['deep-research'] }, { depName: 'openbitfun-core-types', - ownerFeatures: ['deep-research', 'remote-connect', 'remote-ssh-concrete', 'speech'], + ownerFeatures: ['deep-research', 'ptc-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'], }, { depName: 'openbitfun-product-domains', ownerFeatures: ['account-identity', 'canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'ptc-runtime', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index dea1101aed..5c7f5a24f1 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -103,7 +103,9 @@ use openbitfun_agent_runtime::remote_file_delivery::{ }; use openbitfun_agent_runtime::sdk::PermissionReply; use openbitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; -use openbitfun_agent_tools::{PTC_TOOL_PRESENTATION_CONTEXT_KEY, PTC_TOOL_PRESENTATION_MODE}; +use openbitfun_agent_tools::{ + PTC_LANGUAGE_CONTEXT_KEY, PTC_TOOL_PRESENTATION_CONTEXT_KEY, PTC_TOOL_PRESENTATION_MODE, +}; use openbitfun_config_contracts::model_pool::AgentModelSelection; use openbitfun_config_contracts::model_pool::AgentModelSelections; use openbitfun_core_types::model_pool::ModelSelection; @@ -7339,6 +7341,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), PTC_TOOL_PRESENTATION_MODE.to_string(), ); + context_vars.insert( + PTC_LANGUAGE_CONTEXT_KEY.to_string(), + session + .config + .tool_presentation_mode + .language() + .as_str() + .to_string(), + ); } let review_agent = is_review_agent_type(&effective_agent_type); let turn_review_manifest = @@ -7951,6 +7962,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), PTC_TOOL_PRESENTATION_MODE.to_string(), ); + context_vars.insert( + PTC_LANGUAGE_CONTEXT_KEY.to_string(), + session + .config + .tool_presentation_mode + .language() + .as_str() + .to_string(), + ); } if let Some(user_input_available) = metadata_bool( plan.user_message_metadata.as_ref(), diff --git a/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs b/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs index ed45252aca..d6ec3534db 100644 --- a/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs +++ b/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs @@ -785,6 +785,25 @@ mod tests { .expect("tool presentation mode is config-backed"); assert_eq!(applied.changed_path, "ai.tool_presentation_mode"); assert_eq!(applied.effective_value, Value::String("ptc".to_string())); + + let applied_python = configure_config_backed_option( + &service, + option, + &Value::String("ptc_python".to_string()), + ) + .await + .expect("PTC Python should be accepted") + .expect("tool presentation mode is config-backed"); + assert_eq!( + applied_python.effective_value, + Value::String("ptc_python".to_string()) + ); + assert_eq!( + read_config_backed_option(&service, option) + .await + .expect("PTC Python should be readable"), + Some(Value::String("ptc_python".to_string())) + ); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 15d2b6c616..d263fc9746 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -32,13 +32,15 @@ use openbitfun_agent_tools::{ build_tool_execution_timeout_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, build_write_tail_closure_notice, - render_ptc_loaded_tool_typescript_sdk, render_tool_result_for_assistant, + render_ptc_loaded_tool_sdk, render_tool_result_for_assistant, validate_tool_execution_admission, DynamicToolInfo, LoadedDeferredToolSpec, PermissionIntent, PtcToolContract, ResolvedToolInvocation, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, ToolExecutionErrorPresentation, CALL_DEFERRED_TOOL_NAME, - GET_TOOL_SPEC_TOOL_NAME, PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY, + GET_TOOL_SPEC_TOOL_NAME, PTC_LANGUAGE_CONTEXT_KEY, PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY, USER_STEERING_INTERRUPTED_MESSAGE, }; +#[cfg(feature = "ptc-runtime")] +use openbitfun_core_types::PtcLanguage; use openbitfun_runtime_ports::{ PermissionReply, PermissionRequest, PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, RoundInjectionToolPreemption, @@ -50,6 +52,8 @@ use openbitfun_runtime_ports::{ }; #[cfg(feature = "ptc-runtime")] use openbitfun_services_integrations::ptc_runtime::NodePtcRuntime; +#[cfg(feature = "ptc-runtime")] +use openbitfun_services_integrations::python_ptc_runtime::PythonPtcRuntime; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Instant, SystemTime}; @@ -183,9 +187,13 @@ fn convert_tool_result_with_metadata( // "completed successfully" can hide fields the model needs for the // next decision. let assistant_text = result_for_assistant.or_else(|| { + let mut assistant_visible_data = assistant_data.clone(); + if let Some(object) = assistant_visible_data.as_object_mut() { + object.remove("__ptc_language"); + } Some(render_tool_result_for_assistant( effective_tool_name, - &assistant_data, + &assistant_visible_data, )) }); // Tools with a typed response envelope can report a recoverable @@ -754,6 +762,14 @@ struct PtcPipelineToolCallHandler { loaded_deferred_tool_specs: Arc>>, } +#[cfg(feature = "ptc-runtime")] +fn build_ptc_tool_spec_response(language: PtcLanguage, sdk_source: String) -> serde_json::Value { + serde_json::json!({ + "language": language.as_str(), + "sdk_source": sdk_source, + }) +} + #[cfg(feature = "ptc-runtime")] #[async_trait::async_trait] impl PtcToolCallHandler for PtcPipelineToolCallHandler { @@ -886,12 +902,20 @@ impl PtcToolCallHandler for PtcPipelineToolCallHandler { .cloned() .unwrap_or_else(|| serde_json::json!({})); let output_schema = tool.output_schema(); - let typescript_sdk = render_ptc_loaded_tool_typescript_sdk(&PtcToolContract::new( - tool_name.clone(), - description, - input_schema, - output_schema, - )); + let language = if self + .context + .context_vars + .get(PTC_LANGUAGE_CONTEXT_KEY) + .is_some_and(|language| language.eq_ignore_ascii_case("python")) + { + PtcLanguage::Python + } else { + PtcLanguage::TypeScript + }; + let sdk_source = render_ptc_loaded_tool_sdk( + &PtcToolContract::new(tool_name.clone(), description, input_schema, output_schema), + language, + ); let mut loaded = self.loaded_deferred_tool_specs.lock().await; if let Some(existing) = loaded.iter_mut().find(|spec| spec.tool_name == tool_name) { @@ -902,9 +926,7 @@ impl PtcToolCallHandler for PtcPipelineToolCallHandler { catalog_generation, }); } - Ok(serde_json::json!({ - "typescript_sdk": typescript_sdk, - })) + Ok(build_ptc_tool_spec_response(language, sdk_source)) } } @@ -2974,7 +2996,12 @@ impl ToolPipeline { .effective_arguments() .get("timeout_ms") .and_then(serde_json::Value::as_u64); - let runtime = if context.is_remote() { + let use_python = context + .custom_data + .get(PTC_LANGUAGE_CONTEXT_KEY) + .and_then(serde_json::Value::as_str) + .is_some_and(|language| language.eq_ignore_ascii_case("python")); + let runtime: Box = if context.is_remote() { #[cfg(feature = "ssh-remote")] { let workspace = context.workspace.as_ref().ok_or_else(|| { @@ -3002,7 +3029,11 @@ impl ToolPipeline { "Remote SSH services are not initialized".to_string(), ) })?; - NodePtcRuntime::for_remote(ssh_manager, connection_id) + if use_python { + Box::new(PythonPtcRuntime::for_remote(ssh_manager, connection_id)) + } else { + Box::new(NodePtcRuntime::for_remote(ssh_manager, connection_id)) + } } #[cfg(not(feature = "ssh-remote"))] { @@ -3011,7 +3042,11 @@ impl ToolPipeline { )); } } else { - NodePtcRuntime::discover() + if use_python { + Box::new(PythonPtcRuntime::discover()) + } else { + Box::new(NodePtcRuntime::discover()) + } }; let operation_id = task.tool_call.tool_id.clone(); let handler = Arc::new(PtcPipelineToolCallHandler { @@ -3055,6 +3090,34 @@ impl ToolPipeline { "logs": response.logs, }) }; + // Persist the source language alongside the result so replayed RunCode + // cards can select the correct syntax highlighter even though the + // provider's original arguments only contain `code`. + let ptc_language = context + .custom_data + .get(PTC_LANGUAGE_CONTEXT_KEY) + .map(|value| { + if value + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case("python")) + { + PtcLanguage::Python + } else { + PtcLanguage::TypeScript + } + }) + .unwrap_or_default(); + if let Some(object) = data.as_object_mut() { + object.insert( + "__ptc_language".to_string(), + serde_json::Value::String(ptc_language.as_str().to_string()), + ); + } else { + data = serde_json::json!({ + "value": data, + "__ptc_language": ptc_language.as_str(), + }); + } let loaded_deferred_tool_specs = handler.loaded_deferred_tool_specs.lock().await.clone(); if !loaded_deferred_tool_specs.is_empty() { let loaded_specs_value = serde_json::to_value(&loaded_deferred_tool_specs) @@ -4359,6 +4422,23 @@ mod tests { })); } + #[cfg(feature = "ptc-runtime")] + #[test] + fn ptc_tool_spec_response_uses_one_language_neutral_sdk_field() { + for language in [PtcLanguage::TypeScript, PtcLanguage::Python] { + let response = build_ptc_tool_spec_response(language, "sdk".to_string()); + assert_eq!( + response, + json!({ + "language": language.as_str(), + "sdk_source": "sdk", + }) + ); + assert!(response.get("typescript_sdk").is_none()); + assert!(response.get("python_sdk").is_none()); + } + } + #[cfg(feature = "ptc-runtime")] #[tokio::test] async fn ptc_run_code_dispatches_nested_calls_through_the_pipeline() { @@ -4396,7 +4476,11 @@ mod tests { results[0].tool_name, openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME ); - assert_eq!(results[0].result.result, json!({"content": "ok"})); + assert_eq!(results[0].result.result["content"], json!("ok")); + assert_eq!( + results[0].result.result["__ptc_language"], + json!("typescript") + ); let nested = pipeline .state_manager diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index f77b4047f4..0a738c6849 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -8,9 +8,9 @@ use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use crate::util::types::ToolDefinition; #[cfg(feature = "ptc-runtime")] use openbitfun_agent_tools::{ - ptc_run_code_description, ptc_run_code_input_schema, - render_ptc_tool_calling_guide_with_options, render_ptc_typescript_sdk_with_options, - PtcToolContract, PTC_RUN_CODE_TOOL_NAME, PTC_TOOL_PRESENTATION_CONTEXT_KEY, + ptc_run_code_description_for_language, ptc_run_code_input_schema_for_language, + render_ptc_sdk_with_options, render_ptc_tool_calling_guide_for_language, PtcToolContract, + PTC_LANGUAGE_CONTEXT_KEY, PTC_RUN_CODE_TOOL_NAME, PTC_TOOL_PRESENTATION_CONTEXT_KEY, PTC_TOOL_PRESENTATION_MODE, }; use openbitfun_agent_tools::{ @@ -20,6 +20,8 @@ use openbitfun_agent_tools::{ ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolManifestDefinition, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; +#[cfg(feature = "ptc-runtime")] +use openbitfun_core_types::PtcLanguage; use serde::Serialize; #[cfg(feature = "ptc-runtime")] use serde_json::json; @@ -359,10 +361,11 @@ pub(crate) async fn resolve_product_tool_manifest( // PTC keeps deferred tools out of the initial full schema. Their // generated SDK is attached to the prompt reminder by the resolved // manifest facade below. + let language = ptc_language(context); manifest.tool_definitions = vec![ToolManifestDefinition::new( PTC_RUN_CODE_TOOL_NAME, - ptc_run_code_description(), - ptc_run_code_input_schema(), + ptc_run_code_description_for_language(language), + ptc_run_code_input_schema_for_language(language), )]; if !manifest .allowed_tool_names @@ -391,6 +394,22 @@ fn context_is_ptc_mode(context: &ToolUseContext) -> bool { .is_some_and(|agent| agent.eq_ignore_ascii_case("PTC")) } +#[cfg(feature = "ptc-runtime")] +fn ptc_language(context: &ToolUseContext) -> PtcLanguage { + context + .custom_data + .get(PTC_LANGUAGE_CONTEXT_KEY) + .and_then(Value::as_str) + .map(|language| { + if language.eq_ignore_ascii_case("python") { + PtcLanguage::Python + } else { + PtcLanguage::TypeScript + } + }) + .unwrap_or_default() +} + #[cfg(feature = "ptc-runtime")] async fn build_ptc_contracts( tools: &[ToolRef], @@ -558,10 +577,12 @@ pub(crate) async fn resolve_product_resolved_tool_manifest( context, ) .await; + let language = ptc_language(context); let sdk_source = - render_ptc_typescript_sdk_with_options(&contracts, include_deferred_tool_apis); - Some(render_ptc_tool_calling_guide_with_options( + render_ptc_sdk_with_options(&contracts, language, include_deferred_tool_apis); + Some(render_ptc_tool_calling_guide_for_language( &sdk_source, + language, include_deferred_tool_apis, )) } else { @@ -615,12 +636,12 @@ mod tests { use crate::agentic::tools::ToolRuntimeRestrictions; #[cfg(feature = "external-sources")] use crate::agentic::WorkspaceBinding; - #[cfg(feature = "ptc-runtime")] - use openbitfun_agent_tools::PTC_TOOL_PRESENTATION_CONTEXT_KEY; use openbitfun_agent_tools::{ GetToolSpecCatalogProvider, ToolCatalogSnapshotProvider, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, PTC_RUN_CODE_TOOL_NAME, }; + #[cfg(feature = "ptc-runtime")] + use openbitfun_agent_tools::{PTC_LANGUAGE_CONTEXT_KEY, PTC_TOOL_PRESENTATION_CONTEXT_KEY}; use serde_json::{json, Value}; use std::collections::HashMap; #[cfg(feature = "external-sources")] @@ -815,6 +836,47 @@ mod tests { assert!(guide.contains("SessionHistory")); } + #[cfg(feature = "ptc-runtime")] + #[tokio::test] + async fn resolved_python_ptc_manifest_uses_python_schema_and_guide() { + let mut context = tool_context(Some("Standard")); + context.custom_data.insert( + PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), + Value::String("ptc".to_string()), + ); + context.custom_data.insert( + PTC_LANGUAGE_CONTEXT_KEY.to_string(), + Value::String("python".to_string()), + ); + let manifest = resolve_product_resolved_tool_manifest( + &["Read".to_string(), "SessionHistory".to_string()], + &AgentToolPolicyOverrides::default(), + &context, + ) + .await; + let run_code = manifest + .tool_definitions + .iter() + .find(|definition| definition.name == PTC_RUN_CODE_TOOL_NAME) + .expect("Python PTC manifest should expose RunCode"); + assert!(run_code.description.contains("Python program")); + assert!(run_code + .description + .contains("Do not emit TypeScript/JavaScript")); + assert_eq!(run_code.parameters["properties"]["code"]["type"], "string"); + assert_eq!( + run_code.parameters["properties"]["code"]["description"], + "Python source code executed inside an existing async function. Use top-level `await`; do not call `asyncio.run`." + ); + let guide = manifest + .ptc_tool_calling_guide + .as_deref() + .expect("Python PTC manifest should carry the SDK guide"); + assert!(guide.contains("Python program")); + assert!(guide.contains("tools.get_tool_spec")); + assert!(guide.contains("SessionHistory")); + } + #[cfg(feature = "ptc-runtime")] #[tokio::test] async fn ptc_sdk_uses_only_deferred_marker_for_mcp_tools() { diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 3abee0949c..d4b4839477 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -49,7 +49,7 @@ use openbitfun_agent_runtime::user_questions::{ }; use openbitfun_agent_tools::{ LoadedDeferredToolSpec, PortableToolContextProvider, ToolContextFacts, ToolWorkspaceKind, - PTC_TOOL_PRESENTATION_CONTEXT_KEY, + PTC_LANGUAGE_CONTEXT_KEY, PTC_TOOL_PRESENTATION_CONTEXT_KEY, }; #[cfg(feature = "canvas-runtime")] use openbitfun_product_domains::canvas::CanvasStoragePort; @@ -459,6 +459,12 @@ fn build_tool_context_custom_data(context: &ToolExecutionContext) -> HashMap Deserialize<'de> for ToolPresentationMode { let value = String::deserialize(deserializer)?; Ok(match value.as_str() { "ptc" => Self::Ptc, + "ptc_python" => Self::PtcPython, // Unknown values come from a newer build. Keep the session/config // readable and fail closed to the legacy native presentation. _ => Self::Native, @@ -39,13 +42,65 @@ impl ToolPresentationMode { } pub const fn is_ptc(&self) -> bool { - matches!(self, Self::Ptc) + matches!(self, Self::Ptc | Self::PtcPython) + } + + pub const fn is_ptc_python(&self) -> bool { + matches!(self, Self::PtcPython) + } + + pub const fn language(&self) -> PtcLanguage { + if self.is_ptc_python() { + PtcLanguage::Python + } else { + PtcLanguage::TypeScript + } + } +} + +/// Source language used by a PTC program. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PtcLanguage { + TypeScript, + Python, +} + +impl Default for PtcLanguage { + fn default() -> Self { + Self::TypeScript + } +} + +impl<'de> Deserialize<'de> for PtcLanguage { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "python" => Self::Python, + _ => Self::TypeScript, + }) + } +} + +impl PtcLanguage { + pub const fn as_str(self) -> &'static str { + match self { + Self::TypeScript => "typescript", + Self::Python => "python", + } + } + + pub const fn is_typescript(self) -> bool { + matches!(self, Self::TypeScript) } } #[cfg(test)] mod tests { - use super::ToolPresentationMode; + use super::{PtcLanguage, ToolPresentationMode}; #[test] fn tool_presentation_mode_uses_stable_snake_case_values() { @@ -65,10 +120,25 @@ mod tests { serde_json::from_value::(serde_json::json!("ptc")).unwrap(), ToolPresentationMode::Ptc ); + assert_eq!( + serde_json::from_value::(serde_json::json!("ptc_python")) + .unwrap(), + ToolPresentationMode::PtcPython + ); assert_eq!( serde_json::from_value::(serde_json::json!("future_mode")) .unwrap(), ToolPresentationMode::Native ); } + + #[test] + fn ptc_language_defaults_to_typescript_and_preserves_unknown_values() { + assert_eq!(PtcLanguage::default(), PtcLanguage::TypeScript); + assert_eq!(serde_json::to_value(PtcLanguage::Python).unwrap(), "python"); + assert_eq!( + serde_json::from_value::(serde_json::json!("future")).unwrap(), + PtcLanguage::TypeScript + ); + } } diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 42516e8ab6..9a392b9ef5 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5c26d781f664b1daa3cef9062f0ad3e776544242b80fbd1fc303d59d4494eed3", - "ownerDigest": "0b2d30952efd57108d9e566dc0bf5a176f2b3d753abfb736bfc32b2c1fa3f7b7", + "digest": "513e184cc2d93f183cdea258516f532caf58ac6400ef76dce9b54f6de0752627", + "ownerDigest": "3cc6d7d869b2c90c2161494a854b5ff7eb72ea69a1a28e40ccd42065fb3382e2", "searchAcceptance": [ { "id": "native-image-generation", @@ -16923,7 +16923,8 @@ "type": "string", "enum": [ "native", - "ptc" + "ptc", + "ptc_python" ] }, "outputSchema": { @@ -30569,8 +30570,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式", + "titleEn": "Choose native, PTC TypeScript, or PTC Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -30773,13 +30774,14 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript/Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript or Python.", "valueSchema": { "type": "string", "enum": [ "native", - "ptc" + "ptc", + "ptc_python" ] }, "handler": { @@ -30942,8 +30944,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", - "选择模型使用原生模式还是 PTC TypeScript 模式", - "Choose native mode or PTC TypeScript mode for the model", + "选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式", + "Choose native, PTC TypeScript, or PTC Python mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs index 4f592a3dca..d012b7d59c 100644 --- a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs +++ b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs @@ -943,7 +943,7 @@ pub fn owner_definitions() -> Vec { option( "setting.tools.execution", "tool-presentation-mode", - string_enum(&["native", "ptc"]), + string_enum(&["native", "ptc", "ptc_python"]), config("ai.tool_presentation_mode"), ), option( diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index 3ccd092d94..8bc059cb88 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -113,11 +113,13 @@ pub use openbitfun_runtime_ports::{ }; pub use permission_intent::PermissionIntent; pub use ptc::{ - json_schema_to_typescript, ptc_run_code_description, ptc_run_code_input_schema, - render_ptc_loaded_tool_typescript_sdk, render_ptc_tool_calling_guide, + json_schema_to_typescript, ptc_run_code_description, ptc_run_code_description_for_language, + ptc_run_code_input_schema, ptc_run_code_input_schema_for_language, render_ptc_loaded_tool_sdk, + render_ptc_loaded_tool_typescript_sdk, render_ptc_sdk, render_ptc_sdk_with_options, + render_ptc_tool_calling_guide, render_ptc_tool_calling_guide_for_language, render_ptc_tool_calling_guide_with_options, render_ptc_typescript_sdk, render_ptc_typescript_sdk_with_options, PtcToolCallError, PtcToolContract, - PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY, PTC_RUN_CODE_TOOL_NAME, + PTC_LANGUAGE_CONTEXT_KEY, PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY, PTC_RUN_CODE_TOOL_NAME, PTC_TOOL_PRESENTATION_CONTEXT_KEY, PTC_TOOL_PRESENTATION_MODE, }; pub use tool_execution_presentation::{ diff --git a/src/crates/execution/tool-contracts/src/ptc.rs b/src/crates/execution/tool-contracts/src/ptc.rs index 3154c182bb..df6eb2c432 100644 --- a/src/crates/execution/tool-contracts/src/ptc.rs +++ b/src/crates/execution/tool-contracts/src/ptc.rs @@ -2,13 +2,16 @@ //! //! This module deliberately contains no process, model-provider, or product //! runtime code. It describes the stable tool snapshot that can be projected -//! into a TypeScript program before the program is executed by a host runtime. +//! into the selected PTC source language before the program is executed by a +//! host runtime. +use openbitfun_core_types::PtcLanguage; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; pub const PTC_TOOL_PRESENTATION_CONTEXT_KEY: &str = "tool_presentation_mode"; pub const PTC_TOOL_PRESENTATION_MODE: &str = "ptc"; +pub const PTC_LANGUAGE_CONTEXT_KEY: &str = "ptc_language"; /// Serialized per-turn allowlist used by the PTC host for nested calls. /// /// The model-facing manifest intentionally contains only `RunCode`, while @@ -86,6 +89,31 @@ pub fn render_ptc_typescript_sdk(contracts: &[PtcToolContract]) -> String { render_ptc_typescript_sdk_with_options(contracts, true) } +/// Render the SDK for the selected PTC source language. +pub fn render_ptc_sdk(contracts: &[PtcToolContract], language: PtcLanguage) -> String { + render_ptc_sdk_with_options(contracts, language, true) +} + +/// Render the SDK while controlling whether deferred loading APIs are exposed. +/// When deferred loading is disabled, the model should not be told about APIs +/// that the host will reject. +pub fn render_ptc_sdk_with_options( + contracts: &[PtcToolContract], + language: PtcLanguage, + include_deferred_tool_apis: bool, +) -> String { + match language { + PtcLanguage::TypeScript => { + render_ptc_typescript_sdk_with_options(contracts, include_deferred_tool_apis) + } + PtcLanguage::Python => render_ptc_python_sdk( + contracts, + include_deferred_tool_apis, + include_deferred_tool_apis, + ), + } +} + /// Render the generated PTC SDK, optionally exposing the APIs used to load and /// invoke deferred tools by name. pub fn render_ptc_typescript_sdk_with_options( @@ -119,6 +147,334 @@ pub fn render_ptc_loaded_tool_typescript_sdk(contract: &PtcToolContract) -> Stri render_ptc_typescript_sdk_internal(&[contract], false, false, true) } +/// Render a loaded-tool SDK in the selected language. +pub fn render_ptc_loaded_tool_sdk(contract: &PtcToolContract, language: PtcLanguage) -> String { + match language { + PtcLanguage::TypeScript => render_ptc_loaded_tool_typescript_sdk(contract), + PtcLanguage::Python => render_ptc_python_sdk(std::slice::from_ref(contract), false, true), + } +} + +fn render_ptc_python_sdk( + contracts: &[PtcToolContract], + include_deferred_apis: bool, + include_call_tool_api: bool, +) -> String { + let mut output = String::from( + "from typing import Any, Literal, NotRequired, Protocol, Required, TypedDict, Union\n\n", + ); + if include_deferred_apis { + output.push_str( + "PtcToolSpec = TypedDict(\n \"PtcToolSpec\",\n {\n \"language\": Required[str],\n \"sdk_source\": Required[str],\n },\n)\n\n", + ); + } + let mut methods = Vec::new(); + for contract in contracts { + if contract.is_deferred { + continue; + } + if !is_python_identifier(&contract.name) { + continue; + } + let args_alias = python_alias_name(&contract.name, "Args"); + let output_alias = python_alias_name(&contract.name, "Output"); + render_python_schema_alias(&mut output, &args_alias, &contract.input_schema); + render_python_schema_alias(&mut output, &output_alias, &contract.output_schema); + methods.push((contract, args_alias, output_alias)); + } + + output.push_str("\nclass PtcToolBindings(Protocol):\n"); + for contract in contracts { + if contract.is_deferred { + output.push_str(" # Deferred tool: "); + output.push_str(&contract.name); + output.push_str( + ". Load its SDK with tools.get_tool_spec before calling tools.call_tool.\n", + ); + continue; + } + let Some((_, args_alias, output_alias)) = methods + .iter() + .find(|(method, _, _)| method.name == contract.name) + else { + continue; + }; + if !contract.description.trim().is_empty() { + for line in contract.description.lines() { + output.push_str(" # "); + output.push_str(line.trim()); + output.push('\n'); + } + } + output.push_str(" async def "); + output.push_str(&contract.name); + output.push_str("(self, args: "); + output.push_str(args_alias); + output.push_str(") -> "); + output.push_str(output_alias); + output.push_str(": ...\n"); + } + if methods.is_empty() { + output.push_str(" pass\n"); + } + if include_deferred_apis { + output.push_str("\n async def get_tool_spec(self, name: str) -> PtcToolSpec: ...\n"); + } + if include_call_tool_api { + output.push_str( + " async def call_tool(self, name: str, args: dict[str, Any]) -> Any: ...\n", + ); + } + if include_deferred_apis { + output.push_str( + "\n# Deferred tools: await tools.get_tool_spec(\"name\"), then await tools.call_tool(\"name\", args).\n", + ); + } + output.push_str("\ntools: PtcToolBindings\n"); + output +} + +fn python_alias_name(tool_name: &str, suffix: &str) -> String { + let mut name = tool_name + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' { + character + } else { + '_' + } + }) + .collect::(); + if name.is_empty() { + name.push_str("Tool"); + } + if name + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit()) + { + name.insert(0, '_'); + } + name.push_str(suffix); + name +} + +fn render_python_schema_alias(output: &mut String, alias: &str, schema: &Value) { + let mut aliases = Vec::new(); + let type_name = python_type_with_aliases(schema, alias, &mut aliases); + for definition in aliases { + output.push_str(&definition); + } + if type_name != alias { + output.push_str(alias); + output.push_str(" = "); + output.push_str(&type_name); + output.push_str("\n\n"); + } +} + +fn python_type_with_aliases(schema: &Value, alias: &str, aliases: &mut Vec) -> String { + let Some(object) = schema.as_object() else { + return "Any".to_string(); + }; + if let Some(constant) = object.get("const") { + return format!("Literal[{}]", python_literal(constant)); + } + if let Some(values) = object.get("enum").and_then(Value::as_array) { + if values.is_empty() { + return "Any".to_string(); + } + return format!( + "Literal[{}]", + values + .iter() + .map(python_literal) + .collect::>() + .join(", ") + ); + } + for key in ["oneOf", "anyOf"] { + if let Some(variants) = object.get(key).and_then(Value::as_array) { + if variants.is_empty() { + return "Any".to_string(); + } + return format!( + "Union[{}]", + variants + .iter() + .enumerate() + .map(|(index, variant)| { + python_type_with_aliases( + variant, + &format!("{alias}Variant{}", index + 1), + aliases, + ) + }) + .collect::>() + .join(", ") + ); + } + } + if let Some(types) = object.get("type").and_then(Value::as_array) { + return format!( + "Union[{}]", + types + .iter() + .filter_map(Value::as_str) + .enumerate() + .map(|(index, type_name)| { + python_type_with_aliases( + &serde_json::json!({"type": type_name}), + &format!("{alias}Variant{}", index + 1), + aliases, + ) + }) + .collect::>() + .join(", ") + ); + } + if object.contains_key("properties") || object.contains_key("additionalProperties") { + return python_object_type(object, alias, aliases); + } + match object.get("type").and_then(Value::as_str) { + Some("null") => "None".to_string(), + Some("boolean") => "bool".to_string(), + Some("integer") => "int".to_string(), + Some("number") => "float".to_string(), + Some("string") => "str".to_string(), + Some("array") => format!( + "list[{}]", + object + .get("items") + .map(|item| python_type_with_aliases(item, &format!("{alias}Item"), aliases)) + .unwrap_or_else(|| "Any".to_string()) + ), + Some("object") => python_object_type(object, alias, aliases), + _ => "Any".to_string(), + } +} + +fn python_object_type( + object: &Map, + alias: &str, + aliases: &mut Vec, +) -> String { + let Some(properties) = object.get("properties").and_then(Value::as_object) else { + return python_additional_properties_type(object, alias, aliases); + }; + if properties.is_empty() { + return python_additional_properties_type(object, alias, aliases); + } + + let required = object + .get("required") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + let mut definition = String::new(); + definition.push_str(alias); + definition.push_str(" = TypedDict(\n \""); + definition.push_str(alias); + definition.push_str("\",\n {\n"); + let mut fields = Vec::new(); + for (name, property) in properties { + let child_alias = format!("{}{}", alias, python_alias_component(name)); + let property_type = python_type_with_aliases(property, &child_alias, aliases); + fields.push(( + name, + property_type, + required.contains(name.as_str()), + property, + )); + } + for (name, property_type, is_required, property) in fields { + if let Some(description) = property.get("description").and_then(Value::as_str) { + for line in description.lines() { + definition.push_str(" # "); + definition.push_str(line.trim()); + definition.push('\n'); + } + } + definition.push_str(" "); + definition.push_str(&python_string_literal(name)); + definition.push_str(": "); + definition.push_str(if is_required { + "Required[" + } else { + "NotRequired[" + }); + definition.push_str(&property_type); + definition.push_str("],\n"); + } + definition.push_str(" },\n)\n\n"); + aliases.push(definition); + alias.to_string() +} + +fn python_additional_properties_type( + object: &Map, + alias: &str, + aliases: &mut Vec, +) -> String { + match object.get("additionalProperties") { + Some(Value::Bool(false)) => "dict[str, Any]".to_string(), + Some(Value::Bool(true)) | None => "dict[str, Any]".to_string(), + Some(schema) => format!( + "dict[str, {}]", + python_type_with_aliases(schema, &format!("{alias}Value"), aliases) + ), + } +} + +fn python_alias_component(value: &str) -> String { + let mut component = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' { + character + } else { + '_' + } + }) + .collect::(); + if component.is_empty() { + component.push_str("Field"); + } + if component + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit()) + { + component.insert(0, '_'); + } + component[0..1].to_ascii_uppercase() + &component[1..] +} + +fn python_string_literal(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) +} + +fn python_literal(value: &Value) -> String { + match value { + Value::Null => "None".to_string(), + Value::Bool(value) => { + if *value { + "True".to_string() + } else { + "False".to_string() + } + } + Value::Number(value) => value.to_string(), + Value::String(value) => python_string_literal(value), + Value::Array(_) | Value::Object(_) => "Any".to_string(), + } +} + fn render_ptc_typescript_sdk_internal( contracts: &[PtcToolContract], include_deferred_tool_apis: bool, @@ -187,7 +543,9 @@ fn render_ptc_typescript_sdk_internal( if include_deferred_tool_apis { output .push_str(" /** Load a deferred tool definition into the current PTC operation. */\n"); - output.push_str(" getToolSpec(name: string): Promise<{ typescript_sdk: string }>;\n"); + output.push_str( + " getToolSpec(name: string): Promise<{ language: string; sdk_source: string }>;\n", + ); } if include_call_tool_api { output @@ -221,17 +579,66 @@ pub fn ptc_run_code_input_schema() -> Value { }) } +pub fn ptc_run_code_input_schema_for_language(language: PtcLanguage) -> Value { + let mut schema = ptc_run_code_input_schema(); + let description = match language { + PtcLanguage::TypeScript => { + "TypeScript program. Call tools through the generated tools binding and return a JSON value." + } + PtcLanguage::Python => { + "Python source code executed inside an existing async function. Use top-level `await`; do not call `asyncio.run`." + } + }; + if let Some(code) = schema + .get_mut("properties") + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut("code")) + { + code["description"] = Value::String(description.to_string()); + } + schema +} + pub fn ptc_run_code_description() -> String { "Execute a TypeScript program that can call available tools through the provided `tools` binding and return a compact JSON value." .to_string() } +pub fn ptc_run_code_description_for_language(language: PtcLanguage) -> String { + match language { + PtcLanguage::TypeScript => ptc_run_code_description(), + PtcLanguage::Python => { + "Execute a Python program that can call available tools through the provided `tools` binding and return a compact JSON value. The program runs inside an existing async event loop, so use top-level `await` and do not call `asyncio.run`. Use Python syntax only. Do not emit TypeScript/JavaScript syntax such as `const`, `let`, `Promise`, or `=>`. The runtime has already imported and bound `asyncio` and `json` in the execution scope, so use them directly. Do not import other modules, including `os`, `pathlib`, `subprocess`, `requests`, and similar modules.".to_string() + } + } +} + /// Render the PTC SDK as a conversation-start reminder instead of embedding /// the large declaration block in the single `RunCode` tool description. pub fn render_ptc_tool_calling_guide(sdk_source: &str) -> String { render_ptc_tool_calling_guide_with_options(sdk_source, true) } +pub fn render_ptc_tool_calling_guide_for_language( + sdk_source: &str, + language: PtcLanguage, + include_deferred_tool_apis: bool, +) -> String { + if language.is_typescript() { + return render_ptc_tool_calling_guide_with_options(sdk_source, include_deferred_tool_apis); + } + let deferred_guidance = if include_deferred_tool_apis { + "\n\nFor deferred tools:\n1. Await `tools.get_tool_spec(\"tool_name\")` before choosing arguments.\n2. Read the returned `sdk_source`, which contains the input and output types.\n3. Await `tools.call_tool(\"tool_name\", args)` with arguments matching that SDK.\nDo not infer deferred tool arguments from the placeholder type." + } else { + "" + }; + format!( + "# Tool Calling Guide\nUse `RunCode` with a Python program only. The program is inserted into an already-running async function: use top-level `await` and never call `asyncio.run`. Use `await tools.ToolName(args)` for direct tools and return a JSON-serializable value. The runtime has already imported and bound `asyncio` and `json` in the execution scope; use them directly. Do not emit TypeScript/JavaScript (`const`, `let`, `Promise`, `=>`) or import other modules, including `os`, `pathlib`, `subprocess`, `requests`, and similar modules.{}\n\nGenerated SDK:\n```python\n{}\n```", + deferred_guidance, + sdk_source.trim(), + ) +} + /// Render the PTC conversation-start guide with wording that matches the /// capabilities exposed by the generated SDK. pub fn render_ptc_tool_calling_guide_with_options( @@ -239,7 +646,7 @@ pub fn render_ptc_tool_calling_guide_with_options( include_deferred_tool_apis: bool, ) -> String { let deferred_guidance = if include_deferred_tool_apis { - "\n\nFor deferred tools:\n1. Call `tools.getToolSpec(\"tool_name\")` before choosing arguments.\n2. Read the returned `typescript_sdk`, which contains the input and output types.\n3. Call `tools.callTool(\"tool_name\", args)` with arguments matching that SDK.\nDo not infer deferred tool arguments from the placeholder `Record` type." + "\n\nFor deferred tools:\n1. Call `tools.getToolSpec(\"tool_name\")` before choosing arguments.\n2. Read the returned `sdk_source`, which contains the input and output types.\n3. Call `tools.callTool(\"tool_name\", args)` with arguments matching that SDK.\nDo not infer deferred tool arguments from the placeholder `Record` type." } else { "" }; @@ -517,6 +924,51 @@ fn is_typescript_identifier(value: &str) -> bool { }) } +fn is_python_identifier(value: &str) -> bool { + if !is_typescript_identifier(value) { + return false; + } + !matches!( + value, + "and" + | "as" + | "assert" + | "async" + | "await" + | "break" + | "case" + | "class" + | "continue" + | "def" + | "del" + | "elif" + | "else" + | "except" + | "False" + | "finally" + | "for" + | "from" + | "global" + | "if" + | "import" + | "in" + | "is" + | "lambda" + | "None" + | "nonlocal" + | "not" + | "or" + | "pass" + | "raise" + | "return" + | "True" + | "try" + | "while" + | "with" + | "yield" + ) +} + fn json_string_literal(value: &str) -> String { serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) } @@ -534,11 +986,14 @@ fn json_literal_type(value: &Value) -> String { #[cfg(test)] mod tests { use super::{ - json_schema_to_typescript, ptc_run_code_description, ptc_run_code_input_schema, - render_ptc_loaded_tool_typescript_sdk, render_ptc_tool_calling_guide, + json_schema_to_typescript, ptc_run_code_description, ptc_run_code_description_for_language, + ptc_run_code_input_schema, ptc_run_code_input_schema_for_language, + render_ptc_loaded_tool_sdk, render_ptc_loaded_tool_typescript_sdk, render_ptc_sdk, + render_ptc_tool_calling_guide, render_ptc_tool_calling_guide_for_language, render_ptc_tool_calling_guide_with_options, render_ptc_typescript_sdk, render_ptc_typescript_sdk_with_options, PtcToolContract, }; + use openbitfun_core_types::PtcLanguage; use serde_json::json; #[test] @@ -647,7 +1102,8 @@ mod tests { assert!(output.contains("WebFetch: Record;")); assert!(output.contains("WebFetch: unknown;")); assert!(output.contains("getToolSpec(name: string)")); - assert!(output.contains("Promise<{ typescript_sdk: string }>")); + assert!(output.contains("Promise<{ language: string; sdk_source: string }>")); + assert!(!output.contains("typescript_sdk")); assert!(!output.contains("input_schema: unknown")); assert!(output.contains("callTool(name: string, args: Record)")); assert!(!output.contains("url: string")); @@ -667,7 +1123,8 @@ mod tests { assert!( guide.contains("Call `tools.getToolSpec(\"tool_name\")` before choosing arguments.") ); - assert!(guide.contains("Read the returned `typescript_sdk`")); + assert!(guide.contains("Read the returned `sdk_source`")); + assert!(!guide.contains("typescript_sdk")); assert!(guide.contains("Call `tools.callTool(\"tool_name\", args)`")); assert!(guide.contains("Do not infer deferred tool arguments")); } @@ -702,6 +1159,114 @@ mod tests { assert!(!guide.contains("deferred tools")); } + #[test] + fn python_sdk_exposes_valid_bindings_and_generic_deferred_apis() { + let direct = PtcToolContract::new( + "Read", + "Read a file.", + json!({"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}), + json!({"type": "string"}), + ); + let mut deferred = PtcToolContract::new( + "tool.with-dash", + "Deferred tool.", + json!({"type": "object"}), + json!({}), + ); + deferred.is_deferred = true; + let output = render_ptc_sdk(&[direct, deferred], PtcLanguage::Python); + assert!(output.contains("ReadArgs = TypedDict(")); + assert!(output.contains("\"path\": Required[str]")); + assert!(output.contains("\"limit\": NotRequired[int]")); + assert!(output.contains("ReadOutput = str")); + assert!(output.contains("async def Read(self, args: ReadArgs) -> ReadOutput")); + assert!(output.contains("PtcToolSpec = TypedDict(")); + assert!(output.contains("\"language\": Required[str]")); + assert!(output.contains("\"sdk_source\": Required[str]")); + assert!(output.contains("async def get_tool_spec(self, name: str) -> PtcToolSpec")); + assert!(output.contains("async def call_tool(self, name: str, args: dict[str, Any])")); + assert!(!output.contains("async def tool.with-dash")); + + let loaded = render_ptc_loaded_tool_sdk( + &PtcToolContract::new( + "tool.with-dash", + "Loaded tool.", + json!({"type": "object"}), + json!({"type": "string"}), + ), + PtcLanguage::Python, + ); + assert!(loaded.contains("async def call_tool(self, name: str")); + assert!(!loaded.contains("async def get_tool_spec(self")); + } + + #[test] + fn python_sdk_preserves_nested_object_shapes() { + let contract = PtcToolContract::new( + "ListFiles", + "List files.", + json!({ + "type": "object", + "properties": { + "filter": { + "type": "object", + "properties": { + "pattern": {"type": "string"} + }, + "required": ["pattern"], + "additionalProperties": false + } + }, + "required": ["filter"], + "additionalProperties": false + }), + json!({ + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "is_dir": {"type": "boolean"} + }, + "required": ["name", "is_dir"], + "additionalProperties": false + } + } + }, + "required": ["entries"], + "additionalProperties": false + }), + ); + + let output = render_ptc_sdk(&[contract], PtcLanguage::Python); + assert!(output.contains("\"pattern\": Required[str]")); + assert!(output.contains("\"filter\": Required[ListFilesArgsFilter]")); + assert!(output.contains("\"name\": Required[str]")); + assert!(output.contains("\"is_dir\": Required[bool]")); + assert!(output.contains("\"entries\": Required[list[ListFilesOutputEntriesItem]]")); + } + + #[test] + fn python_calling_guide_uses_python_deferred_contract_name() { + let guide = render_ptc_tool_calling_guide_for_language( + "tools: PtcToolBindings", + PtcLanguage::Python, + true, + ); + assert!(guide.contains("Python program")); + assert!(guide.contains("Do not emit TypeScript/JavaScript")); + assert!(guide.contains("already imported and bound `asyncio` and `json`")); + assert!(guide + .contains("including `os`, `pathlib`, `subprocess`, `requests`, and similar modules")); + assert!(guide.contains("never call `asyncio.run`")); + assert!(guide.contains("tools.get_tool_spec")); + assert!(guide.contains("`sdk_source`")); + assert!(!guide.contains("typescript_sdk")); + } + #[test] fn run_code_schema_requires_code_and_description_points_to_guide() { let schema = ptc_run_code_input_schema(); @@ -718,4 +1283,20 @@ mod tests { render_ptc_tool_calling_guide("export type ToolArgsMap = {};").contains("ToolArgsMap") ); } + + #[test] + fn python_run_code_contract_rejects_typescript_syntax() { + let description = ptc_run_code_description_for_language(PtcLanguage::Python); + assert!(description.contains("Do not emit TypeScript/JavaScript")); + assert!(description.contains("`const`, `let`, `Promise`, or `=>`")); + + let schema = ptc_run_code_input_schema_for_language(PtcLanguage::Python); + let code_description = schema["properties"]["code"]["description"] + .as_str() + .expect("Python RunCode code description should be a string"); + assert_eq!( + code_description, + "Python source code executed inside an existing async function. Use top-level `await`; do not call `asyncio.run`." + ); + } } diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index b3e73a73cf..719799ae24 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -469,6 +469,7 @@ script-tool-runtime = [ ] ptc-runtime = [ "async-trait", + "dep:openbitfun-core-types", "openbitfun-runtime-ports/ptc-runtime", "process-tree", "tokio/io-util", diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 83bc5c9d5b..dd492b4c06 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -104,6 +104,8 @@ pub mod script_tool; #[cfg(feature = "ptc-runtime")] pub mod ptc_runtime; +#[cfg(feature = "ptc-runtime")] +pub mod python_ptc_runtime; #[cfg(feature = "speech")] pub mod speech; diff --git a/src/crates/services/services-integrations/src/python_ptc_runtime.rs b/src/crates/services/services-integrations/src/python_ptc_runtime.rs new file mode 100644 index 0000000000..382e585db1 --- /dev/null +++ b/src/crates/services/services-integrations/src/python_ptc_runtime.rs @@ -0,0 +1,890 @@ +//! Python runtime for PTC programs on local or remote workspaces. +//! +//! The child process is a failure-containment boundary, not a security +//! sandbox. The bootstrap exposes only the `tools` binding and a small set of +//! language primitives; host-side policy still owns nested tool admission. + +#[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] +use crate::remote_ssh::{shell_quote_posix, SSHCommandOptions, SSHConnectionManager}; +use async_trait::async_trait; +use openbitfun_runtime_ports::{ + PortError, PortErrorKind, PortResult, PtcExecutionRequest, PtcExecutionResponse, + PtcNestedToolCall, PtcRuntime, PtcRuntimeAvailability, PtcToolCallHandler, PtcToolSpecRequest, + RuntimeServiceCapability, +}; +use openbitfun_services_core::process_tree::ProcessTreeChild; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex, RwLock}; +use tokio::time::{timeout, Duration}; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); +const CANCEL_GRACE: Duration = Duration::from_millis(500); +const MAX_FRAME_BYTES: usize = 128 * 1024 * 1024; + +const BOOTSTRAP: &str = r#"import asyncio +import contextlib +import io +import json +import sys +import threading +import traceback +import uuid + +_TOKEN = str(uuid.uuid4()) +_PENDING = {} +_PENDING_LOCK = threading.Lock() +_LOOP = None +_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 +_LOGS = [] + + +def _write(value): + encoded = json.dumps(_json_safe(value), ensure_ascii=True, separators=(",", ":")).encode("ascii") + sys.__stdout__.buffer.write(encoded + b"\n") + sys.__stdout__.buffer.flush() + + +def _json_safe(value): + if isinstance(value, str): + return "".join("\ufffd" if 0xD800 <= ord(character) <= 0xDFFF else character for character in value) + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {_json_safe(key): _json_safe(item) for key, item in value.items()} + return value + + +def _log(*values): + _LOGS.append(" ".join(_json_safe(value) if isinstance(value, str) else json.dumps(_json_safe(value), ensure_ascii=False) for value in values)) + + +def _json_value(value): + encoded = json.dumps(_json_safe(value), ensure_ascii=True, separators=(",", ":")) + if len(encoded.encode("utf-8")) > _MAX_OUTPUT_BYTES: + raise RuntimeError("PTC result exceeds output limit") + return json.loads(encoded) + + +async def _call_tool(name, arguments, allowed, enforce_binding=True): + if enforce_binding and name not in allowed: + raise RuntimeError(f"tool '{name}' is not available in this PTC snapshot") + if not isinstance(arguments, dict): + raise RuntimeError(f"arguments for '{name}' must be an object") + operation_id = str(uuid.uuid4()) + future = _LOOP.create_future() + with _PENDING_LOCK: + _PENDING[operation_id] = future + _write({"kind": "tool_call", "token": _TOKEN, "operationId": operation_id, "toolName": name, "arguments": arguments, "enforceBinding": enforce_binding}) + return await future + + +async def _get_tool_spec(name): + if not isinstance(name, str) or not name: + raise RuntimeError("get_tool_spec requires a non-empty tool name") + operation_id = str(uuid.uuid4()) + future = _LOOP.create_future() + with _PENDING_LOCK: + _PENDING[operation_id] = future + _write({"kind": "get_tool_spec", "token": _TOKEN, "operationId": operation_id, "toolName": name}) + return await future + + +class _Tools: + def __init__(self, names): + self._names = names + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(name) + return lambda arguments: _call_tool(name, arguments, self._names) + + async def get_tool_spec(self, name): + return await _get_tool_spec(name) + + async def call_tool(self, name, arguments): + return await _call_tool(name, arguments, self._names, False) + + +_SAFE_BUILTINS = { + "bool": bool, "dict": dict, "enumerate": enumerate, "filter": filter, + "float": float, "int": int, "len": len, "list": list, "map": map, + "max": max, "min": min, "range": range, "reversed": reversed, + "round": round, "set": set, "sorted": sorted, "str": str, + "sum": sum, "tuple": tuple, "zip": zip, "abs": abs, + "all": all, "any": any, "isinstance": isinstance, "print": print, + "Exception": Exception, "ValueError": ValueError, "TypeError": TypeError, +} + + +def _safe_import(name, globals=None, locals=None, fromlist=(), level=0): + if level != 0 or name not in {"asyncio", "json"}: + raise ImportError(f"module '{name}' is not available in PTC programs") + return {"asyncio": asyncio, "json": json}[name] + + +_SAFE_BUILTINS["__import__"] = _safe_import + + +async def _execute(message): + global _MAX_OUTPUT_BYTES, _LOGS + configured_limit = message.get("maxOutputBytes") + _MAX_OUTPUT_BYTES = configured_limit if isinstance(configured_limit, int) and configured_limit > 0 else 8 * 1024 * 1024 + _LOGS = [] + names = {binding.get("name") for binding in message.get("bindings", []) if isinstance(binding, dict) and isinstance(binding.get("name"), str)} + tools = _Tools(names) + source = message.get("source") + if not isinstance(source, str) or not source.strip(): + raise ValueError("PTC execution request is incomplete") + indented = "\n".join(" " + line if line else "" for line in source.splitlines()) + wrapped = "async def __ptc_main__():\n" + indented + "\n" + scope = {"__builtins__": _SAFE_BUILTINS, "asyncio": asyncio, "json": json, "tools": tools} + output = io.StringIO() + with contextlib.redirect_stdout(output): + exec(compile(wrapped, "openbitfun-ptc.py", "exec"), scope, scope) + value = await scope["__ptc_main__"]() + printed = output.getvalue() + if printed: + _LOGS.extend(_json_safe(line) for line in printed.rstrip("\n").splitlines()) + result = _json_value(value) + if len(json.dumps(_json_safe(_LOGS), ensure_ascii=True).encode("ascii")) > _MAX_OUTPUT_BYTES: + raise RuntimeError("PTC logs exceed output limit") + return {"value": result, "logs": _LOGS} + + +def _resolve_tool_result(message): + operation_id = message.get("operationId") + with _PENDING_LOCK: + future = _PENDING.pop(operation_id, None) + if future is None or future.done(): + return + if message.get("ok"): + future.set_result(message.get("value")) + else: + future.set_exception(RuntimeError(message.get("error") or "nested tool call failed")) + + +def _reader(): + for raw_line in sys.stdin.buffer: + try: + message = json.loads(raw_line.decode("utf-8")) + except Exception: + return + if message.get("type") == "tool_result": + _LOOP.call_soon_threadsafe(_resolve_tool_result, message) + elif message.get("type") == "execute": + async def run(current): + try: + result = await _execute(current) + _write({"kind": "complete", "token": _TOKEN, "id": current.get("id"), "ok": True, "result": result}) + except Exception as error: + _write({"kind": "complete", "token": _TOKEN, "id": current.get("id"), "ok": False, "errorKind": "exception", "error": str(error)[:4096]}) + _LOOP.call_soon_threadsafe(lambda: asyncio.create_task(run(message))) + + +async def _main(): + global _LOOP + _LOOP = asyncio.get_running_loop() + _write({"kind": "ready", "token": _TOKEN}) + thread = threading.Thread(target=_reader, daemon=True) + thread.start() + await asyncio.Event().wait() + + +try: + asyncio.run(_main()) +except KeyboardInterrupt: + pass"#; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Frame { + kind: String, + token: String, + #[serde(default)] + id: Option, + #[serde(default)] + operation_id: Option, + #[serde(default)] + tool_name: Option, + #[serde(default)] + arguments: Value, + #[serde(default)] + ok: bool, + #[serde(default)] + result: Option, + #[serde(default)] + error_kind: Option, + #[serde(default)] + error: Option, + #[serde(default = "default_true")] + enforce_binding: bool, +} + +fn default_true() -> bool { + true +} + +type PtcReader = Pin>; +type PtcWriter = Pin>; + +enum PtcProcessControl { + Local(Mutex), + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + Remote(crate::remote_ssh::WorkspaceProcessControl), +} + +struct PtcProcess { + stdin: Mutex, + control: PtcProcessControl, +} + +impl PtcProcess { + async fn terminate(&self) { + match &self.control { + PtcProcessControl::Local(child) => { + let _ = child.lock().await.terminate(CANCEL_GRACE).await; + } + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + PtcProcessControl::Remote(control) => { + let _ = control.kill().await; + } + } + } + + async fn send(&self, value: Value) -> PortResult<()> { + let encoded = serde_json::to_vec(&value).map_err(|error| { + PortError::new( + PortErrorKind::InvalidRequest, + format!("invalid PTC frame: {error}"), + ) + })?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "PTC control frame exceeds the protocol limit", + )); + } + let mut stdin = self.stdin.lock().await; + stdin.write_all(&encoded).await.map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("PTC stdin failed: {error}"), + ) + })?; + stdin.write_all(b"\n").await.map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("PTC stdin failed: {error}"), + ) + })?; + stdin.flush().await.map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("PTC stdin failed: {error}"), + ) + }) + } +} + +pub struct PythonPtcRuntime { + executable: RwLock>, + operations: Mutex>>, + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + remote_target: Option, +} + +#[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] +#[derive(Clone)] +struct RemotePtcTarget { + ssh_manager: SSHConnectionManager, + connection_id: String, +} + +impl Default for PythonPtcRuntime { + fn default() -> Self { + Self::discover() + } +} + +impl PythonPtcRuntime { + pub fn discover() -> Self { + Self { + executable: RwLock::new( + which::which("python3") + .or_else(|_| which::which("python")) + .ok(), + ), + operations: Mutex::new(HashMap::new()), + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + remote_target: None, + } + } + + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + pub fn for_remote(ssh_manager: SSHConnectionManager, connection_id: impl Into) -> Self { + Self { + executable: RwLock::new(None), + operations: Mutex::new(HashMap::new()), + remote_target: Some(RemotePtcTarget { + ssh_manager, + connection_id: connection_id.into(), + }), + } + } + + async fn executable(&self) -> Option { + if let Some(path) = self.executable.read().await.clone() { + return Some(path); + } + let path = which::which("python3") + .or_else(|_| which::which("python")) + .ok()?; + *self.executable.write().await = Some(path.clone()); + Some(path) + } + + async fn spawn_local( + &self, + executable: &PathBuf, + cwd: &str, + ) -> PortResult<(Arc, BufReader)> { + let mut command = + openbitfun_services_core::process_manager::create_tokio_command(executable); + command + .arg("-u") + .arg("-c") + .arg(BOOTSTRAP) + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env_remove("PYTHONPATH") + .env_remove("PYTHONHOME"); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("failed to start Python PTC runtime: {error}"), + ) + })?; + let stdin = child + .take_stdin() + .ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC stdin unavailable"))?; + let stdout = child + .take_stdout() + .ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC stdout unavailable"))?; + if let Some(mut stderr) = child.take_stderr() { + tokio::spawn(async move { + let _ = tokio::io::copy(&mut stderr, &mut tokio::io::sink()).await; + }); + } + let process = Arc::new(PtcProcess { + stdin: Mutex::new(Box::pin(stdin)), + control: PtcProcessControl::Local(Mutex::new(child)), + }); + Ok((process, BufReader::new(Box::pin(stdout)))) + } + + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + async fn spawn_remote( + &self, + target: &RemotePtcTarget, + cwd: &str, + ) -> PortResult<(Arc, BufReader)> { + let command = format!( + "unset NODE_OPTIONS NODE_PATH; PYTHON=$(for candidate in python3 python; do if command -v \"$candidate\" >/dev/null 2>&1; then version=$(\"$candidate\" --version 2>&1) || continue; case \"$version\" in Python\\ 3.*) command -v \"$candidate\"; break;; esac; fi; done); test -n \"$PYTHON\" || {{ echo 'Python 3 is not available' >&2; exit 127; }}; cd -- {} && exec \"$PYTHON\" -u -c {}", + shell_quote_posix(cwd), + shell_quote_posix(BOOTSTRAP), + ); + let transport = target + .ssh_manager + .open_workspace_stdio(&target.connection_id, &command) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("failed to start remote Python PTC runtime: {error}"), + ) + })?; + let (stdin, stdout, mut stderr, control, completion) = transport.into_parts(); + tokio::spawn(async move { + let _ = tokio::io::copy(&mut stderr, &mut tokio::io::sink()).await; + let _ = completion.wait().await; + }); + let process = Arc::new(PtcProcess { + stdin: Mutex::new(stdin), + control: PtcProcessControl::Remote(control), + }); + Ok((process, BufReader::new(stdout))) + } + + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + async fn remote_python_version(target: &RemotePtcTarget) -> Result { + let result = target + .ssh_manager + .execute_command_with_options( + &target.connection_id, + "for candidate in python3 python; do if command -v \"$candidate\" >/dev/null 2>&1; then version=$(\"$candidate\" --version 2>&1) || continue; case \"$version\" in Python\\ 3.*) printf '%s' \"$version\"; exit 0;; esac; fi; done; exit 127", + SSHCommandOptions { + timeout_ms: Some(10_000), + cancellation_token: None, + }, + ) + .await + .map_err(|error| error.to_string())?; + if result.exit_code != 0 { + return Err(if result.stderr.trim().is_empty() { + "remote Python version probe failed".to_string() + } else { + result.stderr.trim().to_string() + }); + } + let version = if result.stdout.trim().is_empty() { + result.stderr.trim().to_string() + } else { + result.stdout.trim().to_string() + }; + if version.is_empty() { + Err("remote Python version probe returned no version".to_string()) + } else if !version.starts_with("Python 3.") { + Err("Python 3 is required".to_string()) + } else { + Ok(version) + } + } + + async fn runtime_version(&self) -> PortResult<(Option, String)> { + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + if let Some(target) = &self.remote_target { + let version = Self::remote_python_version(target) + .await + .map_err(|reason| { + PortError::new( + PortErrorKind::NotAvailable, + format!("remote Python PTC runtime is unavailable: {reason}"), + ) + })?; + return Ok((None, version)); + } + + let executable = self.executable().await.ok_or_else(|| { + PortError::new(PortErrorKind::NotAvailable, "Python is not available") + })?; + let version = python_version(&executable).await.map_err(|reason| { + PortError::new( + PortErrorKind::NotAvailable, + format!("Python PTC runtime is unavailable: {reason}"), + ) + })?; + Ok((Some(executable), version)) + } + + async fn spawn_process( + &self, + executable: Option<&PathBuf>, + cwd: &str, + ) -> PortResult<(Arc, BufReader)> { + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + if let Some(target) = &self.remote_target { + return self.spawn_remote(target, cwd).await; + } + + self.spawn_local( + executable.expect("local PTC runtime should have an executable"), + cwd, + ) + .await + } + + async fn execute_inner( + &self, + request: PtcExecutionRequest, + handler: Arc, + ) -> PortResult { + if request.operation_id.trim().is_empty() || request.source.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "PTC execution request is incomplete", + )); + } + let (executable, _version) = self.runtime_version().await?; + let (process, mut reader) = self + .spawn_process(executable.as_ref(), &request.working_directory) + .await?; + self.operations + .lock() + .await + .insert(request.operation_id.clone(), process.clone()); + let result = async { + let ready = timeout(STARTUP_TIMEOUT, read_frame(&mut reader)) + .await + .map_err(|_| PortError::new(PortErrorKind::Timeout, "PTC runtime startup timed out"))??; + if ready.kind != "ready" || ready.token.len() < 16 || ready.token.len() > 128 { + return Err(PortError::new(PortErrorKind::Backend, "invalid PTC startup frame")); + } + let id = 1_u64; + process.send(serde_json::json!({ + "type": "execute", "id": id, "source": request.source, + "bindings": request.bindings, "maxOutputBytes": request.max_output_bytes, + })).await?; + let allowed = request.bindings.iter().map(|binding| binding.name.clone()).collect::>(); + loop { + let frame = read_frame(&mut reader).await?; + if frame.token != ready.token { continue; } + if frame.kind == "tool_call" { + let operation_id = frame.operation_id.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool call has no operation id"))?; + let tool_name = frame.tool_name.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool call has no tool name"))?; + if frame.enforce_binding && !allowed.contains(&tool_name) { + process.send(serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":false,"error":"tool is not in the PTC snapshot"})).await?; + continue; + } + let child_process = process.clone(); + let handler = handler.clone(); + tokio::spawn(async move { + let result = handler.call(PtcNestedToolCall { operation_id: operation_id.clone(), tool_name, arguments: frame.arguments }).await; + let payload = match result { + Ok(value) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":true,"value":value}), + Err(error) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":false,"error":error.message,"errorKind":error.kind}), + }; + let _ = child_process.send(payload).await; + }); + continue; + } + if frame.kind == "get_tool_spec" { + let operation_id = frame.operation_id.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool spec request has no operation id"))?; + let tool_name = frame.tool_name.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool spec request has no tool name"))?; + let child_process = process.clone(); + let handler = handler.clone(); + tokio::spawn(async move { + let result = handler + .get_tool_spec(PtcToolSpecRequest { operation_id: operation_id.clone(), tool_name }) + .await; + let payload = match result { + Ok(value) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":true,"value":value}), + Err(error) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":false,"error":error.message,"errorKind":error.kind}), + }; + let _ = child_process.send(payload).await; + }); + continue; + } + if frame.kind == "complete" && frame.id == Some(id) { + if frame.ok { + return frame.result.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC completion has no result")); + } + let kind = match frame.error_kind.as_deref() { + Some("output_limit") => PortErrorKind::InvalidRequest, + Some("abort") => PortErrorKind::Cancelled, + _ => PortErrorKind::Backend, + }; + return Err(PortError::new(kind, frame.error.unwrap_or_else(|| "PTC program failed".to_string()))); + } + } + }.await; + self.operations.lock().await.remove(&request.operation_id); + process.terminate().await; + result + } +} + +async fn read_frame(reader: &mut BufReader) -> PortResult +where + R: AsyncRead + Unpin, +{ + let mut bytes = Vec::new(); + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| PortError::new(PortErrorKind::NotAvailable, error.to_string()))?; + if available.is_empty() { + return Err(PortError::new( + PortErrorKind::NotAvailable, + "PTC runtime exited", + )); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.unwrap_or(available.len()); + if bytes.len().saturating_add(take) > MAX_FRAME_BYTES { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "PTC frame exceeds the protocol limit", + )); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take + usize::from(newline.is_some())); + if newline.is_some() { + break; + } + } + serde_json::from_slice(&bytes).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("invalid PTC frame: {error}"), + ) + }) +} + +async fn python_version(executable: &PathBuf) -> Result { + let output = openbitfun_services_core::process_manager::create_tokio_command(executable) + .arg("--version") + .output() + .await + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("Python version probe failed".to_string()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let version = if stdout.trim().is_empty() { + stderr + } else { + stdout + }; + let version = version.trim().to_string(); + if !version.starts_with("Python 3.") { + return Err("Python 3 is required".to_string()); + } + Ok(version) +} + +#[async_trait] +impl PtcRuntime for PythonPtcRuntime { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::PtcRuntime + } + + async fn availability(&self) -> PtcRuntimeAvailability { + match self.runtime_version().await { + Ok((executable, version)) => PtcRuntimeAvailability::Available { + executable: executable + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| "python (remote workspace)".to_string()), + version, + }, + Err(error) => PtcRuntimeAvailability::Unavailable { + reason: error.message, + }, + } + } + + async fn execute( + &self, + request: PtcExecutionRequest, + handler: Arc, + ) -> PortResult { + let deadline = Duration::from_millis( + request + .timeout_ms + .unwrap_or(DEFAULT_TIMEOUT.as_millis() as u64) + .max(1), + ); + match timeout(deadline, self.execute_inner(request.clone(), handler)).await { + Ok(result) => result, + Err(_) => { + let _ = self.cancel(&request.operation_id).await; + Err(PortError::new( + PortErrorKind::Timeout, + "PTC program timed out", + )) + } + } + } + + async fn cancel(&self, operation_id: &str) -> PortResult<()> { + let process = self + .operations + .lock() + .await + .remove(operation_id) + .ok_or_else(|| { + PortError::new(PortErrorKind::NotFound, "PTC operation is not running") + })?; + process.terminate().await; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::PythonPtcRuntime; + use async_trait::async_trait; + use openbitfun_runtime_ports::{ + PtcExecutionRequest, PtcNestedToolCall, PtcRuntime, PtcToolBinding, PtcToolCallHandler, + PtcToolSpecRequest, + }; + use serde_json::{json, Value}; + use std::sync::Arc; + + struct Handler; + #[async_trait] + impl PtcToolCallHandler for Handler { + async fn call( + &self, + request: PtcNestedToolCall, + ) -> openbitfun_runtime_ports::PortResult { + Ok(json!({ + "tool": request.tool_name, + "arguments": request.arguments, + "content": "是一个中文结果" + })) + } + + async fn get_tool_spec( + &self, + request: PtcToolSpecRequest, + ) -> openbitfun_runtime_ports::PortResult { + Ok(json!({ + "tool_name": request.tool_name, + "catalog_generation": 7, + "input_schema": {"type": "object"}, + "output_schema": {"type": "object"}, + })) + } + } + + #[tokio::test] + async fn executes_program_and_dispatches_nested_tool_call_when_python_is_available() { + let runtime = PythonPtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-test".to_string(), + source: "result = await tools.Read({'path': 'src/lib.rs'})\nreturn result".to_string(), + bindings: vec![PtcToolBinding { + name: "Read".to_string(), + }], + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(30_000), + max_output_bytes: Some(1024 * 1024), + }; + let response = runtime.execute(request, Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value["tool"], "Read"); + } + + #[tokio::test] + async fn exposes_ptc_spec_loading_and_generic_call_api() { + let runtime = PythonPtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-spec-test".to_string(), + source: "spec = await tools.get_tool_spec('Deferred')\nresult = await tools.call_tool('Deferred', {'value': 1})\nreturn {'spec': spec, 'result': result}".to_string(), + bindings: Vec::new(), + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(30_000), + max_output_bytes: Some(1024 * 1024), + }; + let response = runtime.execute(request, Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value["spec"]["tool_name"], "Deferred"); + assert_eq!(response.value["result"]["tool"], "Deferred"); + } + + #[tokio::test] + async fn permits_preloaded_asyncio_import_and_sanitizes_surrogates() { + let runtime = PythonPtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-unicode-test".to_string(), + source: "import asyncio\nprint('\\udcaf')\nawait asyncio.sleep(0)\nreturn {'value': '\\udcaf'}" + .to_string(), + bindings: Vec::new(), + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(30_000), + max_output_bytes: Some(1024 * 1024), + }; + let response = runtime.execute(request, Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value["value"], "�"); + assert_eq!(response.logs, vec!["�"]); + } + + #[tokio::test] + async fn preserves_utf8_tool_results_across_the_python_frame() { + let runtime = PythonPtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-utf8-result-test".to_string(), + source: "result = await tools.Read({'path': 'x'})\nreturn result".to_string(), + bindings: vec![PtcToolBinding { + name: "Read".to_string(), + }], + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(30_000), + max_output_bytes: Some(1024 * 1024), + }; + let response = runtime.execute(request, Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value["content"], "是一个中文结果"); + } + + #[tokio::test] + async fn timeout_terminates_a_long_running_program() { + let runtime = PythonPtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-timeout-test".to_string(), + source: "await asyncio.sleep(5)\nreturn 1".to_string(), + bindings: Vec::new(), + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(100), + max_output_bytes: Some(1024 * 1024), + }; + let error = runtime + .execute(request, Arc::new(Handler)) + .await + .expect_err("long-running PTC program should time out"); + assert_eq!(error.kind, openbitfun_runtime_ports::PortErrorKind::Timeout); + } +} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 2237c199bf..20677faf1e 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -8171,8 +8171,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式", + "titleEn": "Choose native, PTC TypeScript, or PTC Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -8393,8 +8393,8 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript." + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript/Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript or Python." }, { "id": "subagent-batch-policy", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index bd635ef240..43d5d0b9e3 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5c26d781f664b1daa3cef9062f0ad3e776544242b80fbd1fc303d59d4494eed3", - "ownerDigest": "0b2d30952efd57108d9e566dc0bf5a176f2b3d753abfb736bfc32b2c1fa3f7b7", + "digest": "513e184cc2d93f183cdea258516f532caf58ac6400ef76dce9b54f6de0752627", + "ownerDigest": "3cc6d7d869b2c90c2161494a854b5ff7eb72ea69a1a28e40ccd42065fb3382e2", "searchAcceptance": [ { "id": "native-image-generation", @@ -16923,7 +16923,8 @@ "type": "string", "enum": [ "native", - "ptc" + "ptc", + "ptc_python" ] }, "outputSchema": { @@ -30162,8 +30163,8 @@ }, { "id": "tool-presentation", - "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", - "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "titleZh": "选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式", + "titleEn": "Choose native, PTC TypeScript, or PTC Python mode for the model", "destination": { "kind": "settings", "pageId": "tools.execution", @@ -30359,13 +30360,14 @@ "id": "tool-presentation-mode", "titleZh": "工具呈现模式", "titleEn": "Tool presentation mode", - "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", - "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript/Python 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript or Python.", "valueSchema": { "type": "string", "enum": [ "native", - "ptc" + "ptc", + "ptc_python" ] } }, @@ -30496,8 +30498,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", - "选择模型使用原生模式还是 PTC TypeScript 模式", - "Choose native mode or PTC TypeScript mode for the model", + "选择模型使用原生模式、PTC TypeScript 或 PTC Python 模式", + "Choose native, PTC TypeScript, or PTC Python mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx index 4e10962e49..96e268dd19 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx @@ -41,7 +41,7 @@ type DescribedSelectOption = SelectOption & { description?: string }; const log = createLogger('ExecutionSettingsPage'); type SubagentBatchExecutionPolicy = 'safe_only' | 'force_parallel' | 'serial'; -type ToolPresentationMode = 'native' | 'ptc'; +type ToolPresentationMode = 'native' | 'ptc' | 'ptc_python'; const DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY: SubagentBatchExecutionPolicy = 'force_parallel'; @@ -107,7 +107,10 @@ const ExecutionSettingsPage: React.FC = () => { const [deferredToolLoadingConfigSaving, setDeferredToolLoadingConfigSaving] = useState(false); const [toolPresentationMode, setToolPresentationMode] = useState( - seed.get('ai.tool_presentation_mode', 'native') === 'ptc' ? 'ptc' : 'native', + (() => { + const value = seed.get('ai.tool_presentation_mode', 'native'); + return value === 'ptc' || value === 'ptc_python' ? value : 'native'; + })(), ); const [toolPresentationModeSaving, setToolPresentationModeSaving] = useState(false); @@ -144,8 +147,9 @@ const ExecutionSettingsPage: React.FC = () => { }; const handleToolPresentationModeChange = async (value: string | number | (string | number)[]) => { - const nextMode: ToolPresentationMode = String(Array.isArray(value) ? value[0] : value) === 'ptc' - ? 'ptc' + const selected = String(Array.isArray(value) ? value[0] : value); + const nextMode: ToolPresentationMode = selected === 'ptc' || selected === 'ptc_python' + ? selected : 'native'; if (nextMode === toolPresentationMode) return; const previousMode = toolPresentationMode; @@ -341,7 +345,11 @@ const ExecutionSettingsPage: React.FC = () => { ? '180' : loadedUserQuestionTimeout === null ? '0' : String(loadedUserQuestionTimeout)); setSubagentBatchExecutionPolicy(normalizeSubagentBatchExecutionPolicy(loadedSubagentBatchExecutionPolicy)); - setToolPresentationMode(loadedToolPresentationMode === 'ptc' ? 'ptc' : 'native'); + setToolPresentationMode( + loadedToolPresentationMode === 'ptc' || loadedToolPresentationMode === 'ptc_python' + ? loadedToolPresentationMode + : 'native', + ); hasLoadedPageDataRef.current = true; } catch (error) { @@ -417,6 +425,7 @@ const ExecutionSettingsPage: React.FC = () => { options={[ { value: 'native', label: t('toolPresentation.native') }, { value: 'ptc', label: t('toolPresentation.ptc'), tooltip: t('toolPresentation.ptcTooltip') }, + { value: 'ptc_python', label: t('toolPresentation.ptcPython'), tooltip: t('toolPresentation.ptcPythonTooltip') }, ]} disabled={toolPresentationModeSaving} onValueChange={handleToolPresentationModeChange} diff --git a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx index efdfd1b5f1..69c9770f8a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx @@ -53,6 +53,18 @@ function readOutput(result: unknown): string { return formatSessionViewPreviewText(asText(record.output) || combined); } +function readLanguage(input: unknown, result: unknown): string { + if (input && typeof input === 'object' && !Array.isArray(input)) { + const language = asText((input as Record).language); + if (language) return language; + } + if (result && typeof result === 'object' && !Array.isArray(result)) { + const language = asText((result as Record).__ptc_language); + if (language) return language; + } + return 'typescript'; +} + export const RunCodeToolCard: React.FC = ({ toolItem, config, @@ -71,6 +83,7 @@ export const RunCodeToolCard: React.FC = ({ const code = asText(toolCall?.input?.code); const description = asText(toolCall?.input?.description).trim(); const output = useMemo(() => readOutput(toolResult?.result), [toolResult?.result]); + const language = readLanguage(toolCall?.input, toolResult?.result); const errorMessage = status === 'error' ? toolResult?.error || t('toolCards.runCode.failed') : null; @@ -113,7 +126,7 @@ export const RunCodeToolCard: React.FC = ({ program={code ? (