From 14db66e5b06d5a179f6785390267327ce4aedcbd Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Jan 2026 23:03:48 +0900 Subject: [PATCH] init --- .envrc | 1 + .gitignore | 2 + AGENTS.md | 35 +++ Cargo.lock | 147 ++++++++++ Cargo.toml | 7 + README.md | 1 + docs/research/2026-01-02-llm-streaming.md | 26 ++ .../2026-01-02-provider-event-specs.md | 31 +++ docs/spec/basis.md | 68 +++++ docs/spec/timeline_design.md | 256 ++++++++++++++++++ flake.lock | 77 ++++++ flake.nix | 31 +++ 12 files changed, 682 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 docs/research/2026-01-02-llm-streaming.md create mode 100644 docs/research/2026-01-02-provider-event-specs.md create mode 100644 docs/spec/basis.md create mode 100644 docs/spec/timeline_design.md create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..8392d15 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d5df85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.direnv diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b75be37 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# llm-worker-rs 開発instruction + +## パッケージ管理ルール + +- クレートに依存関係を追加・更新する際は必ず + `cargo`コマンドを使い、`Cargo.toml`を直接手で書き換えず、必ずコマンド経由で管理すること。 + +## worker-types + +`worker-types` クレートには次の条件を満たす型だけを置く。 + +1. **共有セマンティクスの源泉** + - ランタイム(`worker`)、proc-macro(`worker-macros`)、外部利用者のすべてで同じ定義を共有したい値型。 + - 例: `BlockId`, `ProviderEvent`, `ToolArgumentsDelta` などイベント/DTO群。 + +2. **シリアライズ境界を越えるもの** + - serde経由でプロセス外へ渡したり、APIレスポンスとして公開するもの。 + - ロジックを持たない純粋なデータキャリアに限定する。 + +3. **依存の最小化が必要な型** + - `serde`, `serde_json` 程度の軽量依存で収まる。 + +4. **マクロが直接参照する型** + - 属性/derive/proc-macro が型に対してコード生成する場合は `worker-macros` -> + `worker-types` の単方向依存を維持するため、対象型を `worker-types` に置く。 + +5. **副作用を伴わないこと** + - `worker-types` 内では I/O・状態保持・スレッド操作などの副作用を禁止。 + - 振る舞いを持つ場合でも `impl` + は純粋な計算か軽量ユーティリティのみに留める。 + +この基準に当てはまらない型(例えばクライアント状態管理、エラー型で追加依存が必要、プロバイダ固有ロジックなど)は +`worker` クレート側に配置し、どうしても公開が必要なら `worker` +経由で再エクスポートする。 何にせよ、`worker` -> +`worker-types`の片方向依存を維持すること。 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8c8e218 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "worker" +version = "0.1.0" +dependencies = [ + "serde_json", + "thiserror", + "worker-macros", + "worker-types", +] + +[[package]] +name = "worker-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "worker-types", +] + +[[package]] +name = "worker-types" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de9211a9f64b825911bdf0240f58b7a8dac217fe260fc61f080a07f61372fbd5" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cc8b8cd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +resolver = "2" +members = [ + "worker", + "worker-types", + "worker-macros", +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5861971 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# llm-worker-rs diff --git a/docs/research/2026-01-02-llm-streaming.md b/docs/research/2026-01-02-llm-streaming.md new file mode 100644 index 0000000..cf902ca --- /dev/null +++ b/docs/research/2026-01-02-llm-streaming.md @@ -0,0 +1,26 @@ +# 2026-01-02 LLM Streaming & Hooks Research + +## Summary Table +| Topic | Key Takeaways | Sources | +| --- | --- | --- | +| Fine-grained tool streaming | Anthropic beta header `fine-grained-tool-streaming-2025-05-14` streams tool parameters without intermediate JSON validation; reduces latency but may emit invalid/partial JSON that callers must sanitize. | Anthropic Docs – Fine-grained tool streaming (https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/fine-grained-tool-streaming) [turn1search0]; AWS Bedrock Anthropic tool-use reference (https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-tool-use.html) [turn1search5]; Anthropic release notes (https://docs.anthropic.com/en/release-notes/api) [turn1search3] +| Streaming SSE events | Anthropic SSE stream emits typed events (`message_start`, `content_block_start`, `content_block_delta`, etc.) that clients must map to internal state machines for deterministic playback. | Anthropic Streaming Messages (https://docs.anthropic.com/en/docs/build-with-claude/streaming) [turn1search6] +| Tool streaming ergonomics | LangChain Anthropic integration exposes `betas=["fine-grained-tool-streaming-2025-05-14"]` and warns about invalid JSON, reinforcing need for resilient parsers. | LangChain Anthropic integration guide (https://docs.langchain.com/oss/python/integrations/chat/anthropic) [turn1search8] +| Hook architectures | Claude Code hook lifecycle (SessionStart, UserPromptSubmit, Tool Use, etc.) keeps hooks non-blocking, context-injecting, and failure-isolated—useful template for worker hooks/routers. | Claude-Mem hook architecture overview (https://docs.claude-mem.ai/hooks-architecture) [turn0search0]; Claude Blog – configuring hooks (https://claude.com/blog/how-to-configure-hooks) [turn0search2] + +## Detailed Notes + +### Fine-grained Tool Streaming +- Anthropic’s beta header `fine-grained-tool-streaming-2025-05-14` enables parameter streams, shrinking first-byte latency from ~15s to ~3s in their example. Clients must prepare for partial JSON and wrap invalid payloads before echoing them back. [turn1search0] +- AWS Bedrock mirrors the same header, confirming availability on Claude Sonnet 4.5/4 and Opus 4. Their docs explicitly caution about invalid/partial JSON and show the request schema. [turn1search5] +- Anthropic’s June 11, 2025 release notes document the beta launch, signaling freshness and likely API stability requirements. [turn1search3] + +### SSE Event Model +- Anthropic exposes SSE event names plus JSON `type` fields; implementers should parse events like `content_block_start`, `content_block_delta`, and `message_stop` to drive a deterministic timeline. This justifies a dedicated timeline router/state machine as in the spec. [turn1search6] + +### Tool Streaming Ergonomics in SDKs +- LangChain’s Anthropic integration demonstrates how third-party SDKs surface the beta header and reiterates the need for error handling when incomplete JSON arrives because `max_tokens` can stop a stream mid-parameter. This informs library-level abstractions for `scheme` and `llm_client` layers. [turn1search8] + +### Hook / Lifecycle Design Patterns +- Claude-Mem’s architecture treats hooks as lifecycle-triggered closures that must stay non-blocking, degrade gracefully, and respect security constraints (frozen configs, permission prompts). This maps closely to the proposed `Tools/Hooks` system. [turn0search0] +- Claude’s official hook configuration guide enumerates eight hook types (PreToolUse, PostToolUse, PermissionRequest, SessionStart, Stop, etc.) and their contexts, reinforcing the need for a trait/macro system to statically describe hooks and route events. [turn0search2] diff --git a/docs/research/2026-01-02-provider-event-specs.md b/docs/research/2026-01-02-provider-event-specs.md new file mode 100644 index 0000000..21e3c20 --- /dev/null +++ b/docs/research/2026-01-02-provider-event-specs.md @@ -0,0 +1,31 @@ +# 2026-01-02 Provider Event & Tool Streaming Specs + +## Snapshot +| Provider | Stream Transport | Event/Deltas | Tool Call Streaming | References | +| --- | --- | --- | --- | --- | +| OpenAI Responses API | SSE (`stream=true`) with typed semantic events | `response.created`, `response.output_text.delta/done`, `response.function_call_arguments.delta/done`, `response.refusal.*`, `response.code_interpreter.*`, etc. | Tool/function arguments stream as JSON string deltas; supports MCP tool calls and built-ins. | OpenAI Streaming guide & API reference (2025-12) | +| Anthropic Messages API | SSE (`stream=true`) enumerating `message_*` + `content_block_*` events | Content blocks typed (`text_delta`, `input_json_delta`, `thinking_delta`, `signature_delta`, tool results). `ping` and `error` events possible. | Fine-grained tool streaming via beta header `anthropic-beta: fine-grained-tool-streaming-2025-05-14`; tool inputs stream as `partial_json`. | Anthropic Streaming docs (2025-08) + Fine-grained Tool Streaming beta (2025-05) | +| Google Gemini API | SSE via `:streamGenerateContent?alt=sse` returning repeated `GenerateContentResponse` objects | Each chunk is a partial `candidates[]` payload; structured output streaming returns partial JSON; Live API adds WebSocket session events. | Function declarations via `tools.functionDeclarations`. `streamFunctionCallArguments=true` yields `partialArgs` records with `jsonPath` + `willContinue`. | Gemini REST docs + Function Calling guide + Vertex AI streaming args (updated 2025-12) | + +## OpenAI Responses API (2025-12 docs) +- **Lifecycle events**: `response.created`, `response.queued`, `response.in_progress`, `response.completed`, `response.failed`, `error`. Each emits `sequence_number` for ordering. +- **Content events**: `response.output_text.delta/done`, `response.output_text.annotation.added`, `response.refusal.delta/done`, plus `response.output_item.added/done` for multi-part outputs. +- **Tool events**: `response.function_call_arguments.delta/done`, `response.file_search_call.*`, `response.code_interpreter_call.*`, `response.custom_tool_call_input.delta`, `response.mcp_call_arguments.delta/done` for MCP connectors. +- **Design takeaways**: timeline layer must preserve ordering by `sequence_number`, correlate `item_id`/`output_index`, and buffer partial JSON until `.done`. Provide parsing helpers for MCP/custom tool deltas and code interpreter outputs to unify built-in + custom tools. +- **Primary sources**: OpenAI Streaming Responses Guide + Streaming Events API reference (retrieved Jan 2 2026). + +## Anthropic Messages API +- **Event flow**: Always begins with `message_start`, then repeated blocks of (`content_block_start` → `content_block_delta`* → `content_block_stop`), optional `message_delta`, and `message_stop`. `ping` and `error` events may interleave. +- **Content block delta types**: `text_delta`, `input_json_delta` (tool inputs), `thinking_delta`, `signature_delta` (integrity for thinking), plus specialized tool result payloads (e.g., `code_execution_tool_result`). +- **Fine-grained tool streaming**: Beta header `fine-grained-tool-streaming-2025-05-14` streams tool parameters chunk-by-chunk without JSON validation; clients must handle invalid/partial JSON when `max_tokens` stop reason fires mid-argument. Recommended strategy: accumulate raw string, optionally wrap invalid JSON before echoing back. +- **Operational cautions**: Tool input streaming may pause between keys (docs warn of delays). Provide timeouts/heartbeat detection for UIs (issue reports note >3 min stalls with no `ping`). +- **Primary sources**: Anthropic Streaming Messages guide & Fine-grained Tool Streaming beta docs (retrieved Jan 2 2026). + +## Google Gemini API +- **REST streaming**: `POST https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse` returns SSE where each `data:` line is a partial `GenerateContentResponse` (with `candidates`, `usageMetadata`, `modelVersion`). Chrome DevDoc example shows chunked `text` fields. +- **Structured output streaming**: When `response_mime_type=application/json`, streamed chunks are valid partial JSON strings that concatenate to the final object. +- **Function calling**: Tools defined via `functionDeclarations`; `function_calling_config` modes `AUTO/ANY/NONE`. Latest docs confirm JSON Schema + Pydantic/Zod compatibility (2025-12 update) and compositional calls. +- **Function call argument streaming**: Set `toolConfig.functionCallingConfig.streamFunctionCallArguments=true`; responses include `functionCall.partialArgs` with `jsonPath`, `delta` values (string/number/bool/null) and `willContinue`. Vertex AI content schema documents `PartialArg` message for decoding. +- **Transports**: SSE for REST, WebSockets for Live API sessions (bidirectional audio/text). Need to abstract both. +- **Primary sources**: Gemini API overview + streamGenerateContent reference + function calling guide + Vertex AI streaming arguments doc + Chrome streaming example (retrieved Jan 2 2026). + diff --git a/docs/spec/basis.md b/docs/spec/basis.md new file mode 100644 index 0000000..65b77cd --- /dev/null +++ b/docs/spec/basis.md @@ -0,0 +1,68 @@ +# llm_worker_rs + +LLMを用いたワーカーを作成する小型のSDK・ライブラリ。 + +ツールの定義や呼び出し、コンテキストの管理などを責務にもつ。 + +## 用語定義 + +- **ブロック (Block)**: + モデルが「開始→デルタ→終了」で囲む一塊の出力。OpenAIの`response.output_item`やAnthropicの`content_block`、Geminiの`candidates[].content.parts`などが該当する。 +- **メタイベント (Meta Event)**: + `ping`や`usage`など、ブロックに属さない補助イベント群。ステータス更新やハートビートとして処理される。 + +以降の仕様では「ブロック」を上記の生成・ツール単位の総称として扱う。 + +## アプローチ + +module構成概念図 + +``` +worker +├── context +├── llm_client +│ ├── scheme +│ │ └── openai, anthropic, gemini // APIスキーマ +│ ├── events +│ ├── providers +│ │ └── anthropic, googleai, ollama, etc... // プロバイダ +│ └── timeline +``` + +OpenAI互換のプロバイダでスキーマを使い回せるよう、schemeとプロバイダモジュールは分離されている + +### scheme層 + +単純な変換を責務とするスキーマを定義する。 + +- リクエスト変換: SystemPrompt + Context + Tools... → プロバイダ固有のリクエストJSON +- レスポンス変換: Raw stream → 型付きイベント構造体のストリーム + +各APIスキーマ(≠プロバイダ)ごとに実装を持ち、APIスキーマに準じたパース/バリデーションを行う。 + +### llm_client(providers)層 + +providersにリクエストを送信し、差異が吸収され統一された`Event`ストリームを出力する。 + +ストリーミング中のバッファリング(ToolArgumentsの累積等)もこの層で行う。 + +### timeline層 + +イベントストリームの状態を管理する。レスポンスをステートマシンで制約し、イベントをハンドラーに渡す。 + +このTimelineに基づき、Workerはツール実行・フックを行う。 + +イベントストリームを扱う目標に、Text/Input JSON/Thinkingのdeltaと、[細粒度ツールストリーミング](https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming)を含む、deltaの低遅延・リアルタイム処理がある。 +主要なドキュメント:[https://platform.claude.com/docs/en/build-with-claude/streaming#content-block-delta-types] + +TODO: Workerをユーザーが扱うUIとした際の、Timelineの露出のさせ方 + +## Tools/Hooks + +各種プロバイダのLLMのAPI仕様として存在するTool Callや、Function CallingをToolsとして統一的に扱ってる。また、Claude Codeに存在するようなHooksと同じような機能を搭載する。 + +これらはtraitとして表現され、クロージャを持ち、イベントのストリーム中に自動的に呼び出される。 + +Tools/Hooksを定義する為のProcedural macroを提供する。 + +TODO: Tool定義と合わせたTimeline Handlerの定義方法(特定のツールとセットになったHandlerの考慮) diff --git a/docs/spec/timeline_design.md b/docs/spec/timeline_design.md new file mode 100644 index 0000000..6a8a398 --- /dev/null +++ b/docs/spec/timeline_design.md @@ -0,0 +1,256 @@ +# Timeline層設計メモ + +## 目的 + +- OpenAI / Anthropic / Gemini + のストリーミングイベントを単一の抽象レイヤーに正規化し、LLMクライアントの状態遷移を制御する。 +- イベント単位処理(Meta系など)とブロック単位処理(テキスト/Thinking/ToolCall)を同一のパイプラインで扱えるようにする。 + +## 要件 + +イベントストリームに対して直接的にループ処理をしようとすると発生する煩雑な状態管理を避ける。 + +イベントをloop+matchで処理をするような手段を取ると、テキストのデルタの更新先や、完了タイミングなどの状態管理が必要になる。 +また、コンテンツブロックに対する単純なイテレータではping/usageなどの単発イベントを同期的に処理することができない。 + +- Meta系イベントの即時処理 (ブロック内部イベントと順序が前後しないようにする) +- ブロック開始/差分/終了でスコープを保持する +- 型安全なハンドラー + - blockでキャッチするdeltaについて、 Text/Input JSON/Thinking等、 + ブロックに即したイベントの型が必要。 +- エラーの適切な制御 + +TODO:toolのjsonをキャッチする際、定義したToolが期待するjsonschemaに合致しているかのバリデーションはTimeline層の責務か? + +## Memo + +- [Blockの定義](basis.md#用語定義) +- ブロックを処理するHandlerで保持するコンテキストについて、LLMで用いられるコンテキストと混同を避けるために「スコープ」と呼称する。 +- ブロックは常に一つである前提。複数のブロックが同時に存在することは無いため。 + +## イベントモデル + +前提:`llm_client`層は各プロバイダのストリーミングレスポンスを正規化し、**フラットなEvent列挙**として出力する。 + +```rust +pub enum Event { + // Meta events (not tied to a block) + Ping(PingEvent), + Usage(UsageEvent), + Status(StatusEvent), + Error(ErrorEvent), + + // Block lifecycle events + BlockStart(BlockStart), + BlockDelta(BlockDelta), + BlockStop(BlockStop), + BlockAbort(BlockAbort), +} +``` + +-> Timelineがブロック構造化を担う + +### 設計方針 + +- 目的: アプリケーションとの連携を楽にする + +状態共有/非同期: Handler間の状態共有はアプリケーション側の責務、アプリケーション側で非同期化可能な設計 + +複数のHandlerに対し、登録順にディスパッチする。Handlerは後続Handlerへのイベントを改変しない。 + +## TimelineとKind/Handler + +Timelineは`Event`ストリームを受け取り、登録された`Handler`にディスパッチする。 + +### Kind + +`Kind`はイベント型のみを定義する。スコープはHandler側で定義するため、同じKindに対して異なるスコープを持つHandlerを登録できる。 + +```rust +pub trait Kind { + type Event; +} +``` + +### Handler + +`Handler`はKindに対する処理を定義し、自身のスコープ型も決定する。 + +```rust +pub trait Handler { + type Scope: Default; + + fn on_event(&mut self, scope: &mut Self::Scope, event: &K::Event); +} +``` + +- `Kind`によって受け取るイベント型が決定される +- `Handler::Scope`によってHandler固有のスコープ型が決定される +- Meta系とBlock系を統一的に扱える + +### Meta系Kind + +スコープ不要の単発イベント: + +```rust +pub struct UsageKind; +impl Kind for UsageKind { + type Event = UsageEvent; +} + +pub struct PingKind; +impl Kind for PingKind { + type Event = PingEvent; +} + +// 使用例 +struct UsageAccumulator { total_tokens: u64 } +impl Handler for UsageAccumulator { + type Scope = (); // スコープ不要 + + fn on_event(&mut self, _scope: &mut (), usage: &UsageEvent) { + self.total_tokens += usage.total_tokens.unwrap_or(0); + } +} +``` + +### Block系Kind + +ライフサイクル(Start/Delta/Stop)を持つ。スコープはHandler側で定義: + +```rust +pub struct TextBlockKind; +impl Kind for TextBlockKind { + type Event = TextBlockEvent; +} + +pub enum TextBlockEvent<'a> { + Start(&'a BlockStart), + Delta(&'a str), + Stop(&'a BlockStop), +} + +// 使用例1: デルタを即時出力(スコープ不要) +struct PrintHandler; +impl Handler for PrintHandler { + type Scope = (); + + fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) { + if let TextBlockEvent::Delta(s) = event { + print!("{}", s); + } + } +} + +// 使用例2: テキストを蓄積して収集(Stringをスコープとして利用) +struct TextCollector { results: Vec } +impl Handler for TextCollector { + type Scope = String; // bufferとして使用 + + fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) { + match event { + TextBlockEvent::Start(_) => {} + TextBlockEvent::Delta(s) => buffer.push_str(s), + TextBlockEvent::Stop(_) => { + self.results.push(std::mem::take(buffer)); + } + } + } +} +``` + +### Timelineの責務 + +1. `Event`ストリームを受信 +2. Block系イベント(BlockStart/Delta/Stop)をBlockKindごとのライフサイクルイベントに変換 +3. 各Handlerごとのスコープの生成・管理(BlockStart時に生成、BlockStop/Abort時に破棄) +4. 登録されたHandlerへの登録順ディスパッチ + +### Handlerの型消去 + +各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要: + +```rust +// 型消去されたHandler trait +trait ErasedHandler { + fn dispatch(&mut self, event: &K::Event); + fn start_scope(&mut self); // Scope生成 + fn end_scope(&mut self); // Scope破棄 +} + +// HandlerからErasedHandlerへのラッパー +struct HandlerWrapper +where + H: Handler, + K: Kind, +{ + handler: H, + scope: Option, // Block中のみSome + _kind: PhantomData, +} + +impl ErasedHandler for HandlerWrapper +where + H: Handler, + K: Kind, +{ + fn dispatch(&mut self, event: &K::Event) { + if let Some(scope) = &mut self.scope { + self.handler.on_event(scope, event); + } + } + + fn start_scope(&mut self) { + self.scope = Some(H::Scope::default()); + } + + fn end_scope(&mut self) { + self.scope = None; + } +} +``` + +### Timelineのディスパッチ + +```rust +impl Timeline { + pub fn dispatch(&mut self, event: &Event) { + match event { + // Meta系: 即時ディスパッチ(登録順) + Event::Usage(u) => self.dispatch_to::(u), + Event::Ping(p) => self.dispatch_to::(p), + + // Block系: スコープ管理しながらディスパッチ + Event::BlockStart(s) => self.handle_block_start(s), + Event::BlockDelta(d) => self.handle_block_delta(d), + Event::BlockStop(s) => self.handle_block_stop(s), + Event::BlockAbort(a) => self.handle_block_abort(a), + + _ => {} + } + } + + fn handle_block_start(&mut self, start: &BlockStart) { + // 該当Kind の全Handlerに対してスコープ生成 + for handler in self.handlers_for_kind(start.kind()) { + handler.start_scope(); + handler.dispatch(&start.into()); + } + } + + fn handle_block_stop(&mut self, stop: &BlockStop) { + for handler in self.handlers_for_kind(stop.kind()) { + handler.dispatch(&stop.into()); + handler.end_scope(); + } + } +} +``` + +## 期待効果 + +- **統一インターフェース**: Meta系もBlock系も`Handler`で統一 +- **型安全**: `Kind`によってイベント型がコンパイル時に決定、`Handler::Scope`によってHandler固有のスコープ型が決定 +- **スコープの柔軟性**: 同一Kindに対して異なるScopeを持つ複数Handlerを登録可能 +- **責務分離**: llm_client層はフラットなEvent出力、Timeline層がブロック構造化 +- **スコープ管理の自動化**: Handlerは自前でスコープ保持を意識せずに済む diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0b277b3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,77 @@ +{ + "nodes": { + "flake-compat": { + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1767116409, + "narHash": "sha256-5vKw92l1GyTnjoLzEagJy5V5mDFck72LiQWZSOnSicw=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "cad22e7d996aea55ecab064e84834289143e44a0", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a2c6439 --- /dev/null +++ b/flake.nix @@ -0,0 +1,31 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + flake-compat.url = "github:edolstra/flake-compat"; + }; + + outputs = + { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + nixfmt + deno + git + rustc + cargo + ]; + buildInputs = with pkgs; [ + pkg-config + openssl + ]; + }; + } + ); +}