Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,27 @@
[`learn-claude-code`](https://github.com/shareAI-lab/learn-claude-code) 的 Harness
Engineering 思路:模型负责判断下一步做什么,Harness 负责提供工具、上下文、权限边界、持久化和用户界面。

当前版本为 `0.1.0`,使用 DeepSeek 模型,提供一次性命令行执行和交互式 TUI 两种使用方式。
当前版本为 `0.1.0`,默认使用 DeepSeek,也支持 OpenAI Chat Completions,
提供一次性命令行执行和交互式 TUI 两种使用方式。

## 工作区结构

```text
kuncode-cli ──▶ kuncode-agent ──▶ kuncode-core ──▶ DeepSeek API
kuncode-cli ──▶ kuncode-agent ──▶ kuncode-core ──▶ LLM API
│ │ │
│ │ └─ 消息、Completion、流式协议、Provider
│ └─ Agent Loop、工具、权限、会话、压缩与编排
└─ 参数、配置、审批、一次性输出与 TUI
```

- `kuncode-core`:Provider-neutral 的消息与 Completion 抽象,以及 DeepSeek Provider。
- `kuncode-core`:Provider-neutral 的消息与 Completion 抽象,以及 DeepSeek、OpenAI Provider。
- `kuncode-agent`:Agent 运行时、工具调度、权限、Hook、Todo、会话持久化和上下文压缩。
- `kuncode-cli`:命令行参数、项目配置、终端审批、普通输出和交互式 TUI。

## 环境要求

- Rust stable,项目使用 Rust 2024 edition。
- DeepSeek API Key。
- DeepSeek 或 OpenAI API Key。
- 支持 ANSI 终端;交互模式需要 stdin 和 stdout 都连接到真实终端。

## 快速开始
Expand All @@ -40,6 +41,32 @@ export DEEPSEEK_API_KEY="your-api-key"
DEEPSEEK_API_KEY=your-api-key
```

使用 OpenAI 官方接口时,在用户目录的 `~/.kuncode/providers.json` 配置:

```json
{
"defaultProfile": "openai",
"profiles": {
"openai": {
"provider": "openai",
"apiKeyEnv": "OPENAI_API_KEY",
"model": "your-openai-model",
"maxTokens": 16384
}
}
}
```

并设置对应环境变量:

```bash
export OPENAI_API_KEY="your-api-key"
```

兼容 OpenAI Chat Completions 的服务可以在 Profile 中增加 `baseUrl` 和
`headers`。`baseUrl` 支持服务根地址或完整 `/chat/completions` endpoint;
`apiKeyEnv` 为空时不发送 `Authorization`。

一次性执行任务:

```bash
Expand Down Expand Up @@ -72,6 +99,7 @@ cargo build --release -p kuncode-cli
"defaultMode": "default"
},
"model": {
"provider": "deepseek",
"name": "deepseek-v4-pro",
"maxTokens": 65536
},
Expand All @@ -90,8 +118,15 @@ cargo build --release -p kuncode-cli

补充说明:

