Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.
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 Cargo.lock

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

2 changes: 1 addition & 1 deletion llm-lib/crates/llm-lib-napi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "llm-lib-napi"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
description = "napi-rs адаптер llm-lib для @7n/llm-lib (тонкий JS-клієнт замість повторної реалізації ACP/tiers/local_cloud)"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion llm-lib/crates/llm-lib/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "llm-lib"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
description = "Каскадний доступ до LLM: локальні тири, хмарні тири, ACP-агенти з особистою підпискою (Codex, Cursor) — Rust-аналог env-контракту @7n/llm-lib"
license = "Apache-2.0"
Expand Down
28 changes: 3 additions & 25 deletions llm-lib/crates/llm-lib/src/acp/docs/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,13 @@ type: Rust Module
title: session.rs
resource: llm-lib/crates/llm-lib/src/acp/session.rs
docgen:
crc: f39ad2ed
crc: 32c76196
model: openai-codex/gpt-5.4-mini
tier: cloud-min
score: 0
issues: refusal-filler,best-of-2:retry-lost
score: 55
issues: no-overview,short-behavior,best-of-2:retry-lost
---

## Огляд

Публічний session-API крейта для довгоживучої ACP-сесії з відкритим потоком подій, де `SessionOptions` через `create_session` запускає фонову `tokio`-задачу з власним ACP-з’єднанням і mpsc-циклом команд, а `create_session` не завершується, доки не пройдуть `initialize` → `session/new` → опційний `session/set_config_option`; це прибирає гонку на першому `SessionHandle::prompt` і робить сесію готовою до роботи одразу після створення. Контракт дає `prompt` і `cancel` без Tauri-залежностей, а `super::one_shot_acp` лишається окремим тонким фасадом над тим самим `super::transport`-шаром: один prompt, auto-approve і акумуляція тексту без зміни поведінки транспорту. Permission-поведінка побудована як двомодова робота одного механізму через той самий канал подій: `PermissionMode::External` пересилає `session/request_permission` як `SessionEvent::PermissionRequest`, а викликач відповідає через `PermissionRequestEvent::respond` або `PermissionRequestEvent::cancel`; `PermissionMode::AutoApprove` використовує той самий потік подій, де `drive_auto_approve` одразу обирає відповідь через `transport::pick_auto_permission_option`.

## Поведінка

PostSessionConfig і new задають один післястартовий крок конфігурації: після `session/new` у сесію передається готова пара `configId`/`value`, щоб агент стартував уже з потрібною моделлю без втручання в spawn-параметри.

PermissionMode визначає, чи запити дозволу йдуть назовні через події, чи автоматично закриваються всередині фонової сесії; це два режими одного каналу рішення, а не два різні протоколи.

SessionOptions збирає стартові умови для create_session і задає базову поведінку сесії, зокрема режим дозволів за замовчуванням.

create_session відкриває ACP-сесію, завершує handshake перед поверненням і тримає з’єднання живим у фоні, доки існує хоча б одна SessionHandle. Потік подій і команд розділений: prompt рухає хід, cancel просить його зупинити, а SessionEvent несе назовні все, що стається всередині сесії.

SessionEvent є спільним потоком сповіщень для зовнішнього коду: через нього приходять оновлення ходу та запити дозволу, які потребують окремої реакції.

PermissionRequestEvent переносить зовнішньому коду сам запит і варіанти реакції; respond і cancel завершують цей цикл, повертаючи вибір назад у сесію або відхиляючи запит.

SessionHandle — це жива ручка до вже відкритої сесії. prompt запускає новий хід і чекає лише на його термінальний результат, тоді як увесь проміжний вміст продовжує текти через SessionEvent. cancel не чекає підтвердження й лише просить агент зупинити поточний хід; обидві операції залежать від того, що фонова задача сесії ще жива.

drive_auto_approve читає той самий потік PermissionRequestEvent і одразу закриває кожен запит автоматично, не втручаючись у потік SessionEvent.Update. Це зручний шар для стратегій, які хочуть зберегти зовнішній контроль, але без ручного вибору кожного дозволу.

## Публічний API

- PostSessionConfig — Опційний post-`session/new`-крок конфігурації (рішення З.1, потрібен Pi-тіру): один `session/set_config_option` **між** `session/new` і першим `session/prompt` — не env/args на спавні, як у Cursor/Codex. `configId: "model"`, `value: "provider/modelId"` (напр. `"openai-codex/gpt-5.6-terra"`) — точні значення несе тір-пресет (T3), цей тип лише виконує вже готову пару.
Expand Down
109 changes: 102 additions & 7 deletions llm-lib/crates/llm-lib/src/acp/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ use std::collections::HashMap;
use std::path::Path;

