Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ kit init && kit auth login openai && kit tui

## Why Kit

Most agent harnesses expose many tools and require one model round trip for each call. Kit exposes one tool: `compose`. The `compose` argument is a short program in [Runlet](https://github.com/danielkov/runlet). In one round trip, the program can read files, run tests, apply edits, retry failed commands, delegate work to subagents, and return structured data.
Most agent harnesses expose many tools and require one model round trip for each call. Kit exposes `compose` for tool orchestration, plus `ask_user` for interactive decisions when the connected client supports forms. The `compose` argument is a short program in [Runlet](https://github.com/danielkov/runlet). In one round trip, the program can read files, run tests, apply edits, retry failed commands, delegate work to subagents, and return structured data.

Fewer model round trips repeat less context and complete more work in each request. In size-matched production tasks, Kit used approximately half the input tokens and active time per hand-written line of Codex CLI or Claude Code ([comparison](#how-it-compares)).

- **One tool, unlimited composition.** The model receives one tool: `compose`. A `compose` program can call `shell`, `edit`, `subagent`, `prompt`, `fork`, `tool_search`, `tool_schema`, `tool`, `auth`, `skill`, `a2a`, and `docs`. Independent calls run concurrently. Use data dependencies or `after` blocks to control execution order. Use `boundary retry N` and `fail()` to handle errors in the program.
- **One tool for orchestration, unlimited composition.** The model uses `compose` to orchestrate work. A `compose` program can call `shell`, `edit`, `subagent`, `prompt`, `fork`, `tool_search`, `tool_schema`, `tool`, `auth`, `skill`, `a2a`, and `docs`. Independent calls run concurrently. Use data dependencies or `after` blocks to control execution order. Use `boundary retry N` and `fail()` to handle errors in the program.
- **Reusable subagents.** A subagent is a reusable value. You can continue, fork, inspect, and close a subagent. You can require JSON that matches a schema. You can also use Claude Code, Codex, Cursor, or Kit as the subagent harness over ACP.
- **Open protocols.** Kit supports ACP v1 and v2 over stdio, HTTP/SSE, and WebSocket. Kit supports A2A v1 in both directions. It also supports MCP, Agent Skills, and Agent Plugin packages.
- **Long sessions.** Kit synchronizes each append-only JSONL transcript item to disk before it accepts the item. It compacts context automatically at 80% of the context window. You can resume a session from `tui`, `prompt`, or any ACP client. Kit also uses crash-safe locks. It retries eligible `openai-subscription` failures for up to 24 hours.
Expand Down
10 changes: 9 additions & 1 deletion docs/user/compose-and-local-tools.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Compose, Runlet, and Local Tools

Kit exposes one model-visible tool, `compose`. A compose call contains a Runlet program that can invoke Kit's hidden tools, including `shell`, `edit`, `a2a`, subagents, Agent Skills, bundled documentation, and MCP meta-tools. Users normally ask Kit to do work through an installed-binary command such as `kit tui --root /path/to/project` or `kit prompt --root /path/to/project "Run the smallest relevant test"`; the agent writes the Runlet program. Use `kit --help` and `kit <command> --help` for the exhaustive CLI reference.
Kit exposes `compose` for tool orchestration and, when the connected client supports forms, `ask_user` for interactive decisions. A compose call contains a Runlet program that can invoke Kit's hidden tools, including `shell`, `edit`, `a2a`, subagents, Agent Skills, bundled documentation, and MCP meta-tools. Users normally ask Kit to do work through an installed-binary command such as `kit tui --root /path/to/project` or `kit prompt --root /path/to/project "Run the smallest relevant test"`; the agent writes the Runlet program. Use `kit --help` and `kit <command> --help` for the exhaustive CLI reference.

## Ask for a user decision

When an ACP v2 client advertises form elicitation and `ask_user` is enabled (the default), Kit also exposes `ask_user` as a standalone tool. It is not a Runlet function and cannot run in the background. Use it for decisions that need explicit user input, not credentials or secrets. To disable this tool, see [Disable native user questions](getting-started-and-configuration.md#disable-native-user-questions).

A request contains a `message` and 1–8 `questions`. Each question has a stable `id`, `title`, `prompt`, and 1–32 `options` with stable IDs and display labels. Set `multiple: true` for multiple selections and `allow_custom: true` to offer a separate text field. Both flags default to false. The complete form request is limited to 128 KiB; shorten descriptions or split the batch if the tool reports that the form is too large.

Accepted answers are keyed by question ID, with selected option IDs in `selected` and optional text in `custom`. A declined or dismissed request returns `action: "decline"` or `action: "cancel"` without answers; do not treat either as a selection. The bundled TUI supports [paged forms and answer review](tui-and-sessions.md#answer-agent-questions). Clients without form support do not receive this tool; use ordinary chat questions instead.

## How a compose Runlet works

Expand Down
21 changes: 21 additions & 0 deletions docs/user/getting-started-and-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,27 @@ If the config file is missing, `set` creates it, `get` reports an error, and `un

These commands only inspect or edit the local configuration: they do not run migrations, authenticate, or access the network. They do not open an interactive dialog. Use standard help such as `kit config --help` or `kit config set --help` for command syntax. Runtime commands still validate the settings they consume.

### Disable native user questions

To prevent Kit's native agent from exposing the `ask_user` tool, set the top-level
`ask_user` boolean to `false`:

```sh
kit config set ask_user false
```

This writes `ask_user = false` in `~/.kit/config.toml`. Restart the running Kit
process to apply the setting; configuration is read at startup. The default is
`true`, but the tool still requires an ACP v2 client that supports form elicitation.
This setting does not disable generic form display or change the TUI's advertised
form capability.

To restore the default, remove the setting and restart Kit:

```sh
kit config unset ask_user
```

### Configuration example

A representative configuration is:
Expand Down
15 changes: 15 additions & 0 deletions docs/user/tui-and-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,21 @@ Press `Command+B` to detach the newest running foreground top-level compose call

At an idle, non-empty editor, `Ctrl+C` clears the prompt instead of unexpectedly discarding it and quitting in one step; press it again with the empty editor to quit.

## Answer agent questions

When an agent needs a decision, Kit can show a form with single-choice or multiple-choice options and optional custom-answer fields. The form appears in a bounded panel below the conversation, keeping the surrounding context visible. Answers stay local to the form until you submit the review page.

- Use `Up` / `Down` to highlight an option and `Space` to select it. For multiple-choice fields, `Space` toggles each option independently.
- Text and numeric fields use the composer editor: move the cursor with arrow keys, use `Alt+Left` / `Alt+Right` to move by word, and `Alt+Backspace` or `Ctrl+W` to delete the previous word. `Ctrl+U` / `Ctrl+K` delete to the start / end of the line. `Shift+Enter` inserts a newline; `Ctrl+V` / `Shift+Insert` and bracketed paste insert text at the cursor. Clipboard images are not accepted in form fields. Use `Left` / `Right` to choose false / true for a boolean field, or `Space` to toggle it.
- When a question offers **Other**, its text input appears below the choices on the same page. Press `Tab` to move from the choices to Other, then to the next question. You can keep selected options and an Other answer together, or enter only an Other answer.
- Press `Tab` to move forward through inputs and questions to review, even when an answer is blank. `Shift+Tab` moves backwards through the same inputs: from Other to choices, or from choices to the previous question’s last input. Neither key validates answers or submits the form; drafts are preserved. Unrelated form fields remain on separate pages.
- Press `Enter` to validate the current question and advance. A required choice question must have a selection or non-blank Other text.
- On the review page, press `Enter` to validate and submit all answers together, or `Shift+Tab` to revise them. Skipped required answers return you to the input that needs attention. `Tab` on review stays there without submitting.
- Press `Delete` to clear a choice or boolean field; in text fields it deletes forward from the cursor. Use `PageUp` / `PageDown` to scroll the question or review.
- Press `Esc` to dismiss the request, or `Ctrl+D` to explicitly decline. Neither action submits selections.

Do not enter passwords, API keys, or other credentials in these forms. Kit supports session-scoped ACP form elicitation, not URL elicitation. Unsupported form constraints are rejected rather than silently ignored. If your ACP client does not support forms, ask and answer in ordinary chat instead.

## Read the transcript

The transcript keeps a three-column gutter: `›` marks your prompts, `⋮` reasoning, a spinner, `✓`, or `✗` each compose program, `⇅` a compaction, and `↩` a background result landing after its turn ended. Prose has an empty gutter, so the left edge alone shows the turn's shape.
Expand Down
72 changes: 72 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1313,8 +1313,17 @@ const CONFIG_MIGRATIONS: &[ConfigMigration] = &[
ConfigMigration {
apply: migrate_compaction_policy,
},
ConfigMigration {
apply: migrate_ask_user,
},
];

fn migrate_ask_user(config: &mut toml::Table) {
config
.entry("ask_user")
.or_insert(toml::Value::Boolean(true));
}

// Additive policy: preserve explicit values and malformed shapes for validation.
// This migration only changes the in-memory document, never the user's file.
fn migrate_compaction_policy(config: &mut toml::Table) {
Expand Down Expand Up @@ -1364,6 +1373,7 @@ fn migrate_config(mut config: toml::Table) -> toml::Table {

#[derive(Debug, Default, Deserialize)]
struct Config {
ask_user: Option<bool>,
#[serde(default)]
compaction: kit::compaction::policy::CompactionConfig,
#[serde(default, deserialize_with = "deserialize_experimental_config")]
Expand Down Expand Up @@ -2297,6 +2307,7 @@ async fn run_cli(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
};
let runtime = kit::Runtime::with_compaction(runtime, config.compaction)?;
let runtime = kit::Runtime::with_eval(runtime, config.experimental.eval)?;
let runtime = kit::Runtime::with_ask_user(runtime, config.ask_user.unwrap_or(true))?;
let runtime = kit::Runtime::with_hooks(runtime)?;
let runtime = kit::Runtime::with_plugin_runtime(runtime, plugins)?;
let runtime = kit::Runtime::with_telemetry(runtime, telemetry_settings.clone())?;
Expand Down Expand Up @@ -2397,6 +2408,7 @@ async fn run_cli(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
};
let runtime = kit::Runtime::with_compaction(runtime, config.compaction)?;
let runtime = kit::Runtime::with_eval(runtime, config.experimental.eval)?;
let runtime = kit::Runtime::with_ask_user(runtime, config.ask_user.unwrap_or(true))?;
let runtime = kit::Runtime::with_hooks(runtime)?;
let runtime = kit::Runtime::with_plugin_runtime(runtime, plugins)?;
let runtime = kit::Runtime::with_telemetry(runtime, telemetry_settings.clone())?;
Expand Down Expand Up @@ -2472,6 +2484,7 @@ async fn run_cli(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
)?;
let runtime = kit::Runtime::with_compaction(runtime, config.compaction)?;
let runtime = kit::Runtime::with_eval(runtime, config.experimental.eval)?;
let runtime = kit::Runtime::with_ask_user(runtime, config.ask_user.unwrap_or(true))?;
let runtime = kit::Runtime::with_hooks(runtime)?;
let runtime = kit::Runtime::with_plugin_runtime(runtime, plugins)?;
let runtime = kit::Runtime::with_telemetry(runtime, telemetry_settings.clone())?;
Expand Down Expand Up @@ -2936,6 +2949,65 @@ mod tests {
}
}

#[test]
fn config_ask_user_defaults_and_strict_boolean() {
assert!(Config::default().ask_user.unwrap_or(true));
for (text, expected) in [
("", true),
("ask_user = true", true),
("ask_user = false", false),
] {
let config: Config = toml::from_str(text).unwrap();
assert_eq!(config.ask_user.unwrap_or(true), expected);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
fs::write(&path, text).unwrap();
assert_eq!(
Config::load(&path).unwrap().ask_user.unwrap_or(true),
expected
);
assert_eq!(fs::read_to_string(&path).unwrap(), text);
let migrated = super::migrate_config(toml::from_str(text).unwrap());
assert_eq!(migrated["ask_user"].as_bool(), Some(expected));
assert_eq!(super::migrate_config(migrated.clone()), migrated);
}
for value in ["'true'", "1", "0.0", "[]", "{}"] {
let text = format!("ask_user = {value}");
assert!(toml::from_str::<Config>(&text).is_err(), "{text}");
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
fs::write(&path, &text).unwrap();
assert!(Config::load(&path).is_err(), "{text}");
assert_eq!(fs::read_to_string(&path).unwrap(), text);
}
}

#[test]
fn config_ask_user_writer_preserves_settings() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
fs::write(
&path,
"# preserved\nask_user = false # opt out\nmodel = 'old'\nfuture = 'keep'\n",
)
.unwrap();
kit::config_editor::set(&path, "model", "'new'").unwrap();
let updated = fs::read_to_string(&path).unwrap();
assert!(updated.contains("ask_user = false # opt out"));
assert!(updated.starts_with("# preserved\n"));
assert!(!Config::load(&path).unwrap().ask_user.unwrap_or(true));
kit::config_editor::set(&path, "ask_user", "true").unwrap();
assert!(Config::load(&path).unwrap().ask_user.unwrap_or(true));
kit::config_editor::set(&path, "ask_user", "false").unwrap();
assert!(!Config::load(&path).unwrap().ask_user.unwrap_or(true));
kit::config_editor::unset(&path, "ask_user").unwrap();
assert!(Config::load(&path).unwrap().ask_user.unwrap_or(true));
let updated = fs::read_to_string(&path).unwrap();
assert!(!updated.contains("ask_user"));
assert!(updated.contains("future = 'keep'"));
assert!(updated.contains("model = 'new'"));
}

#[cfg(feature = "tui")]
#[test]
fn config_experimental_voice_defaults_and_strict_boolean() {
Expand Down
1 change: 1 addition & 0 deletions src/protocols/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1810,6 +1810,7 @@ impl Server {
)
.with_hook_messages(self.runtime.hooks());
let context = AcpDriverContext {
ask_user: None,
cwd,
additional_directories,
integration: Arc::new(observer),
Expand Down
48 changes: 48 additions & 0 deletions src/protocols/acp/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,8 @@ struct PendingSessionPublication {
}

struct Server {
// Connection-local negotiated snapshot; only initialize writes it.
form_elicitation: AtomicBool,
runtime: Arc<Runtime>,
integration: Arc<AcpIntegration>,
registry: SessionRegistry,
Expand All @@ -754,6 +756,7 @@ impl Server {

fn new(runtime: Arc<Runtime>, registry: SessionRegistry) -> Self {
Self {
form_elicitation: AtomicBool::new(false),
runtime,
integration: Arc::new(AcpIntegration::default()),
registry,
Expand Down Expand Up @@ -870,6 +873,14 @@ impl Server {
&self,
request: wire::InitializeRequest,
) -> Result<wire::InitializeResponse, AcpRuntimeError> {
self.form_elicitation.store(
request
.capabilities
.elicitation
.as_ref()
.is_some_and(|value| value.form.is_some()),
Ordering::Release,
);
// This handler only speaks v2. Initialization must return our supported
// version even when the requested version is unsupported; the client
// then disconnects if it cannot speak v2. It does not switch wire schemas.
Expand Down Expand Up @@ -1065,6 +1076,9 @@ impl Server {
.subscribe(session_id.to_string())
.map_err(AcpRuntimeError::Loop)?;
let cancellation = CancellationController::new();
let ask_user = self.form_elicitation.load(Ordering::Acquire).then(|| {
crate::tools::ask_user::AskUserTool::new(connection.clone(), session_id.clone())
});
let sink = ResponseReplacementSink::new(ConnectionSink(connection));
let activity = native_activity(session_id.clone(), sink.clone());
let binding =
Expand All @@ -1083,6 +1097,7 @@ impl Server {
)
.with_hook_messages(self.runtime.hooks());
let context = AcpDriverContext {
ask_user,
cwd,
additional_directories,
integration: Arc::new(observer),
Expand Down Expand Up @@ -6706,6 +6721,39 @@ mod tests {
.initialize(terminal_auth_initialize_request())
.unwrap();

assert!(!server.form_elicitation.load(Ordering::Acquire));
for (capabilities, expected) in [
(
wire::ClientCapabilities::new().elicitation(
wire::ElicitationCapabilities::new()
.form(wire::ElicitationFormCapabilities::new()),
),
true,
),
(
wire::ClientCapabilities::new().elicitation(wire::ElicitationCapabilities::new()),
false,
),
(
wire::ClientCapabilities::new().elicitation(
wire::ElicitationCapabilities::new()
.url(wire::ElicitationUrlCapabilities::new()),
),
false,
),
(wire::ClientCapabilities::new(), false),
] {
server
.initialize(
wire::InitializeRequest::new(
wire::ProtocolVersion::V2,
wire::Implementation::new("questions-client", "0"),
)
.capabilities(capabilities),
)
.unwrap();
assert_eq!(server.form_elicitation.load(Ordering::Acquire), expected);
}
assert_eq!(response.protocol_version, wire::ProtocolVersion::V2);
assert_eq!(response.auth_methods.len(), 4);
assert!(matches!(
Expand Down
Loading
Loading