From 836b3e5f6b804fa743ecbe705cb715c2ca8a6067 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Fri, 24 Jul 2026 22:13:36 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(llm-lib):=20v0.2.2=20=E2=80=94=20startu?= =?UTF-8?q?p-=D1=88=D1=83=D0=BC=20pi-acp=20=D0=BD=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BF=D0=BB=D1=8F=D1=94=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D1=96=D0=BA=20=D0=BF=D0=BE=D0=B4=D1=96=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=96=D1=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi-acp після session/new шле банер версії + update-нотіс окремим agent_message_chunk (задумано під Zed) і дублює той самий текст у _meta.piAcp.startupInfo. Для headless/чат-споживачів це шум, що приклеюється до першої відповіді (виявлено ручним прогоном task після T9). Session-шар тепер читає задекларований startup-текст з ActiveSession::meta() і гасить рівно один ідентичний чанк — точний збіг, без евристик, незалежно від per-машинних настройок pi (quietStartup). Покриває і session-API (плагін), і one_shot_acp. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 4 +- llm-lib/crates/llm-lib-napi/Cargo.toml | 2 +- llm-lib/crates/llm-lib/Cargo.toml | 2 +- .../crates/llm-lib/src/acp/docs/session.md | 28 +---- llm-lib/crates/llm-lib/src/acp/session.rs | 109 ++++++++++++++++-- 5 files changed, 109 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87cc7fe42..d0a2739a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,7 +968,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-lib" -version = "0.2.1" +version = "0.2.2" dependencies = [ "agent-client-protocol", "genai", @@ -980,7 +980,7 @@ dependencies = [ [[package]] name = "llm-lib-napi" -version = "0.2.1" +version = "0.2.2" dependencies = [ "llm-lib", "napi", diff --git a/llm-lib/crates/llm-lib-napi/Cargo.toml b/llm-lib/crates/llm-lib-napi/Cargo.toml index a76bece85..4a6518aff 100644 --- a/llm-lib/crates/llm-lib-napi/Cargo.toml +++ b/llm-lib/crates/llm-lib-napi/Cargo.toml @@ -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" diff --git a/llm-lib/crates/llm-lib/Cargo.toml b/llm-lib/crates/llm-lib/Cargo.toml index 293c29b20..9923cd270 100644 --- a/llm-lib/crates/llm-lib/Cargo.toml +++ b/llm-lib/crates/llm-lib/Cargo.toml @@ -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" diff --git a/llm-lib/crates/llm-lib/src/acp/docs/session.md b/llm-lib/crates/llm-lib/src/acp/docs/session.md index 53d22cc92..2b5ae8459 100644 --- a/llm-lib/crates/llm-lib/src/acp/docs/session.md +++ b/llm-lib/crates/llm-lib/src/acp/docs/session.md @@ -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), цей тип лише виконує вже готову пару. diff --git a/llm-lib/crates/llm-lib/src/acp/session.rs b/llm-lib/crates/llm-lib/src/acp/session.rs index 423684130..368486c04 100644 --- a/llm-lib/crates/llm-lib/src/acp/session.rs +++ b/llm-lib/crates/llm-lib/src/acp/session.rs @@ -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}; @@ -328,6 +328,7 @@ 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(())); } @@ -335,8 +336,14 @@ pub async fn create_session( 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 => { @@ -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( session: &mut S, idle_timeout: std::time::Duration, text: String, event_tx: &mpsc::UnboundedSender, + startup_noise: &mut Option, ) -> Result where S: AcpSessionUpdates + SendsPrompt, @@ -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>, +) -> Option { + 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, 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>`. @@ -501,6 +552,7 @@ mod tests { std::time::Duration::from_millis(50), "привіт".to_string(), &event_tx, + &mut None, ), ) .await; @@ -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("будь-що"))); + } } From 602de290c9812547d874e057eeaf747bb35fbcec Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Sat, 25 Jul 2026 07:57:51 +0300 Subject: [PATCH 2/2] spell --- .cspell.json | 95 ++++++++++++++++------------------------------------ 1 file changed, 28 insertions(+), 67 deletions(-) diff --git a/.cspell.json b/.cspell.json index 166ae5907..bd27a5c08 100644 --- a/.cspell.json +++ b/.cspell.json @@ -55,8 +55,8 @@ "checkpointing", "CHECKPOINTING", "citypaul", - "CLAUDEAI", "claudeai", + "CLAUDEAI", "CLIS", "Cnxn", "codepaths", @@ -76,6 +76,7 @@ "EXDEV", "exfiltration", "fixability", + "FNDA", "fqcn", "FQCN", "genai", @@ -113,6 +114,8 @@ "mpsc", "multibar", "Mutahunter", + "mutmut", + "Mutmut", "myllm", "myorg", "myrepo", @@ -148,8 +151,8 @@ "subschema", "tavily", "thiserror", - "Threadsafe", "threadsafe", + "Threadsafe", "tostring", "tpath", "truecolor", @@ -173,6 +176,7 @@ "автогенерація", "автогенерованих", "автогенерується", + "Автодетектовані", "автозастосування", "автозастосуванням", "автозастосунок", @@ -193,12 +197,12 @@ "автофіксі", "автофіксовна", "автофіксується", - "автофіксі", "аналогі", "анкоринг", "анкорить", "апляє", "апстрімом", + "асерти", "ассети", "багаторядково", "бакетів", @@ -231,6 +235,7 @@ "валідаційні", "валідаційного", "валідований", + "вантажує", "вендорені", "вендоримо", "вендорингу", @@ -271,8 +276,10 @@ "деterмінований", "дебаг", "дедиковані", + "дедуплікований", "дедуплікувати", "дедуплять", + "делти", "делті", "делтою", "деплоїв", @@ -295,6 +302,7 @@ "директорійні", "диспатчу", "довиконатись", + "догенерації", "догенерацію", "догенерація", "доінлайнюється", @@ -401,9 +409,6 @@ "лейни", "лейнів", "лениве", - "локалами", - "людиночитані", - "людозрозумілий", "лідируючих", "лінковка", "лінта", @@ -414,6 +419,7 @@ "лінтує", "локалами", "людиночитані", + "людозрозумілий", "м'ютекс", "макротаску", "мапився", @@ -464,13 +470,6 @@ "незалогінений", "незамокані", "незматчених", - "неймспейсах", - "непробільним", - "непротектовані", - "непідтриманих", - "непідтриманої", - "Нетекстові", - "нетекстові", "неігнорованої", "неіснуючим", "неіснуючу", @@ -479,7 +478,10 @@ "непідтриманої", "непробільним", "непротектовані", + "нетекстові", + "Нетекстові", "нижчопріоритетні", + "нотіс", "оверрайд", "Оверрайд", "оновлен", @@ -502,15 +504,19 @@ "переюзані", "підманіфестах", "підмножинним", + "підпакета", "підпакетах", "підпакеті", "підперевірок", + "підпискові", "підписочна", "підписочний", "підписочних", "підписочні", + "підтеків", "підходящу", "підшляховий", + "пікера", "пінені", "пінів", "пінінг", @@ -541,46 +547,19 @@ "Преloadимо", "пресетах", "пресети", - "пресетом", "пресетів", + "пресетом", "префілом", "прийомі", "прикладовий", + "пріоритезує", + "пріоритизацію", "провайдерна", "продакшену", "прокинуті", "проксіюється", "промиси", "промт", - "пріоритезує", - "пріоритизацію", - "підманіфестах", - "підмножинним", - "підпакета", - "підпакетах", - "підпакеті", - "підперевірок", - "підпискові", - "підписочна", - "підписочний", - "підписочних", - "підписочні", - "підтеків", - "підходящу", - "підшляховий", - "пікера", - "пінені", - "піновані", - "пінованій", - "пінові", - "пінування", - "пінуються", - "пінуємо", - "пінів", - "пінінг", - "піпінг", - "піпінгу", - "пітч", "ранер", "раннерами", "раннерах", @@ -614,11 +593,11 @@ "репозиторних", "репозиторного", "репортинг", - "Ретеншн", "ретеншн", + "Ретеншн", "ретраїв", - "Рефрешить", "рефрешить", + "Рефрешить", "рефрешни", "рефрешнути", "рефрешу", @@ -687,20 +666,13 @@ "тестогенератор", "тестуваному", "тирових", - "тригереному", - "тулза", - "тулзи", - "тулзів", - "тулом", - "тулінгу", - "тюнять", "тіку", "тірами", "тірах", "тіри", "Тіри", - "тіру", "тірів", + "тіру", "тригереному", "тулза", "тулзи", @@ -741,33 +713,22 @@ "чанка", "чанках", "чанки", + "чанкінгу", "чанкований", "чанкованим", "чанкованого", "чанку", - "чанкінгу", "чейнджлог", "чейнінгу", "чекаута", "чекаутах", "чекауті", "чекаутів", - "чорнетк", - "чорновика", "чіпання", "чорнетк", + "чорновика", "шейп", "шимі", - "яёіїєґ", - "FNDA", - "mutmut", - "Mutmut", - "Автодетектовані", - "асерти", - "вантажує", - "дедуплікований", - "делти", - "догенерації", - "підтеків" + "яёіїєґ" ] }