use agent_client_protocol::schema::v1::{
ClientCapabilities, InitializeRequest, McpServer, NewSessionRequest, PermissionOption,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SelectedPermissionOutcome, SessionConfigId, SessionUpdate, SetSessionConfigOptionRequest,
StopReason,
ClientCapabilities, ContentBlock, ContentChunk, InitializeRequest, McpServer,
NewSessionRequest, PermissionOption, RequestPermissionOutcome, RequestPermissionRequest,
RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigId, SessionUpdate,
SetSessionConfigOptionRequest, StopReason,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{AcpAgent, Client, Responder};
Expand Down Expand Up @@ -328,15 +328,22 @@ pub async fn create_session(
}

let session_id = session.session_id().clone();
let mut startup_noise = unsolicited_startup_text(session.meta().as_ref());
if let Some(ready_tx) = ready_tx.take() {
let _ = ready_tx.send(Ok(()));
}

while let Some(command) = command_rx.recv().await {
match command {
SessionCommand::Prompt { text, reply } => {
let outcome =
run_prompt_turn(&mut session, idle_timeout, text, &event_tx).await;
let outcome = run_prompt_turn(
&mut session,
idle_timeout,
text,
&event_tx,
&mut startup_noise,
)
.await;
let _ = reply.send(outcome);
}
SessionCommand::Cancel => {
Expand Down Expand Up @@ -375,12 +382,15 @@ pub async fn create_session(
/// Один prompt-хід: надсилає `text`, пересилає кожен `SessionUpdate` у
/// канал подій через [`transport::drive_turn`] (idle-timeout + progress-
/// логування, та сама операційна броня, що й у [`super::one_shot_acp`]),
/// повертає фінальний `StopReason`.
/// повертає фінальний `StopReason`. `startup_noise` — задекларований агентом
/// startup-текст ([`unsolicited_startup_text`]); ідентичний йому чанк не
/// потрапляє в канал подій ([`is_startup_noise`]).
async fn run_prompt_turn<S>(
session: &mut S,
idle_timeout: std::time::Duration,
text: String,
event_tx: &mpsc::UnboundedSender<SessionEvent>,
startup_noise: &mut Option<String>,
) -> Result<StopReason, LlmError>
where
S: AcpSessionUpdates + SendsPrompt,
Expand All @@ -390,12 +400,53 @@ where
.map_err(|e| LlmError::Provider(e.to_string()))?;

transport::drive_turn(session, idle_timeout, |update| {
if is_startup_noise(startup_noise, update) {
return;
}
let _ = event_tx.send(SessionEvent::Update(Box::new(update.clone())));
})
.await
.map_err(|e| LlmError::Provider(e.to_string()))
}

/// Startup-текст, який агент задекларував у `_meta` відповіді `session/new`
/// (нині лише pi: `pi-acp` кладе банер версії + update-нотіс у
/// `_meta.piAcp.startupInfo` **і** дублює той самий текст першим
/// `agent_message_chunk` нової сесії — задумано під Zed, який рендерить його
/// окремим привітанням). Для headless/чат-споживачів крейта це шум, що
/// приклеюється до першої відповіді, тож задекларований текст фільтрується
/// з потоку подій точним збігом — без евристик і незалежно від
/// per-машинних настройок pi (`quietStartup`).
fn unsolicited_startup_text(
meta: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<String> {
let text = meta?.get("piAcp")?.get("startupInfo")?.as_str()?;
(!text.is_empty()).then(|| text.to_string())
}

/// `true`, якщо `update` — той самий startup-чанк, що його агент задекларував
/// у `_meta` ([`unsolicited_startup_text`]): текстовий `AgentMessageChunk`,
/// ідентичний очікуваному. Спрацьовує щонайбільше раз — після збігу
/// `startup_noise` скидається, і подальші (навіть ідентичні) чанки проходять
/// як звичайний контент.
fn is_startup_noise(startup_noise: &mut Option<String>, update: &SessionUpdate) -> bool {
let Some(expected) = startup_noise.as_deref() else {
return false;
};
let SessionUpdate::AgentMessageChunk(ContentChunk {
content: ContentBlock::Text(text),
..
}) = update
else {
return false;
};
if text.text != expected {
return false;
}
*startup_noise = None;
true
}

/// Мінімальний зріз `ActiveSession::send_prompt`, потрібний
/// [`run_prompt_turn`] — узагальнено разом з [`AcpSessionUpdates`], щоб
/// уникнути повного generic-підпису `ActiveSession<'_, Link>`.
Expand Down Expand Up @@ -501,6 +552,7 @@ mod tests {
std::time::Duration::from_millis(50),
"привіт".to_string(),
&event_tx,
&mut None,
),
)
.await;
Expand Down Expand Up @@ -529,4 +581,47 @@ mod tests {
assert_eq!(config.config_id, "model");
assert_eq!(config.value, "openai-codex/gpt-5.6-terra");
}

fn text_chunk(text: &str) -> SessionUpdate {
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
agent_client_protocol::schema::v1::TextContent::new(text),
)))
}

/// Той самий шлях, що й `session/new` від `pi-acp`: startup-текст лежить
/// у `_meta.piAcp.startupInfo`.
#[test]
fn unsolicited_startup_text_reads_pi_acp_meta() {
let meta = serde_json::json!({ "piAcp": { "startupInfo": "pi v0.80.10\n---" } });
let meta = meta.as_object().cloned();
assert_eq!(
unsolicited_startup_text(meta.as_ref()),
Some("pi v0.80.10\n---".to_string())
);

assert_eq!(unsolicited_startup_text(None), None);
let empty = serde_json::json!({ "piAcp": { "startupInfo": "" } });
assert_eq!(unsolicited_startup_text(empty.as_object()), None);
let other = serde_json::json!({ "somethingElse": true });
assert_eq!(unsolicited_startup_text(other.as_object()), None);
}

/// Фільтр гасить рівно один ідентичний чанк: інший текст проходить,
/// а повторний ідентичний після збігу — теж (це вже легітимний контент).
#[test]
fn is_startup_noise_drops_exactly_one_matching_chunk() {
let mut noise = Some("pi v0.80.10".to_string());

assert!(!is_startup_noise(&mut noise, &text_chunk("відповідь")));
assert!(noise.is_some(), "чанк без збігу не скидає фільтр");

assert!(is_startup_noise(&mut noise, &text_chunk("pi v0.80.10")));
assert!(noise.is_none(), "після збігу фільтр вимикається");

assert!(
!is_startup_noise(&mut noise, &text_chunk("pi v0.80.10")),
"повторний ідентичний текст — легітимний контент"
);
assert!(!is_startup_noise(&mut None, &text_chunk("будь-що")));
}
}
Loading