- `DEEPSEEK_MODEL` 可以覆盖配置文件中的模型名称。
- `KUNCODE_MODEL` 可以覆盖配置文件中的模型名称;`DEEPSEEK_MODEL` 作为兼容别名保留。
- Provider 配置优先级为 CLI `--profile` / `--model` > 可信项目配置 >
用户 Profile > 内置 DeepSeek 默认值。
- 未使用 `--trust-project` 时,项目中的 `profile`、`provider`、`name`、
`baseUrl`、`apiKeyEnv`、`headers` 和 `maxTokens` 不会覆盖用户配置。
- `model.provider` 支持 `deepseek` 和 `openai`;自定义 endpoint 和 headers
仅适用于 `openai` 协议。
- 内置模型配置包括 `deepseek-v4-pro` 和 `deepseek-v4-flash`。
- 非内置模型启用上下文压缩时,需要显式设置 `compaction.contextLimit`。
- `compaction.mode` 支持 `disabled`、`shadow` 和 `enabled`,默认是 `disabled`。
- `shadow` 只计算和报告压缩候选,不替换当前上下文。
- `enabled` 会在达到预算阈值时执行压缩,并要求会话持久化状态保持健康。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ fn call_names(message: &Message) -> BTreeMap<String, String> {
if let Message::Assistant { content, .. } = message {
for call in content.iter().filter_map(|block| match block {
AssistantContent::ToolCall(call) => Some(call),
AssistantContent::Text(_) | AssistantContent::Reasoning(_) => None,
AssistantContent::Text(_)
| AssistantContent::Reasoning(_)
| AssistantContent::Refusal(_) => None,
}) {
names.insert(call.id.clone(), call.function.name.clone());
}
Expand Down
4 changes: 3 additions & 1 deletion crates/kuncode-agent/src/compaction/protocol/grouping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ pub fn group_messages(messages: &[Message]) -> Result<Vec<ProtocolGroup>, Protoc
.iter()
.filter_map(|block| match block {
AssistantContent::ToolCall(call) => Some(call),
AssistantContent::Text(_) | AssistantContent::Reasoning(_) => None,
AssistantContent::Text(_)
| AssistantContent::Reasoning(_)
| AssistantContent::Refusal(_) => None,
})
.collect::<Vec<_>>();
if calls.is_empty() {
Expand Down
1 change: 1 addition & 0 deletions crates/kuncode-agent/src/compaction/slimming/marker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ fn assistant_call<'a>(
AssistantContent::ToolCall(call) if call.id == result_id => Some(call),
AssistantContent::Text(_)
| AssistantContent::Reasoning(_)
| AssistantContent::Refusal(_)
| AssistantContent::ToolCall(_) => None,
})
}
Expand Down
4 changes: 4 additions & 0 deletions crates/kuncode-agent/src/runner/iteration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ where
StreamEvent::ReasoningDelta(text) => {
self.emit(session, Some(iteration), EventKind::ReasoningDelta { text });
}
StreamEvent::RefusalDelta(text) => {
self.emit(session, Some(iteration), EventKind::TextDelta { text });
}
// The "calling X" hint is surfaced by `ToolStart` after the turn
// completes and the call is gated; ignore the earlier signal.
StreamEvent::ToolCallStart { .. } => {}
Expand Down Expand Up @@ -188,6 +191,7 @@ fn stream_event_kind(event: &StreamEvent) -> &'static str {
match event {
StreamEvent::TextDelta(_) => "text_delta",
StreamEvent::ReasoningDelta(_) => "reasoning_delta",
StreamEvent::RefusalDelta(_) => "refusal_delta",
StreamEvent::ToolCallStart { .. } => "tool_call_start",
StreamEvent::Completed { .. } => "completed",
}
Expand Down
1 change: 1 addition & 0 deletions crates/kuncode-agent/src/runner/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ pub(super) fn assistant_text(content: &NonEmptyVec<AssistantContent>) -> String
.iter()
.filter_map(|content| match content {
AssistantContent::Text(text) => Some(text.text_ref()),
AssistantContent::Refusal(refusal) => Some(refusal.text_ref()),
_ => None,
})
.collect::<Vec<_>>()
Expand Down
3 changes: 3 additions & 0 deletions crates/kuncode-agent/src/session_store/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ enum StoredAssistantContent {
Text {
text: String,
},
Refusal {
text: String,
},
ToolCall {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
4 changes: 4 additions & 0 deletions crates/kuncode-agent/src/session_store/dto/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ impl StoredAssistantContent {
AssistantContent::Text(text) => Self::Text {
text: text.text_ref().to_string(),
},
AssistantContent::Refusal(refusal) => Self::Refusal {
text: refusal.text_ref().to_string(),
},
AssistantContent::ToolCall(call) => Self::ToolCall {
id: call.id.clone(),
call_id: call.call_id.clone(),
Expand All @@ -151,6 +154,7 @@ impl StoredAssistantContent {
fn into_content(self) -> Result<AssistantContent, SessionStoreError> {
match self {
Self::Text { text } => Ok(AssistantContent::Text(Text::from(text))),
Self::Refusal { text } => Ok(AssistantContent::refusal(text)),
Self::ToolCall {
id,
call_id,
Expand Down
2 changes: 2 additions & 0 deletions crates/kuncode-cli/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ fn settings_error_kind(error: &SettingsError) -> &'static str {
match error {
SettingsError::Read(_) => "settings_read",
SettingsError::Parse(_) => "settings_parse",
SettingsError::UserRead(_) => "provider_profiles_read",
SettingsError::UserParse(_) => "provider_profiles_parse",
SettingsError::Workspace(_) => "settings_workspace",
SettingsError::Rule(_, _) => "settings_rule",
SettingsError::Mode(_) => "settings_mode",
Expand Down
6 changes: 6 additions & 0 deletions crates/kuncode-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ pub(crate) struct Cli {
/// Trust this workspace's permission relaxations for the current process.
#[arg(long)]
pub(crate) trust_project: bool,
/// User-level provider profile selected for this run.
#[arg(long, value_name = "PROFILE")]
pub(crate) profile: Option<String>,
/// Model identifier overriding profile and trusted project defaults.
#[arg(long, value_name = "MODEL")]
pub(crate) model: Option<String>,
/// Prompt to run. Omit to start an interactive session.
#[arg(trailing_var_arg = true)]
prompt: Vec<String>,
Expand Down
69 changes: 56 additions & 13 deletions crates/kuncode-cli/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ use kuncode_agent::system_prompt::{
};
use kuncode_agent::workspace::Workspace;
use kuncode_core::completion::{CompletionModel, RetryModel, RetryPolicy};
use kuncode_core::providers::deepseek::{DeepSeekClient, DeepSeekCompletionModel};
use kuncode_core::providers::{
any_chat::{AnyChatClient, AnyChatCompletionModel},
deepseek::DeepSeekClient,
openai::OpenAiClient,
};

use crate::config::{PermissionFlags, resolve_permissions};
use crate::settings::{ProjectSettings, ProjectTrust, load_project_settings};
use crate::settings::{ProjectSettings, ProjectTrust, ProviderKind, load_project_settings};
use crate::{Cli, logging::LoggingObserver};

/// Identity and behavioral instructions rendered as the first system-prompt
Expand All @@ -46,7 +50,7 @@ Keep working until the task is done, then give a short, direct final answer.";
/// observer + approver, plus the bits a frontend renders directly
/// ([`model_name`](Self::model_name), [`mode`](Self::mode)). Generic over the
/// model so a test or a future provider can supply its own `M`; [`assemble`]
/// pins it to the CLI's [`DeepSeekCompletionModel`] wrapped in a
/// pins it to the configured [`AnyChatCompletionModel`] wrapped in a
/// [`RetryModel`] so transient provider failures are retried transparently.
///
/// [`assemble`]: Self::assemble
Expand All @@ -64,21 +68,21 @@ pub struct CliRuntime<M> {
persistence_error: Option<String>,
}

impl CliRuntime<RetryModel<DeepSeekCompletionModel>> {
impl CliRuntime<RetryModel<AnyChatCompletionModel>> {
/// Builds the runtime from parsed CLI args and the project settings file.
///
/// Resolves permissions from built-in ∪ project file ∪ CLI flags (mode
/// precedence CLI > project > Default), assembles the system prompt from its
/// identity/environment/tools sections, and wires the DeepSeek model + the
/// default workspace tool registry.
/// identity/environment/tools sections, and wires the configured model +
/// the default workspace tool registry.
///
/// # Errors
///
/// Fails if the current directory is not a usable workspace, the project
/// settings or resolved permissions are invalid, active compaction cannot
/// be bound to the selected model, or the DeepSeek client cannot be built
/// from the environment. Failure to open the optional session store is
/// retained as degraded persistence state rather than failing assembly.
/// be bound to the selected model, or the provider client cannot be built
/// from its fixed credential environment. Failure to open the optional session
/// store is retained as degraded persistence state rather than failing assembly.
pub async fn assemble(cli: &Cli) -> Result<Self, Box<dyn std::error::Error>> {
let workspace = Workspace::from_current_dir().await?;
tracing::debug!(
Expand All @@ -94,9 +98,15 @@ impl CliRuntime<RetryModel<DeepSeekCompletionModel>> {
} else {
ProjectTrust::Untrusted
};
let project = load_project_settings(workspace.root(), project_trust)?;
let project = load_project_settings(
workspace.root(),
project_trust,
cli.profile.as_deref(),
cli.model.as_deref(),
)?;
let model_name = project.model_name.clone();
let config = agent_config(&project)?;
let client = provider_client(&project)?;
let flags = PermissionFlags {
allow: &cli.allow,
ask: &cli.ask,
Expand Down Expand Up @@ -160,11 +170,10 @@ impl CliRuntime<RetryModel<DeepSeekCompletionModel>> {
(None, Some("home directory unavailable".to_string()))
}
};
let client = DeepSeekClient::from_env()?;
// Normal turns inherit the default retry budget. Semantic summaries use
// a separate one-retry wrapper so their fallback latency is bounded
// independently of ordinary model calls.
let provider = DeepSeekCompletionModel::make(&client, model_name.clone());
let provider = AnyChatCompletionModel::make(&client, model_name.clone());
let model = RetryModel::with_policy(provider.clone(), RetryPolicy::default());
let summary_model = RetryModel::with_policy(provider, summary_retry_policy());
let registry = ToolRegistry::with_default_workspace_tools(workspace)?;
Expand All @@ -185,6 +194,40 @@ impl CliRuntime<RetryModel<DeepSeekCompletionModel>> {
}
}

fn provider_client(project: &ProjectSettings) -> Result<AnyChatClient, Box<dyn std::error::Error>> {
let api_key = if project.api_key_env.is_empty() {
String::new()
} else {
std::env::var(&project.api_key_env).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"provider API key environment variable `{}` is unavailable: {error}",
project.api_key_env
),
)
})?
};
match project.provider {
ProviderKind::DeepSeek => Ok(AnyChatClient::DeepSeek(DeepSeekClient::new(api_key)?)),
ProviderKind::OpenAi => match project.base_url.as_deref() {
Some(base_url) => Ok(AnyChatClient::OpenAi(OpenAiClient::with_endpoint(
api_key,
base_url,
project.headers.clone(),
)?)),
None if project.headers.is_empty() => {
Ok(AnyChatClient::OpenAi(OpenAiClient::new(api_key)?))
}
None => Ok(AnyChatClient::OpenAi(OpenAiClient::with_endpoint(
api_key,
"https://api.openai.com/v1",
project.headers.clone(),
)?)),
},
}
}

fn agent_config(project: &ProjectSettings) -> Result<AgentConfig, AgentCompactionConfigError> {
let compaction = project
.compaction
Expand Down Expand Up @@ -301,7 +344,7 @@ mod tests {
)
.expect("write settings");
let settings =
load_project_settings_from(&dir, None, ProjectTrust::Untrusted).expect("load settings");
load_project_settings_from(&dir, None, ProjectTrust::Trusted).expect("load settings");
let _ = fs::remove_dir_all(&dir);
settings
}
Expand Down
Loading