From ccd9f66bbe89a161b687c155bcefe34cd365eaf2 Mon Sep 17 00:00:00 2001 From: "liqiankun.1111" Date: Sun, 9 Aug 2026 12:24:58 +0800 Subject: [PATCH 1/3] feat: add polyglot agent framework adapters --- .github/workflows/ci.yml | 35 +- .gitignore | 3 + Makefile | 19 +- README.md | 129 +- conformance/README.md | 8 + conformance/vectors/append.json | 20 + go/Makefile | 13 + go/adapters/agentgo/adapter.go | 280 ++++ go/adapters/agentgo/adapter_test.go | 149 ++ go/adapters/agentgo/model.go | 105 ++ go/canonical.go | 23 + go/go.mod | 8 + go/go.sum | 4 + go/id.go | 19 + go/memory_store.go | 191 +++ go/recorder.go | 125 ++ go/store.go | 12 + go/store_test.go | 88 ++ go/types.go | 88 ++ python/Makefile | 16 + python/README.md | 4 + {examples => python/examples}/plain_loop.py | 0 pyproject.toml => python/pyproject.toml | 6 +- {src => python/src}/agent_ledger/__init__.py | 8 +- python/src/agent_ledger/adapters.py | 38 + {src => python/src}/agent_ledger/artifacts.py | 0 {src => python/src}/agent_ledger/errors.py | 0 .../src}/agent_ledger/frameworks/__init__.py | 2 +- .../src}/agent_ledger/frameworks/base.py | 8 +- .../agent_ledger/frameworks/plain_loop.py | 19 + .../src}/agent_ledger/inspection.py | 8 +- {src => python/src}/agent_ledger/models.py | 21 +- {src => python/src}/agent_ledger/py.typed | 0 {src => python/src}/agent_ledger/recorder.py | 17 +- {src => python/src}/agent_ledger/store.py | 6 +- .../src}/agent_ledger/stores/__init__.py | 0 .../src}/agent_ledger/stores/_validation.py | 8 +- .../src}/agent_ledger/stores/memory.py | 19 +- .../src}/agent_ledger/stores/redis.py | 23 +- .../src}/agent_ledger/stores/sql.py | 53 +- .../src}/agent_ledger/trajectory.py | 4 +- {tests => python/tests}/conftest.py | 0 {tests => python/tests}/test_models.py | 20 +- {tests => python/tests}/test_projection.py | 0 {tests => python/tests}/test_recorder.py | 6 +- .../tests}/test_store_contract.py | 86 +- uv.lock => python/uv.lock | 11 + spec/rfcs/0001-agent-ledger.md | 32 +- spec/rfcs/0002-polyglot-adapters.md | 105 ++ spec/schemas/adapter.schema.json | 30 + spec/schemas/event.schema.json | 1 + typescript/Makefile | 10 + typescript/package-lock.json | 1393 +++++++++++++++++ typescript/package.json | 15 + typescript/packages/core/package.json | 13 + typescript/packages/core/src/canonical.ts | 44 + typescript/packages/core/src/index.ts | 5 + typescript/packages/core/src/memory-store.ts | 90 ++ typescript/packages/core/src/recorder.ts | 129 ++ typescript/packages/core/src/store.ts | 12 + typescript/packages/core/src/types.ts | 88 ++ typescript/packages/core/test/core.test.ts | 72 + typescript/packages/core/tsconfig.json | 17 + typescript/packages/pi/package.json | 22 + typescript/packages/pi/src/harness.ts | 129 ++ typescript/packages/pi/src/index.ts | 2 + typescript/packages/pi/src/session-storage.ts | 172 ++ typescript/packages/pi/test/pi.test.ts | 158 ++ typescript/packages/pi/tsconfig.json | 18 + typescript/tsconfig.json | 7 + 70 files changed, 4069 insertions(+), 197 deletions(-) create mode 100644 conformance/README.md create mode 100644 conformance/vectors/append.json create mode 100644 go/Makefile create mode 100644 go/adapters/agentgo/adapter.go create mode 100644 go/adapters/agentgo/adapter_test.go create mode 100644 go/adapters/agentgo/model.go create mode 100644 go/canonical.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/id.go create mode 100644 go/memory_store.go create mode 100644 go/recorder.go create mode 100644 go/store.go create mode 100644 go/store_test.go create mode 100644 go/types.go create mode 100644 python/Makefile create mode 100644 python/README.md rename {examples => python/examples}/plain_loop.py (100%) rename pyproject.toml => python/pyproject.toml (92%) rename {src => python/src}/agent_ledger/__init__.py (82%) create mode 100644 python/src/agent_ledger/adapters.py rename {src => python/src}/agent_ledger/artifacts.py (100%) rename {src => python/src}/agent_ledger/errors.py (100%) rename {src => python/src}/agent_ledger/frameworks/__init__.py (67%) rename {src => python/src}/agent_ledger/frameworks/base.py (63%) rename {src => python/src}/agent_ledger/frameworks/plain_loop.py (84%) rename {src => python/src}/agent_ledger/inspection.py (93%) rename {src => python/src}/agent_ledger/models.py (91%) rename {src => python/src}/agent_ledger/py.typed (100%) rename {src => python/src}/agent_ledger/recorder.py (95%) rename {src => python/src}/agent_ledger/store.py (78%) rename {src => python/src}/agent_ledger/stores/__init__.py (100%) rename {src => python/src}/agent_ledger/stores/_validation.py (85%) rename {src => python/src}/agent_ledger/stores/memory.py (89%) rename {src => python/src}/agent_ledger/stores/redis.py (93%) rename {src => python/src}/agent_ledger/stores/sql.py (89%) rename {src => python/src}/agent_ledger/trajectory.py (97%) rename {tests => python/tests}/conftest.py (100%) rename {tests => python/tests}/test_models.py (56%) rename {tests => python/tests}/test_projection.py (100%) rename {tests => python/tests}/test_recorder.py (95%) rename {tests => python/tests}/test_store_contract.py (59%) rename uv.lock => python/uv.lock (99%) create mode 100644 spec/rfcs/0002-polyglot-adapters.md create mode 100644 spec/schemas/adapter.schema.json create mode 100644 typescript/Makefile create mode 100644 typescript/package-lock.json create mode 100644 typescript/package.json create mode 100644 typescript/packages/core/package.json create mode 100644 typescript/packages/core/src/canonical.ts create mode 100644 typescript/packages/core/src/index.ts create mode 100644 typescript/packages/core/src/memory-store.ts create mode 100644 typescript/packages/core/src/recorder.ts create mode 100644 typescript/packages/core/src/store.ts create mode 100644 typescript/packages/core/src/types.ts create mode 100644 typescript/packages/core/test/core.test.ts create mode 100644 typescript/packages/core/tsconfig.json create mode 100644 typescript/packages/pi/package.json create mode 100644 typescript/packages/pi/src/harness.ts create mode 100644 typescript/packages/pi/src/index.ts create mode 100644 typescript/packages/pi/src/session-storage.ts create mode 100644 typescript/packages/pi/test/pi.test.ts create mode 100644 typescript/packages/pi/tsconfig.json create mode 100644 typescript/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d531b8..3eaeba5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: pull_request: jobs: - test: + python: runs-on: ubuntu-latest services: redis: @@ -40,13 +40,46 @@ jobs: AGENT_LEDGER_REDIS_URL: redis://localhost:6379/0 AGENT_LEDGER_POSTGRES_URL: postgresql+asyncpg://ledger:ledger@localhost:5432/ledger AGENT_LEDGER_MYSQL_URL: mysql+asyncmy://ledger:ledger@localhost:3306/ledger + defaults: + run: + working-directory: python steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 with: python-version: "3.11" enable-cache: true + cache-dependency-glob: python/uv.lock - run: uv sync --all-extras - run: make lint - run: make test + typescript: + runs-on: ubuntu-latest + defaults: + run: + working-directory: typescript + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: typescript/package-lock.json + - run: npm ci + - run: make lint + - run: make test + + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: go/go.sum + - run: make lint + - run: make test diff --git a/.gitignore b/.gitignore index 7655cd5..a11f3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .venv/ .worktree/ +.worktrees/ +node_modules/ +*.tsbuildinfo .mypy_cache/ .pytest_cache/ .ruff_cache/ diff --git a/Makefile b/Makefile index 45c5242..a96f1d6 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,20 @@ .PHONY: fix lint test build fix: - uv run ruff format . - uv run ruff check --fix . + $(MAKE) -C python fix + $(MAKE) -C go fix lint: - uv run ruff format --check . - uv run ruff check . - uv run mypy + $(MAKE) -C python lint + $(MAKE) -C typescript lint + $(MAKE) -C go lint test: - uv run pytest + $(MAKE) -C python test + $(MAKE) -C typescript test + $(MAKE) -C go test build: - uv build - + $(MAKE) -C python build + $(MAKE) -C typescript build + $(MAKE) -C go build diff --git a/README.md b/README.md index 440e11d..46b598d 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,71 @@ # Agent Ledger -Agent Ledger is a framework-neutral event ledger for durable agent sessions. It records agent -steps before model and tool execution, keeps causal links across distributed agent runs, and gives -framework adapters enough facts to rebuild their own run context after a restart. - -The ledger is the source of truth for **what happened**. Recovery remains owned by the framework -integration that understands its checkpoint and `RunContext` types. - -## Why another event log? - -Traditional logs and traces explain service execution. Agent Ledger adds agent-native invariants: - -- `Session` groups one end-to-end task across processes and agents. -- `Run` is the optimistic-concurrency stream written by one agent loop. -- `Step` is a logical unit; `Attempt` is one physical model or tool invocation. -- requested events are committed before external calls, so interrupted calls remain visible. -- `parent_run_id` and `caused_by_event_id` form a causal DAG without relying on timestamps. -- trajectories such as ATIF are projections, not the durable source of truth. - -## Quick start - -```python -from agent_ledger import Actor, SessionRecorder -from agent_ledger.stores.memory import MemoryEventStore - -store = MemoryEventStore() -recorder = SessionRecorder( - store=store, - session_id="session-1", - run_id="run-1", - actor=Actor(type="agent", id="researcher"), -) - -await recorder.start_run(payload={"task": "summarize"}) -attempt = await recorder.before_model_call( - step_id="step-1", - payload={"model": "example-model", "messages": [{"role": "user", "content": "Hi"}]}, -) - -# The real model call starts only after model.requested is durably appended. -response = await model.generate() -await recorder.model_completed(attempt, payload={"message": response}) +Agent Ledger is a framework-neutral specification and a set of polyglot adapters for durable agent +sessions. It records model and tool attempts before execution, preserves causal timelines across +distributed agents, and lets each framework rebuild its own native session after interruption. + +The specification is the stable product. Language SDKs are deliberately small; most project code +lives in adapters that understand a framework's hooks, messages, checkpoints, and resume APIs. + +## Model + +- `Session` groups one end-to-end task across processes, languages, and agents. +- `Run` identifies one semantic agent execution and participates in the causal DAG. +- `EventStream` is an optimistic-concurrency partition. It may contain one run's execution events or + framework-native state that survives several runtime runs. +- `Step` is logical work that survives retries; `Attempt` is one physical model or tool invocation. +- Normalized events are the source for timelines and trajectories. Framework-native records are the + source for resume. + +Requested events are committed before an external call. A requested event without a terminal event +is unresolved after a crash. It is input to the adapter's reconciliation policy; an adapter must +not silently replay a side-effecting tool. + +## Repository + +| Area | Responsibility | +| --- | --- | +| `spec/` | Event, append, adapter capability, and recovery contracts | +| `conformance/` | Cross-language golden vectors and adapter contract tests | +| `python/` | Python core SDK plus memory, Redis, and SQLAlchemy stores | +| `typescript/` | TypeScript core SDK and Pi adapter | +| `go/` | Go core SDK and AgentGo adapter | + +Current framework profiles: + +| Adapter | Recording | Recovery | +| --- | --- | --- | +| Pi AgentHarness | Awaited model/tool hooks | Ledger-backed Pi `SessionStorage` | +| AgentGo | `ChatModel` wrapper, turn hooks, message committer, tool middleware | Native message codec with `HoldRuns` + `SetMessages` + `Continue` | +| Plain Python loop | Explicit recorder calls | Snapshot plus tail replay | + +Every adapter publishes machine-readable capabilities such as `strict`, `best_effort`, and +`unsupported`; installing a telemetry-only hook never silently claims durable recovery. + +## Store contract + +Applications inject an `EventStore`. V1 has no mandatory collector or `/agent-session` service: +framework processes write directly to an in-memory, Redis, or database implementation selected by +the host. + +```text +append(stream, expected_version, append_id, events) +read_stream(stream, after_version) +scan_session(session_id, after_cursor) ``` -If the process stops after `before_model_call`, inspection reports an unresolved attempt. An adapter -can then ask the provider for a result, require human confirmation, or retry with a new -`attempt_id`; the generic library never silently repeats an external side effect. - -## Stores - -`EventStore` has three implementations: - -- `MemoryEventStore`: process-local reference implementation and test double. -- `RedisEventStore`: atomic append through Lua, with per-session cluster key co-location. -- `SqlEventStore`: one SQLAlchemy 2.x implementation for SQLite, MySQL, and PostgreSQL. - -Redis and SQL clients are supplied by the application so pool size, connection timeout, and -deployment-specific durability are explicit. Install optional dependencies with -`agent-ledger[redis]`, `agent-ledger[sql]`, `agent-ledger[mysql]`, or -`agent-ledger[postgres]`. - -## Design boundaries - -- No collector or mandatory network service in v1. -- No generic cross-framework `RunContext` serializer. -- No automatic replay of an unresolved tool side effect. -- No exactly-once claim. Appends are atomic and idempotent; external calls are not transactional - with the ledger. -- No global ordering claim. `commit_cursor` orders one session's stored events for display and - pagination; causal links define execution relationships. - -See [RFC 0001](spec/rfcs/0001-agent-ledger.md) for the contract and -[the plain-loop example](examples/plain_loop.py) for adapter-owned recovery. +Appends are atomic, idempotent by RFC 8785 canonical event content, and protected by optimistic +concurrency. `commit_cursor` gives one session a display/pagination order; causal links, not cursor +or timestamps, define execution relationships. ## Development ```bash -uv sync --all-extras make fix make lint make test +make build ``` +See [RFC 0001](spec/rfcs/0001-agent-ledger.md) for the ledger contract and +[RFC 0002](spec/rfcs/0002-polyglot-adapters.md) for framework recording and recovery boundaries. diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..23bcf43 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,8 @@ +# Conformance + +Language SDKs consume the same golden vectors to verify event encoding and append identity. Adapter +contract suites live beside their implementations. The conformance target includes failure +injection before model calls, before tools, after external outcomes, and during native-state +restoration. + +Passing these vectors is required before an SDK can claim Agent Ledger schema `1.x` compatibility. diff --git a/conformance/vectors/append.json b/conformance/vectors/append.json new file mode 100644 index 0000000..7a121ca --- /dev/null +++ b/conformance/vectors/append.json @@ -0,0 +1,20 @@ +{ + "canonicalization": "RFC8785", + "canonical": "[{\"actor\":{\"framework\":\"pi\",\"id\":\"writer\",\"type\":\"agent\"},\"attempt_id\":\"attempt-1\",\"event_id\":\"event-1\",\"event_type\":\"model.requested\",\"extensions\":{},\"occurred_at\":\"2026-01-02T03:04:05Z\",\"payload\":{\"model\":\"claude\",\"temperature\":0.25,\"tokens\":1024},\"run_id\":\"run-1\",\"schema_version\":\"1.0\",\"session_id\":\"session-1\",\"step_id\":\"step-1\"}]", + "sha256": "b83e0ae0fcf7e76613c66f34b6c034cb0d35e517271aea5cf6c8820ac7ef5721", + "events": [ + { + "schema_version": "1.0", + "event_id": "event-1", + "event_type": "model.requested", + "session_id": "session-1", + "run_id": "run-1", + "actor": { "type": "agent", "id": "writer", "framework": "pi" }, + "occurred_at": "2026-01-02T03:04:05Z", + "step_id": "step-1", + "attempt_id": "attempt-1", + "payload": { "model": "claude", "temperature": 0.25, "tokens": 1024 }, + "extensions": {} + } + ] +} diff --git a/go/Makefile b/go/Makefile new file mode 100644 index 0000000..cd6b5d3 --- /dev/null +++ b/go/Makefile @@ -0,0 +1,13 @@ +.PHONY: fix lint test build + +fix: + go fmt ./... + +lint: + go vet ./... + +test: + go test ./... + +build: + go build ./... diff --git a/go/adapters/agentgo/adapter.go b/go/adapters/agentgo/adapter.go new file mode 100644 index 0000000..6b7b28e --- /dev/null +++ b/go/adapters/agentgo/adapter.go @@ -0,0 +1,280 @@ +package agentgoadapter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/compforge/agent-ledger/go" + "github.com/compforge/agentgo" +) + +var Descriptor = agentledger.AdapterDescriptor{ + SchemaVersion: "1.0", + AdapterID: "agentgo", + AdapterVersion: "1", + Framework: "agentgo", + FrameworkVersion: ">=0.0.1 <1", + Capabilities: agentledger.AdapterCapabilities{ + ModelPrewrite: "strict", + ToolPrewrite: "strict", + OutcomeGate: "strict", + Recovery: "native_store", + PreservesNativeState: true, + }, +} + +type MessageCodec interface { + Encode(agentgo.AgentMessage) (kind string, data json.RawMessage, err error) + Decode(kind string, data json.RawMessage) (agentgo.AgentMessage, error) +} + +type Config struct { + Store agentledger.EventStore + SessionID string + RunID string + Actor agentledger.Actor + NativeSessionID string + MessageCodec MessageCodec + BeforeTurn agentgo.BeforeTurnHook + AfterTurn agentgo.AfterTurnHook + OperationTimeout time.Duration +} + +type Adapter struct { + runtimeRecorder *agentledger.SessionRecorder + stateRecorder *agentledger.SessionRecorder + codec MessageCodec + timeout time.Duration + beforeTurn agentgo.BeforeTurnHook + afterTurn agentgo.AfterTurnHook + + mu sync.Mutex + currentStep string +} + +func New(ctx context.Context, config Config) (*Adapter, error) { + if config.Store == nil { + return nil, errors.New("agentgo adapter requires an event store") + } + if config.OperationTimeout <= 0 { + return nil, errors.New("agentgo adapter operation timeout must be positive") + } + codec := config.MessageCodec + if codec == nil { + codec = ConcreteMessageCodec{} + } + nativeSessionID := config.NativeSessionID + if nativeSessionID == "" { + nativeSessionID = config.SessionID + } + runtimeRecorder, err := agentledger.ResumeRecorder(ctx, agentledger.RecorderOptions{ + Store: config.Store, SessionID: config.SessionID, RunID: config.RunID, Actor: config.Actor, + }) + if err != nil { + return nil, fmt.Errorf("resume agentgo runtime recorder: %w", err) + } + stateRecorder, err := agentledger.ResumeRecorder(ctx, agentledger.RecorderOptions{ + Store: config.Store, SessionID: config.SessionID, RunID: config.RunID, + StreamID: "framework/agentgo/" + nativeSessionID, Actor: config.Actor, + }) + if err != nil { + return nil, fmt.Errorf("resume agentgo state recorder: %w", err) + } + return &Adapter{ + runtimeRecorder: runtimeRecorder, + stateRecorder: stateRecorder, + codec: codec, + timeout: config.OperationTimeout, + beforeTurn: config.BeforeTurn, + afterTurn: config.AfterTurn, + }, nil +} + +// Options must be installed after any competing message committer or middleware options. +func (a *Adapter) Options(existing ...agentgo.ToolMiddleware) []agentgo.AgentOption { + middlewares := append([]agentgo.ToolMiddleware{a.ToolMiddleware()}, existing...) + return []agentgo.AgentOption{ + agentgo.WithBeforeTurn(a.handleBeforeTurn), + agentgo.WithAfterTurn(a.handleAfterTurn), + agentgo.WithMessageCommitter(a.commitMessage), + agentgo.WithMiddlewares(middlewares...), + } +} + +// WrapModel records every physical provider attempt, including AgentGo retries. +func (a *Adapter) WrapModel(model agentgo.ChatModel) agentgo.ChatModel { + return &recordingModel{inner: model, adapter: a} +} + +func (a *Adapter) ToolMiddleware() agentgo.ToolMiddleware { + return func(ctx context.Context, call agentgo.ToolCall, next agentgo.ToolExecuteFunc) (json.RawMessage, error) { + attempt, err := a.runtimeRecorder.BeforeToolCall(ctx, a.stepID(), map[string]any{ + "tool_call_id": call.ID, + "tool_name": call.Name, + "arguments": string(call.Args), + }) + if err != nil { + return nil, fmt.Errorf("record agentgo tool request: %w", err) + } + result, callErr := next(ctx, call.Args) + if callErr != nil { + if err := a.runtimeRecorder.ToolFailed(ctx, attempt, callErr); err != nil { + return nil, fmt.Errorf("record agentgo tool failure after execution: %w", err) + } + return result, callErr + } + if err := a.runtimeRecorder.ToolCompleted(ctx, attempt, map[string]any{"result": string(result)}); err != nil { + return nil, fmt.Errorf("record agentgo tool result after execution: %w", err) + } + return result, nil + } +} + +// Restore replaces an idle AgentGo transcript with its last durably committed prefix. +// Do not call it from an AgentGo event listener: HoldRuns waits for the active run to drain. +func (a *Adapter) Restore(ctx context.Context, agent *agentgo.Agent) error { + release := agent.HoldRuns() + defer release() + if err := ctx.Err(); err != nil { + return err + } + messages, err := a.loadMessages(ctx) + if err != nil { + return err + } + if err := agent.SetMessages(messages); err != nil { + return fmt.Errorf("restore agentgo messages: %w", err) + } + return nil +} + +func (a *Adapter) RestoreAndContinue(ctx context.Context, agent *agentgo.Agent) error { + if err := a.Restore(ctx, agent); err != nil { + return err + } + return agent.Continue(ctx) +} + +func (a *Adapter) commitMessage(message agentgo.AgentMessage) error { + kind, data, err := a.codec.Encode(message) + if err != nil { + return fmt.Errorf("encode agentgo message: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), a.timeout) + defer cancel() + _, err = a.stateRecorder.Record(ctx, "framework.agentgo.message.committed", map[string]any{ + "message_type": kind, + "message_json": string(data), + }, "", "") + if err != nil { + return fmt.Errorf("persist agentgo message: %w", err) + } + return nil +} + +func (a *Adapter) handleBeforeTurn(ctx context.Context, turn agentgo.BeforeTurnContext) ([]agentgo.AgentMessage, error) { + stepID := a.nextStep() + if _, err := a.runtimeRecorder.Record(ctx, "step.started", map[string]any{"turn": turn.TurnIndex}, stepID, ""); err != nil { + return nil, fmt.Errorf("record agentgo step start: %w", err) + } + if a.beforeTurn != nil { + return a.beforeTurn(ctx, turn) + } + return nil, nil +} + +func (a *Adapter) handleAfterTurn(ctx context.Context, turn agentgo.AfterTurnContext) error { + stepID := a.stepID() + if a.afterTurn != nil { + if err := a.afterTurn(ctx, turn); err != nil { + _, recordErr := a.runtimeRecorder.Record(ctx, "step.failed", map[string]any{"error": err.Error()}, stepID, "") + if recordErr != nil { + return fmt.Errorf("agentgo after-turn failed: %v; record step failure: %w", err, recordErr) + } + return err + } + } + if _, err := a.runtimeRecorder.Record(ctx, "step.completed", map[string]any{"turn": turn.TurnIndex}, stepID, ""); err != nil { + return fmt.Errorf("record agentgo step completion: %w", err) + } + a.clearStep() + return nil +} + +func (a *Adapter) loadMessages(ctx context.Context) ([]agentgo.AgentMessage, error) { + var messages []agentgo.AgentMessage + for event, err := range a.stateRecorder.Store().Load(ctx, a.stateRecorder.Stream(), -1) { + if err != nil { + return nil, fmt.Errorf("load agentgo native state: %w", err) + } + if event.EventType != "framework.agentgo.message.committed" { + continue + } + kind, ok := event.Payload["message_type"].(string) + if !ok { + return nil, errors.New("agentgo state event is missing message_type") + } + encoded, ok := event.Payload["message_json"].(string) + if !ok { + return nil, errors.New("agentgo state event is missing message_json") + } + message, err := a.codec.Decode(kind, json.RawMessage(encoded)) + if err != nil { + return nil, fmt.Errorf("decode agentgo message type %q: %w", kind, err) + } + messages = append(messages, message) + } + return messages, nil +} + +func (a *Adapter) nextStep() string { + a.mu.Lock() + defer a.mu.Unlock() + a.currentStep = agentledger.NewID() + return a.currentStep +} + +func (a *Adapter) stepID() string { + a.mu.Lock() + defer a.mu.Unlock() + if a.currentStep == "" { + a.currentStep = agentledger.NewID() + } + return a.currentStep +} + +func (a *Adapter) clearStep() { + a.mu.Lock() + defer a.mu.Unlock() + a.currentStep = "" +} + +type ConcreteMessageCodec struct{} + +func (ConcreteMessageCodec) Encode(message agentgo.AgentMessage) (string, json.RawMessage, error) { + switch value := message.(type) { + case agentgo.Message: + data, err := json.Marshal(value) + return "message", data, err + case *agentgo.Message: + data, err := json.Marshal(value) + return "message", data, err + default: + return "", nil, fmt.Errorf("unsupported custom AgentMessage %T; configure MessageCodec", message) + } +} + +func (ConcreteMessageCodec) Decode(kind string, data json.RawMessage) (agentgo.AgentMessage, error) { + if kind != "message" { + return nil, fmt.Errorf("unsupported message type %q", kind) + } + var message agentgo.Message + if err := json.Unmarshal(data, &message); err != nil { + return nil, err + } + return message, nil +} diff --git a/go/adapters/agentgo/adapter_test.go b/go/adapters/agentgo/adapter_test.go new file mode 100644 index 0000000..da493b8 --- /dev/null +++ b/go/adapters/agentgo/adapter_test.go @@ -0,0 +1,149 @@ +package agentgoadapter + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/compforge/agent-ledger/go" + "github.com/compforge/agentgo" +) + +func TestNativeMessagesRestoreIntoAgentGo(t *testing.T) { + ctx := context.Background() + store := agentledger.NewMemoryEventStore() + adapter := newTestAdapter(t, ctx, store) + if err := adapter.commitMessage(agentgo.UserMsg("hello")); err != nil { + t.Fatalf("commit message: %v", err) + } + + agent := agentgo.NewAgent() + if err := adapter.Restore(ctx, agent); err != nil { + t.Fatalf("restore: %v", err) + } + messages := agent.Messages() + if len(messages) != 1 || messages[0].TextContent() != "hello" { + t.Fatalf("restored messages = %#v", messages) + } +} + +func TestWrappedModelFailsClosedWhenPrewriteFails(t *testing.T) { + ctx := context.Background() + base := agentledger.NewMemoryEventStore() + store := failingStore{EventStore: base, eventType: "model.requested"} + adapter := newTestAdapter(t, ctx, store) + inner := &countingModel{} + _, err := adapter.WrapModel(inner).Generate(ctx, []agentgo.Message{agentgo.UserMsg("hello")}, nil) + if err == nil { + t.Fatal("generate succeeded despite prewrite failure") + } + if inner.called { + t.Fatal("inner model was called before durable prewrite") + } +} + +func TestToolMiddlewareWritesBeforeExecute(t *testing.T) { + ctx := context.Background() + store := agentledger.NewMemoryEventStore() + adapter := newTestAdapter(t, ctx, store) + executed := false + call := agentgo.ToolCall{ID: "tool-1", Name: "read", Args: json.RawMessage(`{"path":"README.md"}`)} + _, err := adapter.ToolMiddleware()(ctx, call, func(context.Context, json.RawMessage) (json.RawMessage, error) { + executed = true + return json.RawMessage(`{"ok":true}`), nil + }) + if err != nil { + t.Fatalf("execute middleware: %v", err) + } + if !executed { + t.Fatal("tool was not executed") + } + + var types []string + for event, loadErr := range store.Load(ctx, agentledger.EventStream{SessionID: "session", StreamID: "run"}, -1) { + if loadErr != nil { + t.Fatalf("load events: %v", loadErr) + } + types = append(types, event.EventType) + } + if len(types) != 2 || types[0] != "tool.requested" || types[1] != "tool.completed" { + t.Fatalf("event types = %v", types) + } +} + +func TestWrappedModelRecordsPhysicalAttempt(t *testing.T) { + ctx := context.Background() + store := agentledger.NewMemoryEventStore() + adapter := newTestAdapter(t, ctx, store) + model := adapter.WrapModel(fakeModel{}) + if _, err := model.Generate(ctx, []agentgo.Message{agentgo.UserMsg("hello")}, nil); err != nil { + t.Fatalf("generate: %v", err) + } + + var types []string + for event, loadErr := range store.Load(ctx, agentledger.EventStream{SessionID: "session", StreamID: "run"}, -1) { + if loadErr != nil { + t.Fatalf("load events: %v", loadErr) + } + types = append(types, event.EventType) + } + if len(types) != 2 || types[0] != "model.requested" || types[1] != "model.completed" { + t.Fatalf("event types = %v", types) + } +} + +func newTestAdapter(t *testing.T, ctx context.Context, store agentledger.EventStore) *Adapter { + t.Helper() + adapter, err := New(ctx, Config{ + Store: store, SessionID: "session", RunID: "run", NativeSessionID: "native", + Actor: agentledger.Actor{Type: "agent", ID: "agentgo", Framework: "agentgo"}, + OperationTimeout: time.Second, + }) + if err != nil { + t.Fatalf("new adapter: %v", err) + } + return adapter +} + +type fakeModel struct{} + +func (fakeModel) Generate(context.Context, []agentgo.Message, []agentgo.ToolSpec, ...agentgo.CallOption) (*agentgo.LLMResponse, error) { + return &agentgo.LLMResponse{Message: agentgo.Message{Role: agentgo.RoleAssistant, Content: []agentgo.ContentBlock{agentgo.TextBlock("done")}, Timestamp: time.Now()}}, nil +} + +func (fakeModel) GenerateStream(context.Context, []agentgo.Message, []agentgo.ToolSpec, ...agentgo.CallOption) (<-chan agentgo.StreamEvent, error) { + stream := make(chan agentgo.StreamEvent) + close(stream) + return stream, nil +} + +func (fakeModel) SupportsTools() bool { return true } + +type countingModel struct{ called bool } + +func (m *countingModel) Generate(context.Context, []agentgo.Message, []agentgo.ToolSpec, ...agentgo.CallOption) (*agentgo.LLMResponse, error) { + m.called = true + return &agentgo.LLMResponse{}, nil +} + +func (*countingModel) GenerateStream(context.Context, []agentgo.Message, []agentgo.ToolSpec, ...agentgo.CallOption) (<-chan agentgo.StreamEvent, error) { + return nil, errors.New("not implemented") +} + +func (*countingModel) SupportsTools() bool { return true } + +type failingStore struct { + agentledger.EventStore + eventType string +} + +func (s failingStore) Append(ctx context.Context, stream agentledger.EventStream, expectedVersion int64, appendID string, events ...agentledger.ProposedEvent) (agentledger.CommitReceipt, error) { + for _, event := range events { + if event.EventType == s.eventType { + return agentledger.CommitReceipt{}, errors.New("prewrite failed") + } + } + return s.EventStore.Append(ctx, stream, expectedVersion, appendID, events...) +} diff --git a/go/adapters/agentgo/model.go b/go/adapters/agentgo/model.go new file mode 100644 index 0000000..275658b --- /dev/null +++ b/go/adapters/agentgo/model.go @@ -0,0 +1,105 @@ +package agentgoadapter + +import ( + "context" + "errors" + "fmt" + + "github.com/compforge/agentgo" +) + +type recordingModel struct { + inner agentgo.ChatModel + adapter *Adapter +} + +func (m *recordingModel) Generate(ctx context.Context, messages []agentgo.Message, tools []agentgo.ToolSpec, options ...agentgo.CallOption) (*agentgo.LLMResponse, error) { + attempt, err := m.adapter.runtimeRecorder.BeforeModelCall(ctx, m.adapter.stepID(), modelPayload(messages, tools)) + if err != nil { + return nil, fmt.Errorf("record agentgo model request: %w", err) + } + response, callErr := m.inner.Generate(ctx, messages, tools, options...) + if callErr != nil { + if err := m.adapter.runtimeRecorder.ModelFailed(ctx, attempt, callErr); err != nil { + return nil, fmt.Errorf("record agentgo model failure: %w", err) + } + return nil, callErr + } + if err := m.adapter.runtimeRecorder.ModelCompleted(ctx, attempt, map[string]any{"message": response.Message}); err != nil { + return nil, fmt.Errorf("record agentgo model result: %w", err) + } + return response, nil +} + +func (m *recordingModel) GenerateStream(ctx context.Context, messages []agentgo.Message, tools []agentgo.ToolSpec, options ...agentgo.CallOption) (<-chan agentgo.StreamEvent, error) { + attempt, err := m.adapter.runtimeRecorder.BeforeModelCall(ctx, m.adapter.stepID(), modelPayload(messages, tools)) + if err != nil { + return nil, fmt.Errorf("record agentgo model request: %w", err) + } + source, callErr := m.inner.GenerateStream(ctx, messages, tools, options...) + if callErr != nil { + if err := m.adapter.runtimeRecorder.ModelFailed(ctx, attempt, callErr); err != nil { + return nil, fmt.Errorf("record agentgo model failure: %w", err) + } + return nil, callErr + } + output := make(chan agentgo.StreamEvent, 16) + go func() { + defer close(output) + terminal := false + for event := range source { + if event.Type == agentgo.StreamEventDone { + terminal = true + if err := m.adapter.runtimeRecorder.ModelCompleted(ctx, attempt, map[string]any{"message": event.Message}); err != nil { + event = agentgo.StreamEvent{Type: agentgo.StreamEventError, Err: fmt.Errorf("record agentgo model result: %w", err)} + } + } else if event.Type == agentgo.StreamEventError { + terminal = true + failure := event.Err + if failure == nil { + failure = errors.New("agentgo model stream failed") + } + if err := m.adapter.runtimeRecorder.ModelFailed(ctx, attempt, failure); err != nil { + event.Err = fmt.Errorf("record agentgo model failure: %w", err) + } + } + select { + case output <- event: + case <-ctx.Done(): + return + } + } + if !terminal && ctx.Err() == nil { + failure := errors.New("agentgo model stream closed without terminal event") + if err := m.adapter.runtimeRecorder.ModelFailed(ctx, attempt, failure); err != nil { + failure = fmt.Errorf("%v; record failure: %w", failure, err) + } + output <- agentgo.StreamEvent{Type: agentgo.StreamEventError, Err: failure} + } + }() + return output, nil +} + +func (m *recordingModel) SupportsTools() bool { return m.inner.SupportsTools() } + +func (m *recordingModel) ProviderName() string { + if named, ok := m.inner.(agentgo.ProviderNamer); ok { + return named.ProviderName() + } + return "" +} + +func (m *recordingModel) ModelName() string { + if named, ok := m.inner.(agentgo.ModelNamer); ok { + return named.ModelName() + } + return "" +} + +func modelPayload(messages []agentgo.Message, tools []agentgo.ToolSpec) map[string]any { + toolNames := make([]string, 0, len(tools)) + for _, tool := range tools { + toolNames = append(toolNames, tool.Name) + } + return map[string]any{"messages": messages, "tools": toolNames} +} diff --git a/go/canonical.go b/go/canonical.go new file mode 100644 index 0000000..845e685 --- /dev/null +++ b/go/canonical.go @@ -0,0 +1,23 @@ +package agentledger + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer" +) + +func CanonicalAppendDigest(events []ProposedEvent) (string, error) { + encoded, err := json.Marshal(events) + if err != nil { + return "", fmt.Errorf("marshal append batch: %w", err) + } + canonical, err := jsoncanonicalizer.Transform(encoded) + if err != nil { + return "", fmt.Errorf("canonicalize append batch: %w", err) + } + digest := sha256.Sum256(canonical) + return hex.EncodeToString(digest[:]), nil +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..a1abc91 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,8 @@ +module github.com/compforge/agent-ledger/go + +go 1.25.0 + +require ( + github.com/compforge/agentgo v0.0.1 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..1d61692 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,4 @@ +github.com/compforge/agentgo v0.0.1 h1:e3JiF7za1xN9NdGw4M8BFH9vdVwMJuD/K55eNSclK1I= +github.com/compforge/agentgo v0.0.1/go.mod h1:5EkjADRpln5pwK2wd1cNwUwndq2VQDbeefybMVNMhpk= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= diff --git a/go/id.go b/go/id.go new file mode 100644 index 0000000..583d476 --- /dev/null +++ b/go/id.go @@ -0,0 +1,19 @@ +package agentledger + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +func NewID() string { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + panic(fmt.Errorf("generate agent ledger id: %w", err)) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value[:]) + return fmt.Sprintf("%s-%s-%s-%s-%s", + encoded[0:8], encoded[8:12], encoded[12:16], encoded[16:20], encoded[20:32]) +} diff --git a/go/memory_store.go b/go/memory_store.go new file mode 100644 index 0000000..dcff028 --- /dev/null +++ b/go/memory_store.go @@ -0,0 +1,191 @@ +package agentledger + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "iter" + "strconv" + "sync" + "time" +) + +var ( + ErrStreamConflict = errors.New("event stream version conflict") + ErrIdempotencyViolation = errors.New("append id reused with different content") + ErrDuplicateEvent = errors.New("duplicate event id") +) + +type MemoryEventStore struct { + mu sync.Mutex + streams map[string][]StoredEvent + sessions map[string][]StoredEvent + receipts map[string]CommitReceipt + eventIDs map[string]struct{} +} + +func NewMemoryEventStore() *MemoryEventStore { + return &MemoryEventStore{ + streams: make(map[string][]StoredEvent), + sessions: make(map[string][]StoredEvent), + receipts: make(map[string]CommitReceipt), + eventIDs: make(map[string]struct{}), + } +} + +func (s *MemoryEventStore) Append( + ctx context.Context, + stream EventStream, + expectedVersion int64, + appendID string, + events ...ProposedEvent, +) (CommitReceipt, error) { + if err := ctx.Err(); err != nil { + return CommitReceipt{}, err + } + if len(events) == 0 { + return CommitReceipt{}, errors.New("append requires at least one event") + } + batch, err := clone(events) + if err != nil { + return CommitReceipt{}, fmt.Errorf("snapshot append batch: %w", err) + } + batchEventIDs := make(map[string]struct{}, len(batch)) + for _, event := range batch { + if event.SessionID != stream.SessionID { + return CommitReceipt{}, errors.New("all events must belong to the target stream's session") + } + if _, duplicate := batchEventIDs[event.EventID]; duplicate { + return CommitReceipt{}, fmt.Errorf("%w: %s", ErrDuplicateEvent, event.EventID) + } + batchEventIDs[event.EventID] = struct{}{} + } + digest, err := CanonicalAppendDigest(batch) + if err != nil { + return CommitReceipt{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + receiptKey := streamKey(stream) + "\x00" + appendID + if receipt, ok := s.receipts[receiptKey]; ok { + if receipt.Digest != digest { + return CommitReceipt{}, ErrIdempotencyViolation + } + return cloneOne(receipt) + } + key := streamKey(stream) + current := s.streams[key] + if int64(len(current))-1 != expectedVersion { + return CommitReceipt{}, fmt.Errorf("%w: expected %d, actual %d", ErrStreamConflict, expectedVersion, len(current)-1) + } + for _, event := range batch { + if _, exists := s.eventIDs[stream.SessionID+"\x00"+event.EventID]; exists { + return CommitReceipt{}, fmt.Errorf("%w: %s", ErrDuplicateEvent, event.EventID) + } + } + + committedAt := time.Now().UTC().Format(time.RFC3339Nano) + session := s.sessions[stream.SessionID] + stored := make([]StoredEvent, 0, len(batch)) + for index, event := range batch { + stored = append(stored, StoredEvent{ + ProposedEvent: event, + StreamID: stream.StreamID, + StreamVersion: expectedVersion + int64(index) + 1, + CommitCursor: strconv.Itoa(len(session) + index), + CommittedAt: committedAt, + }) + } + receipt := CommitReceipt{ + Stream: stream, + AppendID: appendID, + Digest: digest, + FirstVersion: stored[0].StreamVersion, + LastVersion: stored[len(stored)-1].StreamVersion, + FirstCursor: stored[0].CommitCursor, + LastCursor: stored[len(stored)-1].CommitCursor, + CommittedAt: committedAt, + } + for _, event := range stored { + receipt.EventIDs = append(receipt.EventIDs, event.EventID) + s.eventIDs[stream.SessionID+"\x00"+event.EventID] = struct{}{} + } + s.streams[key] = append(current, stored...) + s.sessions[stream.SessionID] = append(session, stored...) + s.receipts[receiptKey] = receipt + return cloneOne(receipt) +} + +func (s *MemoryEventStore) Load(ctx context.Context, stream EventStream, afterVersion int64) iter.Seq2[StoredEvent, error] { + return func(yield func(StoredEvent, error) bool) { + s.mu.Lock() + events, err := clone(s.streams[streamKey(stream)]) + s.mu.Unlock() + if err != nil { + yield(StoredEvent{}, err) + return + } + for _, event := range events { + if err := ctx.Err(); err != nil { + yield(StoredEvent{}, err) + return + } + if event.StreamVersion > afterVersion && !yield(event, nil) { + return + } + } + } +} + +func (s *MemoryEventStore) ScanSession(ctx context.Context, sessionID, afterCursor string) iter.Seq2[StoredEvent, error] { + return func(yield func(StoredEvent, error) bool) { + cursor := -1 + var err error + if afterCursor != "" { + cursor, err = strconv.Atoi(afterCursor) + } + if err != nil || cursor < -1 { + yield(StoredEvent{}, fmt.Errorf("invalid cursor %q", afterCursor)) + return + } + s.mu.Lock() + events, cloneErr := clone(s.sessions[sessionID]) + s.mu.Unlock() + if cloneErr != nil { + yield(StoredEvent{}, cloneErr) + return + } + for _, event := range events { + if err := ctx.Err(); err != nil { + yield(StoredEvent{}, err) + return + } + position, _ := strconv.Atoi(event.CommitCursor) + if position > cursor && !yield(event, nil) { + return + } + } + } +} + +func streamKey(stream EventStream) string { + return stream.SessionID + "\x00" + stream.StreamID +} + +func clone[T any](value T) (T, error) { + var result T + encoded, err := json.Marshal(value) + if err != nil { + return result, err + } + if err := json.Unmarshal(encoded, &result); err != nil { + return result, err + } + return result, nil +} + +func cloneOne[T any](value T) (T, error) { + return clone(value) +} diff --git a/go/recorder.go b/go/recorder.go new file mode 100644 index 0000000..d90dba3 --- /dev/null +++ b/go/recorder.go @@ -0,0 +1,125 @@ +package agentledger + +import ( + "context" + "fmt" + "sync" +) + +type RecorderOptions struct { + Store EventStore + SessionID string + RunID string + StreamID string + Actor Actor + ExpectedVersion *int64 +} + +type SessionRecorder struct { + store EventStore + stream EventStream + runID string + actor Actor + expectedVersion int64 + mu sync.Mutex +} + +func NewSessionRecorder(options RecorderOptions) *SessionRecorder { + streamID := options.StreamID + if streamID == "" { + streamID = options.RunID + } + expectedVersion := int64(-1) + if options.ExpectedVersion != nil { + expectedVersion = *options.ExpectedVersion + } + return &SessionRecorder{ + store: options.Store, + stream: EventStream{SessionID: options.SessionID, StreamID: streamID}, + runID: options.RunID, + actor: options.Actor, + expectedVersion: expectedVersion, + } +} + +func ResumeRecorder(ctx context.Context, options RecorderOptions) (*SessionRecorder, error) { + recorder := NewSessionRecorder(options) + expectedVersion := int64(-1) + for event, err := range options.Store.Load(ctx, recorder.stream, -1) { + if err != nil { + return nil, fmt.Errorf("load recorder stream: %w", err) + } + expectedVersion = event.StreamVersion + } + recorder.expectedVersion = expectedVersion + return recorder, nil +} + +func (r *SessionRecorder) Stream() EventStream { return r.stream } +func (r *SessionRecorder) RunID() string { return r.runID } +func (r *SessionRecorder) Store() EventStore { return r.store } + +func (r *SessionRecorder) Record(ctx context.Context, eventType string, payload map[string]any, stepID, attemptID string) (StoredEvent, error) { + r.mu.Lock() + defer r.mu.Unlock() + event := NewEvent(eventType, r.stream.SessionID, r.runID, r.actor) + event.Payload = payload + event.StepID = stepID + event.AttemptID = attemptID + receipt, err := r.store.Append(ctx, r.stream, r.expectedVersion, NewID(), event) + if err != nil { + return StoredEvent{}, err + } + r.expectedVersion = receipt.LastVersion + return StoredEvent{ + ProposedEvent: event, + StreamID: r.stream.StreamID, + StreamVersion: receipt.FirstVersion, + CommitCursor: receipt.FirstCursor, + CommittedAt: receipt.CommittedAt, + }, nil +} + +func (r *SessionRecorder) BeforeModelCall(ctx context.Context, stepID string, payload map[string]any) (AttemptHandle, error) { + return r.beforeCall(ctx, "model", stepID, payload) +} + +func (r *SessionRecorder) BeforeToolCall(ctx context.Context, stepID string, payload map[string]any) (AttemptHandle, error) { + return r.beforeCall(ctx, "tool", stepID, payload) +} + +func (r *SessionRecorder) ModelCompleted(ctx context.Context, attempt AttemptHandle, payload map[string]any) error { + _, err := r.Record(ctx, "model.completed", payload, attempt.StepID, attempt.AttemptID) + return err +} + +func (r *SessionRecorder) ModelFailed(ctx context.Context, attempt AttemptHandle, failure error) error { + _, err := r.Record(ctx, "model.failed", errorPayload(failure), attempt.StepID, attempt.AttemptID) + return err +} + +func (r *SessionRecorder) ToolCompleted(ctx context.Context, attempt AttemptHandle, payload map[string]any) error { + _, err := r.Record(ctx, "tool.completed", payload, attempt.StepID, attempt.AttemptID) + return err +} + +func (r *SessionRecorder) ToolFailed(ctx context.Context, attempt AttemptHandle, failure error) error { + _, err := r.Record(ctx, "tool.failed", errorPayload(failure), attempt.StepID, attempt.AttemptID) + return err +} + +func (r *SessionRecorder) beforeCall(ctx context.Context, kind, stepID string, payload map[string]any) (AttemptHandle, error) { + attemptID := NewID() + event, err := r.Record(ctx, kind+".requested", payload, stepID, attemptID) + if err != nil { + return AttemptHandle{}, err + } + return AttemptHandle{Kind: kind, StepID: stepID, AttemptID: attemptID, RequestedEventID: event.EventID}, nil +} + +func errorPayload(err error) map[string]any { + if err == nil { + return map[string]any{"error": "unknown error"} + } + return map[string]any{"error": err.Error()} +} diff --git a/go/store.go b/go/store.go new file mode 100644 index 0000000..99f0fe8 --- /dev/null +++ b/go/store.go @@ -0,0 +1,12 @@ +package agentledger + +import ( + "context" + "iter" +) + +type EventStore interface { + Append(ctx context.Context, stream EventStream, expectedVersion int64, appendID string, events ...ProposedEvent) (CommitReceipt, error) + Load(ctx context.Context, stream EventStream, afterVersion int64) iter.Seq2[StoredEvent, error] + ScanSession(ctx context.Context, sessionID, afterCursor string) iter.Seq2[StoredEvent, error] +} diff --git a/go/store_test.go b/go/store_test.go new file mode 100644 index 0000000..35ad9ba --- /dev/null +++ b/go/store_test.go @@ -0,0 +1,88 @@ +package agentledger + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" +) + +func TestEventStreamCanSpanSemanticRuns(t *testing.T) { + ctx := context.Background() + store := NewMemoryEventStore() + stream := EventStream{SessionID: "session", StreamID: "framework/pi/native"} + first := NewEvent("framework.state.recorded", "session", "run-1", Actor{Type: "agent", ID: "pi"}) + second := NewEvent("framework.state.recorded", "session", "run-2", Actor{Type: "agent", ID: "pi"}) + if _, err := store.Append(ctx, stream, -1, "append-1", first); err != nil { + t.Fatalf("append first: %v", err) + } + if _, err := store.Append(ctx, stream, 0, "append-2", second); err != nil { + t.Fatalf("append second: %v", err) + } + + var runs []string + for event, err := range store.Load(ctx, stream, -1) { + if err != nil { + t.Fatalf("load: %v", err) + } + runs = append(runs, event.RunID) + } + if len(runs) != 2 || runs[0] != "run-1" || runs[1] != "run-2" { + t.Fatalf("runs = %v", runs) + } +} + +func TestAppendDigestMatchesCrossLanguageVector(t *testing.T) { + data, err := os.ReadFile("../conformance/vectors/append.json") + if err != nil { + t.Fatalf("read vector: %v", err) + } + var vector struct { + Events []ProposedEvent `json:"events"` + SHA256 string `json:"sha256"` + } + if err := json.Unmarshal(data, &vector); err != nil { + t.Fatalf("decode vector: %v", err) + } + digest, err := CanonicalAppendDigest(vector.Events) + if err != nil { + t.Fatalf("digest: %v", err) + } + if digest != vector.SHA256 { + t.Fatalf("digest = %s, want %s", digest, vector.SHA256) + } +} + +func TestEventIDUniquenessIsScopedToSession(t *testing.T) { + ctx := context.Background() + store := NewMemoryEventStore() + first := NewEvent("test.recorded", "session-1", "run-1", Actor{Type: "agent", ID: "test"}) + first.EventID = "shared-event-id" + second := first + second.SessionID = "session-2" + second.RunID = "run-2" + + if _, err := store.Append(ctx, EventStream{SessionID: "session-1", StreamID: "run-1"}, -1, "append-1", first); err != nil { + t.Fatalf("append first session: %v", err) + } + if _, err := store.Append(ctx, EventStream{SessionID: "session-2", StreamID: "run-2"}, -1, "append-2", second); err != nil { + t.Fatalf("append second session: %v", err) + } +} + +func TestDuplicateEventIDRejectsWholeBatch(t *testing.T) { + ctx := context.Background() + store := NewMemoryEventStore() + stream := EventStream{SessionID: "session", StreamID: "run"} + first := NewEvent("test.recorded", "session", "run", Actor{Type: "agent", ID: "test"}) + second := first + second.EventType = "test.second" + + if _, err := store.Append(ctx, stream, -1, "append", first, second); !errors.Is(err, ErrDuplicateEvent) { + t.Fatalf("append error = %v, want ErrDuplicateEvent", err) + } + for event, err := range store.Load(ctx, stream, -1) { + t.Fatalf("unexpected stored event %#v, error %v", event, err) + } +} diff --git a/go/types.go b/go/types.go new file mode 100644 index 0000000..8547184 --- /dev/null +++ b/go/types.go @@ -0,0 +1,88 @@ +package agentledger + +import "time" + +type Actor struct { + Type string `json:"type"` + ID string `json:"id"` + Framework string `json:"framework,omitempty"` +} + +type EventStream struct { + SessionID string `json:"session_id"` + StreamID string `json:"stream_id"` +} + +type ProposedEvent struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + Actor Actor `json:"actor"` + OccurredAt string `json:"occurred_at"` + StepID string `json:"step_id,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + ParentRunID string `json:"parent_run_id,omitempty"` + CausedByEventID string `json:"caused_by_event_id,omitempty"` + Payload map[string]any `json:"payload"` + Extensions map[string]any `json:"extensions"` +} + +type StoredEvent struct { + ProposedEvent + StreamID string `json:"stream_id"` + StreamVersion int64 `json:"stream_version"` + CommitCursor string `json:"commit_cursor"` + CommittedAt string `json:"committed_at"` +} + +type CommitReceipt struct { + Stream EventStream `json:"stream"` + AppendID string `json:"append_id"` + Digest string `json:"digest"` + FirstVersion int64 `json:"first_version"` + LastVersion int64 `json:"last_version"` + FirstCursor string `json:"first_cursor"` + LastCursor string `json:"last_cursor"` + EventIDs []string `json:"event_ids"` + CommittedAt string `json:"committed_at"` +} + +type AdapterCapabilities struct { + ModelPrewrite string `json:"model_prewrite"` + ToolPrewrite string `json:"tool_prewrite"` + OutcomeGate string `json:"outcome_gate"` + Recovery string `json:"recovery"` + PreservesNativeState bool `json:"preserves_native_state"` +} + +type AdapterDescriptor struct { + SchemaVersion string `json:"schema_version"` + AdapterID string `json:"adapter_id"` + AdapterVersion string `json:"adapter_version"` + Framework string `json:"framework"` + FrameworkVersion string `json:"framework_version"` + Capabilities AdapterCapabilities `json:"capabilities"` +} + +type AttemptHandle struct { + Kind string + StepID string + AttemptID string + RequestedEventID string +} + +func NewEvent(eventType, sessionID, runID string, actor Actor) ProposedEvent { + return ProposedEvent{ + SchemaVersion: "1.0", + EventID: NewID(), + EventType: eventType, + SessionID: sessionID, + RunID: runID, + Actor: actor, + OccurredAt: time.Now().UTC().Format(time.RFC3339Nano), + Payload: map[string]any{}, + Extensions: map[string]any{}, + } +} diff --git a/python/Makefile b/python/Makefile new file mode 100644 index 0000000..a5ca90f --- /dev/null +++ b/python/Makefile @@ -0,0 +1,16 @@ +.PHONY: fix lint test build + +fix: + uv run --no-sync ruff format . + uv run --no-sync ruff check --fix . + +lint: + uv run --no-sync ruff format --check . + uv run --no-sync ruff check . + MYPYPATH=src uv run --no-sync mypy + +test: + PYTHONPATH=src uv run --no-sync pytest + +build: + uv build diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..d3896a6 --- /dev/null +++ b/python/README.md @@ -0,0 +1,4 @@ +# Agent Ledger Python SDK + +Python implementation of the Agent Ledger event, recorder, and store contracts. The project-wide +architecture and framework adapter model are documented in the repository root README and specs. diff --git a/examples/plain_loop.py b/python/examples/plain_loop.py similarity index 100% rename from examples/plain_loop.py rename to python/examples/plain_loop.py diff --git a/pyproject.toml b/python/pyproject.toml similarity index 92% rename from pyproject.toml rename to python/pyproject.toml index 3559181..823c53a 100644 --- a/pyproject.toml +++ b/python/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Typing :: Typed", ] -dependencies = ["pydantic>=2.10,<3"] +dependencies = ["pydantic>=2.10,<3", "rfc8785>=0.1.4,<1"] [project.urls] Repository = "https://github.com/compforge/agent-ledger" @@ -69,3 +69,7 @@ select = ["E", "F", "I", "UP", "B", "ASYNC", "RUF"] python_version = "3.11" strict = true packages = ["agent_ledger"] + +[[tool.mypy.overrides]] +module = "rfc8785" +ignore_missing_imports = true diff --git a/src/agent_ledger/__init__.py b/python/src/agent_ledger/__init__.py similarity index 82% rename from src/agent_ledger/__init__.py rename to python/src/agent_ledger/__init__.py index b7ba439..b739d7b 100644 --- a/src/agent_ledger/__init__.py +++ b/python/src/agent_ledger/__init__.py @@ -1,3 +1,9 @@ +from agent_ledger.adapters import ( + AdapterCapabilities, + AdapterDescriptor, + RecordingGuarantee, + RecoveryMode, +) from agent_ledger.artifacts import ArtifactStore, MemoryArtifactStore from agent_ledger.errors import ( AgentLedgerError, @@ -19,9 +25,9 @@ Actor, ArtifactRef, CommitReceipt, + EventStream, EventType, ProposedEvent, - RunStream, StoredEvent, ) from agent_ledger.recorder import AttemptHandle, SessionRecorder diff --git a/python/src/agent_ledger/adapters.py b/python/src/agent_ledger/adapters.py new file mode 100644 index 0000000..1d0b2c1 --- /dev/null +++ b/python/src/agent_ledger/adapters.py @@ -0,0 +1,38 @@ +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class RecordingGuarantee(StrEnum): + STRICT = "strict" + BEST_EFFORT = "best_effort" + UNSUPPORTED = "unsupported" + + +class RecoveryMode(StrEnum): + NATIVE_STORE = "native_store" + SNAPSHOT = "snapshot" + CHECKPOINT = "checkpoint" + UNSUPPORTED = "unsupported" + + +class AdapterCapabilities(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + model_prewrite: RecordingGuarantee + tool_prewrite: RecordingGuarantee + outcome_gate: RecordingGuarantee + recovery: RecoveryMode + preserves_native_state: bool + + +class AdapterDescriptor(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + schema_version: Literal["1.0"] = "1.0" + adapter_id: str = Field(min_length=1) + adapter_version: str = Field(min_length=1) + framework: str = Field(min_length=1) + framework_version: str = Field(min_length=1) + capabilities: AdapterCapabilities diff --git a/src/agent_ledger/artifacts.py b/python/src/agent_ledger/artifacts.py similarity index 100% rename from src/agent_ledger/artifacts.py rename to python/src/agent_ledger/artifacts.py diff --git a/src/agent_ledger/errors.py b/python/src/agent_ledger/errors.py similarity index 100% rename from src/agent_ledger/errors.py rename to python/src/agent_ledger/errors.py diff --git a/src/agent_ledger/frameworks/__init__.py b/python/src/agent_ledger/frameworks/__init__.py similarity index 67% rename from src/agent_ledger/frameworks/__init__.py rename to python/src/agent_ledger/frameworks/__init__.py index 56be04d..c21c7b9 100644 --- a/src/agent_ledger/frameworks/__init__.py +++ b/python/src/agent_ledger/frameworks/__init__.py @@ -1,4 +1,4 @@ -from agent_ledger.frameworks.base import FrameworkProfile +from agent_ledger.frameworks.base import StateAdapter from agent_ledger.frameworks.plain_loop import ( PlainLoopContext, PlainLoopProfile, diff --git a/src/agent_ledger/frameworks/base.py b/python/src/agent_ledger/frameworks/base.py similarity index 63% rename from src/agent_ledger/frameworks/base.py rename to python/src/agent_ledger/frameworks/base.py index 8c0a5a8..6df0e33 100644 --- a/src/agent_ledger/frameworks/base.py +++ b/python/src/agent_ledger/frameworks/base.py @@ -1,15 +1,17 @@ from collections.abc import Sequence from typing import Generic, Protocol, TypeVar +from agent_ledger.adapters import AdapterDescriptor from agent_ledger.models import StoredEvent from agent_ledger.recorder import SessionRecorder ContextT = TypeVar("ContextT") -class FrameworkProfile(Protocol, Generic[ContextT]): - profile_id: str - profile_version: str +class StateAdapter(Protocol, Generic[ContextT]): + """Persists and rebuilds framework-native state without defining hook APIs.""" + + descriptor: AdapterDescriptor async def save(self, recorder: SessionRecorder, context: ContextT) -> StoredEvent: ... diff --git a/src/agent_ledger/frameworks/plain_loop.py b/python/src/agent_ledger/frameworks/plain_loop.py similarity index 84% rename from src/agent_ledger/frameworks/plain_loop.py rename to python/src/agent_ledger/frameworks/plain_loop.py index f1512b2..8569441 100644 --- a/src/agent_ledger/frameworks/plain_loop.py +++ b/python/src/agent_ledger/frameworks/plain_loop.py @@ -5,6 +5,12 @@ from pydantic import BaseModel, ConfigDict, Field +from agent_ledger.adapters import ( + AdapterCapabilities, + AdapterDescriptor, + RecordingGuarantee, + RecoveryMode, +) from agent_ledger.inspection import UnresolvedAttempt, inspect_session from agent_ledger.models import EventType, StoredEvent from agent_ledger.recorder import SessionRecorder @@ -31,6 +37,19 @@ class PlainLoopProfile: profile_id = "plain-loop" profile_version = "1" + descriptor = AdapterDescriptor( + adapter_id=profile_id, + adapter_version=profile_version, + framework="plain-loop", + framework_version="*", + capabilities=AdapterCapabilities( + model_prewrite=RecordingGuarantee.STRICT, + tool_prewrite=RecordingGuarantee.STRICT, + outcome_gate=RecordingGuarantee.STRICT, + recovery=RecoveryMode.SNAPSHOT, + preserves_native_state=True, + ), + ) async def save( self, diff --git a/src/agent_ledger/inspection.py b/python/src/agent_ledger/inspection.py similarity index 93% rename from src/agent_ledger/inspection.py rename to python/src/agent_ledger/inspection.py index 9d883f8..028d9a8 100644 --- a/src/agent_ledger/inspection.py +++ b/python/src/agent_ledger/inspection.py @@ -10,7 +10,7 @@ class StreamGap(BaseModel): model_config = ConfigDict(frozen=True) - run_id: str + stream_id: str expected_version: int actual_version: int @@ -49,16 +49,16 @@ def inspect_session(events: list[StoredEvent]) -> SessionInspection: edges: dict[tuple[str, str], RunEdge] = {} for event in events: - expected = versions[event.run_id] + 1 + expected = versions[event.stream_id] + 1 if event.stream_version != expected: gaps.append( StreamGap( - run_id=event.run_id, + stream_id=event.stream_id, expected_version=expected, actual_version=event.stream_version, ) ) - versions[event.run_id] = event.stream_version + versions[event.stream_id] = event.stream_version if event.parent_run_id is not None and event.caused_by_event_id is not None: edges[(event.parent_run_id, event.run_id)] = RunEdge( diff --git a/src/agent_ledger/models.py b/python/src/agent_ledger/models.py similarity index 91% rename from src/agent_ledger/models.py rename to python/src/agent_ledger/models.py index 3f3b0d5..5da99ca 100644 --- a/src/agent_ledger/models.py +++ b/python/src/agent_ledger/models.py @@ -1,12 +1,12 @@ from __future__ import annotations import hashlib -import json from datetime import UTC, datetime from enum import StrEnum from typing import Any, Literal, Self from uuid import uuid4 +import rfc8785 from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator SCHEMA_VERSION: Literal["1.0"] = "1.0" @@ -53,11 +53,11 @@ class ArtifactRef(BaseModel): content_type: str = Field(min_length=1) -class RunStream(BaseModel): +class EventStream(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") session_id: str = Field(min_length=1) - run_id: str = Field(min_length=1) + stream_id: str = Field(min_length=1) class ProposedEvent(BaseModel): @@ -90,12 +90,9 @@ def validate_causal_parent(self) -> Self: raise ValueError("parent_run_id and caused_by_event_id must be set together") return self - @property - def stream(self) -> RunStream: - return RunStream(session_id=self.session_id, run_id=self.run_id) - class StoredEvent(ProposedEvent): + stream_id: str = Field(min_length=1) stream_version: int = Field(ge=0) commit_cursor: str = Field(min_length=1) committed_at: datetime @@ -112,6 +109,7 @@ def from_proposed( cls, event: ProposedEvent, *, + stream_id: str, stream_version: int, commit_cursor: str, committed_at: datetime, @@ -119,6 +117,7 @@ def from_proposed( return cls.model_validate( { **event.model_dump(), + "stream_id": stream_id, "stream_version": stream_version, "commit_cursor": commit_cursor, "committed_at": committed_at, @@ -129,7 +128,7 @@ def from_proposed( class CommitReceipt(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - stream: RunStream + stream: EventStream append_id: str = Field(min_length=1) digest: str = Field(pattern=r"^[0-9a-f]{64}$") first_version: int = Field(ge=0) @@ -148,6 +147,6 @@ def require_commit_timezone(cls, value: datetime) -> datetime: def canonical_append_digest(events: list[ProposedEvent] | tuple[ProposedEvent, ...]) -> str: - body = [event.model_dump(mode="json") for event in events] - canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False) - return hashlib.sha256(canonical.encode()).hexdigest() + body = [event.model_dump(mode="json", exclude_none=True) for event in events] + canonical = rfc8785.dumps(body) + return hashlib.sha256(canonical).hexdigest() diff --git a/src/agent_ledger/py.typed b/python/src/agent_ledger/py.typed similarity index 100% rename from src/agent_ledger/py.typed rename to python/src/agent_ledger/py.typed diff --git a/src/agent_ledger/recorder.py b/python/src/agent_ledger/recorder.py similarity index 95% rename from src/agent_ledger/recorder.py rename to python/src/agent_ledger/recorder.py index 87c59ec..cd0bb6c 100644 --- a/src/agent_ledger/recorder.py +++ b/python/src/agent_ledger/recorder.py @@ -10,9 +10,9 @@ from agent_ledger.models import ( Actor, CommitReceipt, + EventStream, EventType, ProposedEvent, - RunStream, StoredEvent, ) from agent_ledger.store import EventStore @@ -28,7 +28,7 @@ class AttemptHandle(BaseModel): class SessionRecorder: - """Framework-facing recorder that serializes writes to one run stream.""" + """Framework-facing recorder that serializes writes to one execution stream.""" def __init__( self, @@ -44,7 +44,8 @@ def __init__( if (parent_run_id is None) != (caused_by_event_id is None): raise ValueError("parent_run_id and caused_by_event_id must be set together") self.store = store - self.stream = RunStream(session_id=session_id, run_id=run_id) + self.run_id = run_id + self.stream = EventStream(session_id=session_id, stream_id=run_id) self.actor = actor self.expected_version = expected_version self.parent_run_id = parent_run_id @@ -60,7 +61,7 @@ async def resume( run_id: str, actor: Actor, ) -> Self: - stream = RunStream(session_id=session_id, run_id=run_id) + stream = EventStream(session_id=session_id, stream_id=run_id) expected_version = -1 parent_run_id: str | None = None caused_by_event_id: str | None = None @@ -108,7 +109,7 @@ async def record( event = ProposedEvent( event_type=event_type, session_id=self.stream.session_id, - run_id=self.stream.run_id, + run_id=self.run_id, actor=self.actor, step_id=step_id, attempt_id=attempt_id, @@ -118,6 +119,7 @@ async def record( receipt = await self.append([event], append_id=append_id) return StoredEvent.from_proposed( event, + stream_id=self.stream.stream_id, stream_version=receipt.first_version, commit_cursor=receipt.first_cursor, committed_at=receipt.committed_at, @@ -127,7 +129,7 @@ async def start_run(self, *, payload: dict[str, Any] | None = None) -> StoredEve event = ProposedEvent( event_type=EventType.RUN_STARTED, session_id=self.stream.session_id, - run_id=self.stream.run_id, + run_id=self.run_id, actor=self.actor, parent_run_id=self.parent_run_id, caused_by_event_id=self.caused_by_event_id, @@ -136,6 +138,7 @@ async def start_run(self, *, payload: dict[str, Any] | None = None) -> StoredEve receipt = await self.append([event]) return StoredEvent.from_proposed( event, + stream_id=self.stream.stream_id, stream_version=receipt.first_version, commit_cursor=receipt.first_cursor, committed_at=receipt.committed_at, @@ -327,7 +330,7 @@ def child( session_id=self.stream.session_id, run_id=run_id, actor=actor, - parent_run_id=self.stream.run_id, + parent_run_id=self.run_id, caused_by_event_id=caused_by_event_id, ) diff --git a/src/agent_ledger/store.py b/python/src/agent_ledger/store.py similarity index 78% rename from src/agent_ledger/store.py rename to python/src/agent_ledger/store.py index 37a479b..2a2702c 100644 --- a/src/agent_ledger/store.py +++ b/python/src/agent_ledger/store.py @@ -1,13 +1,13 @@ from collections.abc import AsyncIterator, Sequence from typing import Protocol -from agent_ledger.models import CommitReceipt, ProposedEvent, RunStream, StoredEvent +from agent_ledger.models import CommitReceipt, EventStream, ProposedEvent, StoredEvent class EventStore(Protocol): async def append( self, - stream: RunStream, + stream: EventStream, expected_version: int, append_id: str, events: Sequence[ProposedEvent], @@ -15,7 +15,7 @@ async def append( def read_stream( self, - stream: RunStream, + stream: EventStream, after_version: int = -1, ) -> AsyncIterator[StoredEvent]: ... diff --git a/src/agent_ledger/stores/__init__.py b/python/src/agent_ledger/stores/__init__.py similarity index 100% rename from src/agent_ledger/stores/__init__.py rename to python/src/agent_ledger/stores/__init__.py diff --git a/src/agent_ledger/stores/_validation.py b/python/src/agent_ledger/stores/_validation.py similarity index 85% rename from src/agent_ledger/stores/_validation.py rename to python/src/agent_ledger/stores/_validation.py index fa1b5b4..ce26cd7 100644 --- a/src/agent_ledger/stores/_validation.py +++ b/python/src/agent_ledger/stores/_validation.py @@ -1,11 +1,11 @@ from collections.abc import Sequence from agent_ledger.errors import StoreError -from agent_ledger.models import ProposedEvent, RunStream, canonical_append_digest +from agent_ledger.models import EventStream, ProposedEvent, canonical_append_digest def validate_append( - stream: RunStream, + stream: EventStream, expected_version: int, append_id: str, events: Sequence[ProposedEvent], @@ -23,8 +23,8 @@ def validate_append( event_ids: set[str] = set() for event in batch: - if event.stream != stream: - raise StoreError("all events must belong to the target run stream") + if event.session_id != stream.session_id: + raise StoreError("all events must belong to the target stream's session") if event.event_id in event_ids: raise StoreError(f"event_id {event.event_id!r} appears more than once in the batch") event_ids.add(event.event_id) diff --git a/src/agent_ledger/stores/memory.py b/python/src/agent_ledger/stores/memory.py similarity index 89% rename from src/agent_ledger/stores/memory.py rename to python/src/agent_ledger/stores/memory.py index 462c0e0..7737a07 100644 --- a/src/agent_ledger/stores/memory.py +++ b/python/src/agent_ledger/stores/memory.py @@ -6,8 +6,8 @@ from agent_ledger.errors import DuplicateEvent, IdempotencyViolation, InvalidCursor, StreamConflict from agent_ledger.models import ( CommitReceipt, + EventStream, ProposedEvent, - RunStream, StoredEvent, utc_now, ) @@ -21,20 +21,20 @@ def __init__(self) -> None: self._lock = asyncio.Lock() self._stream_events: dict[tuple[str, str], list[StoredEvent]] = {} self._session_events: dict[str, list[StoredEvent]] = {} - self._event_ids: set[str] = set() + self._event_ids: set[tuple[str, str]] = set() self._receipts: dict[tuple[str, str, str], CommitReceipt] = {} self._session_cursors: dict[str, int] = {} async def append( self, - stream: RunStream, + stream: EventStream, expected_version: int, append_id: str, events: Sequence[ProposedEvent], ) -> CommitReceipt: batch, digest = validate_append(stream, expected_version, append_id, events) - receipt_key = (stream.session_id, stream.run_id, append_id) - stream_key = (stream.session_id, stream.run_id) + receipt_key = (stream.session_id, stream.stream_id, append_id) + stream_key = (stream.session_id, stream.stream_id) async with self._lock: existing = self._receipts.get(receipt_key) @@ -49,7 +49,7 @@ async def append( raise StreamConflict(expected_version, actual_version) for event in batch: - if event.event_id in self._event_ids: + if (stream.session_id, event.event_id) in self._event_ids: raise DuplicateEvent(event.event_id) committed_at = utc_now() @@ -60,6 +60,7 @@ async def append( stored.append( StoredEvent.from_proposed( event, + stream_id=stream.stream_id, stream_version=first_version + offset, commit_cursor=str(next_cursor + offset), committed_at=committed_at, @@ -80,20 +81,20 @@ async def append( self._stream_events.setdefault(stream_key, []).extend(stored) self._session_events.setdefault(stream.session_id, []).extend(stored) - self._event_ids.update(event.event_id for event in stored) + self._event_ids.update((stream.session_id, event.event_id) for event in stored) self._session_cursors[stream.session_id] = next_cursor + len(stored) - 1 self._receipts[receipt_key] = receipt return receipt.model_copy(deep=True) async def read_stream( self, - stream: RunStream, + stream: EventStream, after_version: int = -1, ) -> AsyncIterator[StoredEvent]: async with self._lock: events = [ event.model_copy(deep=True) - for event in self._stream_events.get((stream.session_id, stream.run_id), []) + for event in self._stream_events.get((stream.session_id, stream.stream_id), []) if event.stream_version > after_version ] for event in events: diff --git a/src/agent_ledger/stores/redis.py b/python/src/agent_ledger/stores/redis.py similarity index 93% rename from src/agent_ledger/stores/redis.py rename to python/src/agent_ledger/stores/redis.py index d608122..69e0edf 100644 --- a/src/agent_ledger/stores/redis.py +++ b/python/src/agent_ledger/stores/redis.py @@ -16,7 +16,7 @@ StoreError, StreamConflict, ) -from agent_ledger.models import CommitReceipt, ProposedEvent, RunStream, StoredEvent, utc_now +from agent_ledger.models import CommitReceipt, EventStream, ProposedEvent, StoredEvent, utc_now from agent_ledger.stores._validation import validate_append _APPEND_LUA = r""" @@ -55,6 +55,7 @@ local version = first_version + index local commit_cursor = first_cursor + index event['stream_version'] = version + event['stream_id'] = ARGV[7] event['commit_cursor'] = tostring(commit_cursor) event['committed_at'] = ARGV[5] redis.call('HSET', KEYS[2], event_id, cjson.encode(event)) @@ -69,7 +70,7 @@ redis.call('HSET', KEYS[1], 'cursor', tostring(last_cursor)) local receipt = { - stream = {session_id = ARGV[6], run_id = ARGV[7]}, + stream = {session_id = ARGV[6], stream_id = ARGV[7]}, append_id = ARGV[3], digest = ARGV[4], first_version = first_version, @@ -106,7 +107,7 @@ def __init__( async def append( self, - stream: RunStream, + stream: EventStream, expected_version: int, append_id: str, events: Sequence[ProposedEvent], @@ -116,12 +117,12 @@ async def append( committed_at = utc_now() args: list[str | int] = [ expected_version, - f"version:{_hash(stream.run_id)}", + f"version:{_hash(stream.stream_id)}", append_id, digest, committed_at.isoformat(), stream.session_id, - stream.run_id, + stream.stream_id, len(batch), ] for event in batch: @@ -162,7 +163,7 @@ async def append( async def read_stream( self, - stream: RunStream, + stream: EventStream, after_version: int = -1, ) -> AsyncIterator[StoredEvent]: keys = self._keys(stream) @@ -192,7 +193,7 @@ async def scan_session( after_cursor: str | None = None, ) -> AsyncIterator[StoredEvent]: cursor = _parse_cursor(after_cursor) - stream = RunStream(session_id=session_id, run_id="_") + stream = EventStream(session_id=session_id, stream_id="_") keys = self._keys(stream) try: event_ids = await asyncio.wait_for( @@ -214,16 +215,16 @@ async def scan_session( raise StoreError("Redis session index refers to a missing event") yield StoredEvent.model_validate_json(raw) - def _keys(self, stream: RunStream) -> list[str]: + def _keys(self, stream: EventStream) -> list[str]: tag = _hash(stream.session_id) root = f"{self._prefix}:{{{tag}}}" - run = _hash(stream.run_id) + stream_key = _hash(stream.stream_id) return [ f"{root}:meta", f"{root}:events", f"{root}:session-index", - f"{root}:run-index:{run}", - f"{root}:commits:{run}", + f"{root}:stream-index:{stream_key}", + f"{root}:commits:{stream_key}", ] diff --git a/src/agent_ledger/stores/sql.py b/python/src/agent_ledger/stores/sql.py similarity index 89% rename from src/agent_ledger/stores/sql.py rename to python/src/agent_ledger/stores/sql.py index 7b9349c..0f3ce10 100644 --- a/src/agent_ledger/stores/sql.py +++ b/python/src/agent_ledger/stores/sql.py @@ -30,8 +30,8 @@ ) from agent_ledger.models import ( CommitReceipt, + EventStream, ProposedEvent, - RunStream, StoredEvent, utc_now, ) @@ -52,11 +52,11 @@ class _SchemaVersion(_Base): version: Mapped[int] = mapped_column(Integer, nullable=False) -class _RunStream(_Base): - __tablename__ = "agent_ledger_run_streams" +class _EventStream(_Base): + __tablename__ = "agent_ledger_streams" session_id: Mapped[str] = mapped_column(String(_ID_LENGTH), primary_key=True) - run_id: Mapped[str] = mapped_column(String(_ID_LENGTH), primary_key=True) + stream_id: Mapped[str] = mapped_column(String(_ID_LENGTH), primary_key=True) current_version: Mapped[int] = mapped_column(BigInteger, nullable=False) @@ -64,7 +64,7 @@ class _AppendCommit(_Base): __tablename__ = "agent_ledger_append_commits" session_id: Mapped[str] = mapped_column(String(_ID_LENGTH), primary_key=True) - run_id: Mapped[str] = mapped_column(String(_ID_LENGTH), primary_key=True) + stream_id: Mapped[str] = mapped_column(String(_ID_LENGTH), primary_key=True) append_id: Mapped[str] = mapped_column(String(_ID_LENGTH), primary_key=True) digest: Mapped[str] = mapped_column(String(64), nullable=False) receipt: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) @@ -75,10 +75,15 @@ class _Event(_Base): __table_args__ = ( UniqueConstraint( "session_id", - "run_id", + "stream_id", "stream_version", name="uq_agent_ledger_stream_version", ), + UniqueConstraint( + "session_id", + "event_id", + name="uq_agent_ledger_session_event", + ), Index("ix_agent_ledger_session_cursor", "session_id", "commit_position"), ) @@ -87,9 +92,9 @@ class _Event(_Base): primary_key=True, autoincrement=True, ) - event_id: Mapped[str] = mapped_column(String(_ID_LENGTH), unique=True, nullable=False) + event_id: Mapped[str] = mapped_column(String(_ID_LENGTH), nullable=False) session_id: Mapped[str] = mapped_column(String(_ID_LENGTH), nullable=False) - run_id: Mapped[str] = mapped_column(String(_ID_LENGTH), nullable=False) + stream_id: Mapped[str] = mapped_column(String(_ID_LENGTH), nullable=False) stream_version: Mapped[int] = mapped_column(BigInteger, nullable=False) committed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) event: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) @@ -115,9 +120,9 @@ async def initialize(self) -> None: version = result.scalar_one_or_none() if version is None: await connection.execute( - insert(_SchemaVersion).values(component="core", version=1) + insert(_SchemaVersion).values(component="core", version=2) ) - elif version != 1: + elif version != 2: raise StoreError(f"unsupported SQL schema version: {version}") except TimeoutError as error: raise StoreError("SQL schema initialization timed out") from error @@ -126,7 +131,7 @@ async def initialize(self) -> None: async def append( self, - stream: RunStream, + stream: EventStream, expected_version: int, append_id: str, events: Sequence[ProposedEvent], @@ -157,7 +162,7 @@ async def append( async def _append_once( self, - stream: RunStream, + stream: EventStream, expected_version: int, append_id: str, batch: tuple[ProposedEvent, ...], @@ -169,7 +174,7 @@ async def _append_once( select(_AppendCommit) .where( _AppendCommit.session_id == stream.session_id, - _AppendCommit.run_id == stream.run_id, + _AppendCommit.stream_id == stream.stream_id, _AppendCommit.append_id == append_id, ) .with_for_update() @@ -180,17 +185,17 @@ async def _append_once( return CommitReceipt.model_validate(existing.receipt) stream_row = await session.scalar( - select(_RunStream) + select(_EventStream) .where( - _RunStream.session_id == stream.session_id, - _RunStream.run_id == stream.run_id, + _EventStream.session_id == stream.session_id, + _EventStream.stream_id == stream.stream_id, ) .with_for_update() ) if stream_row is None: - stream_row = _RunStream( + stream_row = _EventStream( session_id=stream.session_id, - run_id=stream.run_id, + stream_id=stream.stream_id, current_version=-1, ) session.add(stream_row) @@ -201,7 +206,8 @@ async def _append_once( duplicate = await session.scalar( select(_Event.event_id).where( - _Event.event_id.in_(event.event_id for event in batch) + _Event.session_id == stream.session_id, + _Event.event_id.in_(event.event_id for event in batch), ) ) if duplicate is not None: @@ -214,7 +220,7 @@ async def _append_once( row = _Event( event_id=event.event_id, session_id=stream.session_id, - run_id=stream.run_id, + stream_id=stream.stream_id, stream_version=first_version + offset, committed_at=committed_at, event=event.model_dump(mode="json"), @@ -226,6 +232,7 @@ async def _append_once( stored = [ StoredEvent.from_proposed( event, + stream_id=stream.stream_id, stream_version=row.stream_version, commit_cursor=str(row.commit_position), committed_at=committed_at, @@ -250,7 +257,7 @@ async def _append_once( session.add( _AppendCommit( session_id=stream.session_id, - run_id=stream.run_id, + stream_id=stream.stream_id, append_id=append_id, digest=digest, receipt=receipt.model_dump(mode="json"), @@ -260,7 +267,7 @@ async def _append_once( async def read_stream( self, - stream: RunStream, + stream: EventStream, after_version: int = -1, ) -> AsyncIterator[StoredEvent]: try: @@ -270,7 +277,7 @@ async def read_stream( select(_Event) .where( _Event.session_id == stream.session_id, - _Event.run_id == stream.run_id, + _Event.stream_id == stream.stream_id, _Event.stream_version > after_version, ) .order_by(_Event.stream_version) diff --git a/src/agent_ledger/trajectory.py b/python/src/agent_ledger/trajectory.py similarity index 97% rename from src/agent_ledger/trajectory.py rename to python/src/agent_ledger/trajectory.py index a9f217d..59d6660 100644 --- a/src/agent_ledger/trajectory.py +++ b/python/src/agent_ledger/trajectory.py @@ -50,7 +50,9 @@ def _project_run( agent_version: str, is_root: bool, ) -> dict[str, Any]: - events = sorted(by_run[run_id], key=lambda event: event.stream_version) + # scan_session already supplies the store's session order. stream_version cannot + # compare events from different physical streams that share a semantic run. + events = by_run[run_id] session_id = events[0].session_id steps: list[dict[str, Any]] = [] attempts: dict[str, dict[str, Any]] = {} diff --git a/tests/conftest.py b/python/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to python/tests/conftest.py diff --git a/tests/test_models.py b/python/tests/test_models.py similarity index 56% rename from tests/test_models.py rename to python/tests/test_models.py index 97591ee..d14d23d 100644 --- a/tests/test_models.py +++ b/python/tests/test_models.py @@ -6,7 +6,8 @@ from jsonschema import Draft202012Validator, FormatChecker from agent_ledger import Actor, MemoryArtifactStore, ProposedEvent, StoredEvent -from agent_ledger.models import utc_now +from agent_ledger.frameworks.plain_loop import PlainLoopProfile +from agent_ledger.models import canonical_append_digest, utc_now def test_event_matches_normative_json_schema() -> None: @@ -16,13 +17,14 @@ def test_event_matches_normative_json_schema() -> None: run_id="run", actor=Actor(type="agent", id="test"), ) - schema_path = Path(__file__).parents[1] / "spec" / "schemas" / "event.schema.json" + schema_path = Path(__file__).parents[2] / "spec" / "schemas" / "event.schema.json" schema = json.loads(schema_path.read_text()) validator = Draft202012Validator(schema, format_checker=FormatChecker()) validator.validate(event.model_dump(mode="json", exclude_none=True)) stored = StoredEvent.from_proposed( event, + stream_id="run", stream_version=0, commit_cursor="0", committed_at=utc_now(), @@ -36,3 +38,17 @@ async def test_memory_artifact_round_trip() -> None: assert ref.size == 18 assert await store.get(ref) == b"large model output" + + +def test_append_digest_matches_cross_language_vector() -> None: + vector_path = Path(__file__).parents[2] / "conformance" / "vectors" / "append.json" + vector = json.loads(vector_path.read_text()) + events = [ProposedEvent.model_validate(event) for event in vector["events"]] + + assert canonical_append_digest(events) == vector["sha256"] + + +def test_adapter_descriptor_matches_normative_schema() -> None: + schema_path = Path(__file__).parents[2] / "spec" / "schemas" / "adapter.schema.json" + schema = json.loads(schema_path.read_text()) + Draft202012Validator(schema).validate(PlainLoopProfile.descriptor.model_dump(mode="json")) diff --git a/tests/test_projection.py b/python/tests/test_projection.py similarity index 100% rename from tests/test_projection.py rename to python/tests/test_projection.py diff --git a/tests/test_recorder.py b/python/tests/test_recorder.py similarity index 95% rename from tests/test_recorder.py rename to python/tests/test_recorder.py index 5678262..bed86f7 100644 --- a/tests/test_recorder.py +++ b/python/tests/test_recorder.py @@ -69,8 +69,8 @@ async def test_child_run_forms_causal_edge() -> None: inspection = inspect_session(events) assert len(inspection.run_edges) == 1 - assert inspection.run_edges[0].parent_run_id == parent.stream.run_id - assert inspection.run_edges[0].child_run_id == child.stream.run_id + assert inspection.run_edges[0].parent_run_id == parent.run_id + assert inspection.run_edges[0].child_run_id == child.run_id assert inspection.run_edges[0].caused_by_event_id == trigger.event_id @@ -105,7 +105,7 @@ async def test_recorder_resume_continues_at_last_version() -> None: resumed = await SessionRecorder.resume( store=store, session_id=original.stream.session_id, - run_id=original.stream.run_id, + run_id=original.run_id, actor=original.actor, ) await resumed.complete_run() diff --git a/tests/test_store_contract.py b/python/tests/test_store_contract.py similarity index 59% rename from tests/test_store_contract.py rename to python/tests/test_store_contract.py index 78d0330..8c989c5 100644 --- a/tests/test_store_contract.py +++ b/python/tests/test_store_contract.py @@ -8,9 +8,9 @@ from agent_ledger import ( Actor, DuplicateEvent, + EventStream, IdempotencyViolation, ProposedEvent, - RunStream, StoredEvent, StreamConflict, ) @@ -34,9 +34,10 @@ async def collect(iterator: AsyncIterator[StoredEvent]) -> list[StoredEvent]: async def test_atomic_batch_versions_and_reads(event_store: EventStore) -> None: session_id = str(uuid4()) - stream = RunStream(session_id=session_id, run_id=str(uuid4())) - first = event(session_id, stream.run_id) - second = event(session_id, stream.run_id) + run_id = str(uuid4()) + stream = EventStream(session_id=session_id, stream_id=run_id) + first = event(session_id, run_id) + second = event(session_id, run_id) receipt = await event_store.append(stream, -1, str(uuid4()), [first, second]) @@ -49,8 +50,9 @@ async def test_atomic_batch_versions_and_reads(event_store: EventStore) -> None: async def test_append_is_idempotent_by_content(event_store: EventStore) -> None: session_id = str(uuid4()) - stream = RunStream(session_id=session_id, run_id=str(uuid4())) - item = event(session_id, stream.run_id) + run_id = str(uuid4()) + stream = EventStream(session_id=session_id, stream_id=run_id) + item = event(session_id, run_id) append_id = str(uuid4()) original = await event_store.append(stream, -1, append_id, [item]) @@ -60,16 +62,17 @@ async def test_append_is_idempotent_by_content(event_store: EventStore) -> None: assert len(await collect(event_store.read_stream(stream))) == 1 with pytest.raises(IdempotencyViolation): - await event_store.append(stream, -1, append_id, [event(session_id, stream.run_id)]) + await event_store.append(stream, -1, append_id, [event(session_id, run_id)]) async def test_optimistic_concurrency(event_store: EventStore) -> None: session_id = str(uuid4()) - stream = RunStream(session_id=session_id, run_id=str(uuid4())) - await event_store.append(stream, -1, str(uuid4()), [event(session_id, stream.run_id)]) + run_id = str(uuid4()) + stream = EventStream(session_id=session_id, stream_id=run_id) + await event_store.append(stream, -1, str(uuid4()), [event(session_id, run_id)]) with pytest.raises(StreamConflict) as caught: - await event_store.append(stream, -1, str(uuid4()), [event(session_id, stream.run_id)]) + await event_store.append(stream, -1, str(uuid4()), [event(session_id, run_id)]) assert caught.value.actual_version == 0 assert len(await collect(event_store.read_stream(stream))) == 1 @@ -77,37 +80,62 @@ async def test_optimistic_concurrency(event_store: EventStore) -> None: async def test_duplicate_event_rolls_back_whole_batch(event_store: EventStore) -> None: session_id = str(uuid4()) - first_stream = RunStream(session_id=session_id, run_id=str(uuid4())) + first_run_id = str(uuid4()) + first_stream = EventStream(session_id=session_id, stream_id=first_run_id) duplicate_id = str(uuid4()) await event_store.append( first_stream, -1, str(uuid4()), - [event(session_id, first_stream.run_id, event_id=duplicate_id)], + [event(session_id, first_run_id, event_id=duplicate_id)], ) - second_stream = RunStream(session_id=session_id, run_id=str(uuid4())) + second_run_id = str(uuid4()) + second_stream = EventStream(session_id=session_id, stream_id=second_run_id) with pytest.raises(DuplicateEvent): await event_store.append( second_stream, -1, str(uuid4()), [ - event(session_id, second_stream.run_id), - event(session_id, second_stream.run_id, event_id=duplicate_id), + event(session_id, second_run_id), + event(session_id, second_run_id, event_id=duplicate_id), ], ) assert await collect(event_store.read_stream(second_stream)) == [] +async def test_event_id_uniqueness_is_scoped_to_session(event_store: EventStore) -> None: + event_id = str(uuid4()) + first_session = str(uuid4()) + second_session = str(uuid4()) + first_run = str(uuid4()) + second_run = str(uuid4()) + + await event_store.append( + EventStream(session_id=first_session, stream_id=first_run), + -1, + str(uuid4()), + [event(first_session, first_run, event_id=event_id)], + ) + await event_store.append( + EventStream(session_id=second_session, stream_id=second_run), + -1, + str(uuid4()), + [event(second_session, second_run, event_id=event_id)], + ) + + async def test_session_scan_merges_runs_and_pages(event_store: EventStore) -> None: session_id = str(uuid4()) - first_stream = RunStream(session_id=session_id, run_id=str(uuid4())) - second_stream = RunStream(session_id=session_id, run_id=str(uuid4())) - first = event(session_id, first_stream.run_id) - second = event(session_id, second_stream.run_id) - third = event(session_id, first_stream.run_id) + first_run_id = str(uuid4()) + second_run_id = str(uuid4()) + first_stream = EventStream(session_id=session_id, stream_id=first_run_id) + second_stream = EventStream(session_id=session_id, stream_id=second_run_id) + first = event(session_id, first_run_id) + second = event(session_id, second_run_id) + third = event(session_id, first_run_id) await event_store.append(first_stream, -1, str(uuid4()), [first]) await event_store.append(second_stream, -1, str(uuid4()), [second]) @@ -119,10 +147,24 @@ async def test_session_scan_merges_runs_and_pages(event_store: EventStore) -> No assert [item.event_id for item in page] == [second.event_id, third.event_id] +async def test_framework_state_stream_can_span_runtime_runs(event_store: EventStore) -> None: + session_id = str(uuid4()) + stream = EventStream(session_id=session_id, stream_id="framework/pi/native-session") + first = event(session_id, "runtime-1") + second = event(session_id, "runtime-2") + + await event_store.append(stream, -1, str(uuid4()), [first]) + await event_store.append(stream, 0, str(uuid4()), [second]) + + stored = await collect(event_store.read_stream(stream)) + assert [item.run_id for item in stored] == ["runtime-1", "runtime-2"] + + async def test_append_snapshots_nested_payload(event_store: EventStore) -> None: session_id = str(uuid4()) - stream = RunStream(session_id=session_id, run_id=str(uuid4())) - item = event(session_id, stream.run_id) + run_id = str(uuid4()) + stream = EventStream(session_id=session_id, stream_id=run_id) + item = event(session_id, run_id) item.payload["nested"] = {"value": 1} await event_store.append(stream, -1, str(uuid4()), [item]) diff --git a/uv.lock b/python/uv.lock similarity index 99% rename from uv.lock rename to python/uv.lock index f9fd280..134ea65 100644 --- a/uv.lock +++ b/python/uv.lock @@ -12,6 +12,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, + { name = "rfc8785" }, ] [package.optional-dependencies] @@ -58,6 +59,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10,<3" }, { name = "redis", marker = "extra == 'all'", specifier = ">=5,<7" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=5,<7" }, + { name = "rfc8785", specifier = ">=0.1.4,<1" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'all'", specifier = ">=2.0,<3" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'mysql'", specifier = ">=2.0,<3" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'postgres'", specifier = ">=2.0,<3" }, @@ -710,6 +712,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "rpds-py" version = "2026.6.3" diff --git a/spec/rfcs/0001-agent-ledger.md b/spec/rfcs/0001-agent-ledger.md index b9a201c..bd122ee 100644 --- a/spec/rfcs/0001-agent-ledger.md +++ b/spec/rfcs/0001-agent-ledger.md @@ -16,11 +16,11 @@ framework integrations can share while leaving framework state restoration to th | Concept | Meaning | | --- | --- | | Session | End-to-end task and the boundary of the global timeline. | -| Run Stream | One agent loop's ordered append stream inside a session. | +| Event Stream | One optimistic-concurrency partition inside a session. | | Step | Logical work that survives retries. | | Attempt | One physical model or tool call within a step. | | Event | Immutable fact proposed by a producer and enriched by a store. | -| Framework Profile | Mapping between one framework's hooks/checkpoints and ledger events. | +| Framework Adapter | Recording and recovery bindings for one framework. | | Trajectory | Read-side projection for evaluation or analysis. | A child agent starts another run in the same session. Its `run.started` event carries @@ -30,7 +30,8 @@ authoritative relationship between runs. ## Event envelope `spec/schemas/event.schema.json` is normative. Producers set the identity and causal fields. A -store adds `stream_version`, `commit_cursor`, and `committed_at` when the event is accepted. +store adds `stream_id`, `stream_version`, `commit_cursor`, and `committed_at` when the event is +accepted. `occurred_at` is the producer timestamp. `committed_at` is the store timestamp. Neither field establishes causality. @@ -40,7 +41,9 @@ type, byte size, and URI while an application-selected `ArtifactStore` owns the ## Append contract -The empty stream version is `-1`; the first stored event has version `0`. +The empty stream version is `-1`; the first stored event has version `0`. A stream is identified by +`(session_id, stream_id)`. The physical `stream_id` is intentionally separate from the semantic +`run_id`, so framework-native state can span runtime replacement. ```python await store.append(stream, expected_version, append_id, events) @@ -51,16 +54,23 @@ The operation is an atomic batch with these outcomes: 1. If `append_id` was committed with identical canonical event content, return its original receipt. 2. If the same `append_id` names different content, fail with `IdempotencyViolation`. -3. If `expected_version` differs from the run stream's current version, fail with +3. If `expected_version` differs from the event stream's current version, fail with `StreamConflict`. 4. Otherwise append all events contiguously or append none. -`expected_version` is scoped to `(session_id, run_id)`. A store must reject an event identifier +`expected_version` is scoped to `(session_id, stream_id)`. A store must reject an event identifier already present in the same session; producers must generate globally unique event identifiers. +The append digest is SHA-256 over the RFC 8785 canonical JSON encoding of the ordered proposed-event +array. Optional fields that are absent are omitted; explicit `null` remains part of the digest. + `commit_cursor` is an opaque, session-scoped pagination token. Consumers must not compare cursors from different sessions or infer causality from cursor order. +Live tailing is an optional store extension, not part of the V1 `EventStore`: Redis Pub/Sub, +database polling, and CDC provide materially different delivery guarantees. Portable consumers use +`scan_session` with the last durable cursor and may layer a wake-up signal on top. + ## Write-before-execute Framework hooks must await `model.requested` or `tool.requested` before invoking the external @@ -68,7 +78,7 @@ operation. If that append fails, the call must not start. The completed or faile appended before the loop advances. After a crash, a requested event without a terminal event represents an unresolved attempt. The -framework profile decides whether to query an external provider, ask for confirmation, mark the +framework adapter decides whether to query an external provider, ask for confirmation, mark the attempt failed, or retry with a new `attempt_id`. Repeating a tool automatically is unsafe because the first call may have produced a side effect. @@ -77,15 +87,16 @@ Retries keep the same `step_id` and use a new `attempt_id`. ## Framework recovery The core library can find stream gaps, unresolved attempts, and run-parent edges. It does not -construct a generic run context. A Framework Profile defines: +construct a generic run context. A framework adapter defines: - hook-to-event mappings; - snapshot/checkpoint encoding; - reconstruction of the framework's native context; - policy for unresolved attempts. -The bundled plain-loop profile demonstrates this boundary. Frameworks with native checkpointing -can store a checkpoint link or snapshot event and restore with their own APIs. +The bundled plain-loop profile demonstrates snapshot recovery. Frameworks with native storage or +checkpointing preserve that state losslessly and restore with their own APIs. RFC 0002 defines the +recording/recovery split and capability declarations. ## Store durability @@ -101,4 +112,3 @@ stream, but leases, fencing, and scheduling belong to the orchestrator. Readers must reject unsupported major schema versions and should preserve unknown event types, payload fields, and `extensions`. Additive fields are permitted within a major version. - diff --git a/spec/rfcs/0002-polyglot-adapters.md b/spec/rfcs/0002-polyglot-adapters.md new file mode 100644 index 0000000..e577717 --- /dev/null +++ b/spec/rfcs/0002-polyglot-adapters.md @@ -0,0 +1,105 @@ +# RFC 0002: Polyglot Framework Adapters + +## Status + +Draft specification for adapter packages in the `0.x` line. + +## Problem + +Agent Ledger is useful only when it can enter a framework at the points where external work starts +and where native recovery state changes. Those points are framework-owned and language-specific. +A universal hook API would either expose the lowest common denominator or claim guarantees that a +framework cannot provide. + +The stable product is therefore the ledger specification. Language SDKs are thin implementations, +while framework adapters own most integration code and evolve with their target frameworks. + +## Adapter responsibilities + +An adapter has two independent responsibilities: + +1. **Recording binding** maps framework lifecycle boundaries to normalized Ledger events. +2. **Recovery binding** persists lossless framework-native state and invokes the framework's own + continuation API. + +Normalized events serve global timelines, trajectories, inspection, and alerting. Native state +serves recovery. An adapter must not claim that normalized events alone can rebuild a context when +the framework also depends on branches, compaction state, queues, custom messages, or opaque +checkpoints. + +## Capability declaration + +Every adapter publishes an `AdapterDescriptor` conforming to `schemas/adapter.schema.json`. +Recording boundaries use three guarantee levels: + +- `strict`: the framework awaits the write and does not begin or advance past the external action + when persistence fails; +- `best_effort`: the callback is ordered but an error may be swallowed or cannot stop execution; +- `unsupported`: the framework exposes no trustworthy boundary. + +Recovery uses `native_store`, `snapshot`, `checkpoint`, or `unsupported`. The descriptor describes +the fully documented installation. For example, AgentGo reaches strict physical model attempts only +when its model wrapper is installed together with its hooks. + +## Event streams and semantic runs + +An `EventStream` is the optimistic-concurrency partition identified by `(session_id, stream_id)`. +`run_id` remains a semantic field on every event. Separating the two allows a framework-native state +stream to survive process replacement and contain mutations caused by several runtime runs. + +Recommended stream identities are: + +```text +run/ normalized execution owned by one loop +framework/pi/ Pi session tree +framework/agentgo/ AgentGo native messages +``` + +Language SDKs may use `stream_id = run_id` for normalized runs. A session scan merges all streams by +the store-assigned `commit_cursor`; causality remains defined by run and event links, not cursor or +wall-clock order. + +## Pi profile + +The strict Pi integration implements its native `SessionStorage`. Entries and active-leaf changes +are stored losslessly, then Pi's `Session` rebuilds model context. Direct AgentHarness hooks add +normalized model, tool, turn, and run events. + +A coding-agent extension that swallows model-hook errors must declare model prewrite as +`best_effort`. It can still emit telemetry, but it is not the strict profile. + +## AgentGo profile + +The AgentGo integration combines: + +- `WithMessageCommitter` for native transcript durability before messages enter context or trigger + tools; +- `WithBeforeTurn` and `WithAfterTurn` for logical step boundaries; +- a `ChatModel` wrapper for every physical provider attempt, including internal retries; +- `ToolMiddleware` for write-before-tool and outcome gating; +- `HoldRuns`, `SetMessages`, and `Continue` for restoration. + +Custom `AgentMessage` implementations require an application codec. The default codec must reject +unknown message types instead of silently lowering them to model messages. Queues that a framework +cannot export are a declared recovery limitation and must be captured by a host wrapper before they +can be replayed. + +## Recovery sequence + +Adapters follow the same semantic sequence even though their APIs differ: + +1. freeze or create an idle framework runtime; +2. load the latest compatible native state and tail mutations; +3. restore the framework-owned session or context; +4. inspect unresolved Ledger attempts and reconcile them with native state; +5. never replay an unresolved side-effecting tool unless explicitly safe; +6. invoke the framework's native resume or continue API. + +Recovery may return a blocked or decision-required result. Starting execution is not proof that +recovery was safe. + +## Conformance + +Core SDKs must pass the shared RFC 8785 append vectors. Strict adapters additionally inject failures +at model prewrite, tool prewrite, outcome write, native-state write, and restore boundaries. A test +must prove that the relevant external operation did not start or the loop did not advance. diff --git a/spec/schemas/adapter.schema.json b/spec/schemas/adapter.schema.json new file mode 100644 index 0000000..07be96c --- /dev/null +++ b/spec/schemas/adapter.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/compforge/agent-ledger/main/spec/schemas/adapter.schema.json", + "title": "Agent Ledger Adapter Descriptor", + "type": "object", + "required": ["schema_version", "adapter_id", "adapter_version", "framework", "framework_version", "capabilities"], + "properties": { + "schema_version": { "const": "1.0" }, + "adapter_id": { "type": "string", "minLength": 1 }, + "adapter_version": { "type": "string", "minLength": 1 }, + "framework": { "type": "string", "minLength": 1 }, + "framework_version": { "type": "string", "minLength": 1 }, + "capabilities": { + "type": "object", + "required": ["model_prewrite", "tool_prewrite", "outcome_gate", "recovery", "preserves_native_state"], + "properties": { + "model_prewrite": { "$ref": "#/$defs/recordingGuarantee" }, + "tool_prewrite": { "$ref": "#/$defs/recordingGuarantee" }, + "outcome_gate": { "$ref": "#/$defs/recordingGuarantee" }, + "recovery": { "enum": ["native_store", "snapshot", "checkpoint", "unsupported"] }, + "preserves_native_state": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "recordingGuarantee": { "enum": ["strict", "best_effort", "unsupported"] } + } +} diff --git a/spec/schemas/event.schema.json b/spec/schemas/event.schema.json index c868ef0..f9fdce3 100644 --- a/spec/schemas/event.schema.json +++ b/spec/schemas/event.schema.json @@ -27,6 +27,7 @@ "caused_by_event_id": { "type": "string", "minLength": 1 }, "payload": { "type": "object" }, "extensions": { "type": "object" }, + "stream_id": { "type": "string", "minLength": 1 }, "stream_version": { "type": "integer", "minimum": 0 }, "commit_cursor": { "type": "string", "minLength": 1 }, "committed_at": { "type": "string", "format": "date-time" } diff --git a/typescript/Makefile b/typescript/Makefile new file mode 100644 index 0000000..e7c1542 --- /dev/null +++ b/typescript/Makefile @@ -0,0 +1,10 @@ +.PHONY: lint test build + +lint: + npm run lint + +test: + npm test + +build: + npm run build diff --git a/typescript/package-lock.json b/typescript/package-lock.json new file mode 100644 index 0000000..26f1977 --- /dev/null +++ b/typescript/package-lock.json @@ -0,0 +1,1393 @@ +{ + "name": "agent-ledger-typescript", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-ledger-typescript", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@agent-ledger/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@agent-ledger/pi": { + "resolved": "packages/pi", + "link": true + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.31.tgz", + "integrity": "sha512-/BRzvkp46mF6eXBL/l9WKPQQfifLlUPaWli6n9/T/WDLUg8he7TCyuNFnk6RvHP5j5W/kMj5Gxw7W778LJaXDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.26.tgz", + "integrity": "sha512-2eIvouTZoxPu5ClHY6ij13De1yhY8Rmllt0dlGeBNXX3wmR7fU1pvMCGb50fKm1GxcuntWK0t1T0cMjTyDoUQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.49.tgz", + "integrity": "sha512-wGYJvu1/stdWuzGcNyh44u8aY7ArU8XFrMesBlzIYn70HWVCRBNEngQexDuPIMu0NEgsKGKmTc0uvnS/bF7KWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", + "integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.80.6", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", + "integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "packages/core": { + "name": "@agent-ledger/core", + "version": "0.1.0" + }, + "packages/pi": { + "name": "@agent-ledger/pi", + "version": "0.1.0", + "dependencies": { + "@agent-ledger/core": "0.1.0" + }, + "devDependencies": { + "@earendil-works/pi-agent-core": "0.80.6" + }, + "peerDependencies": { + "@earendil-works/pi-agent-core": ">=0.80 <1" + } + } + } +} diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 0000000..a4dc80f --- /dev/null +++ b/typescript/package.json @@ -0,0 +1,15 @@ +{ + "name": "agent-ledger-typescript", + "private": true, + "type": "module", + "workspaces": ["packages/*"], + "scripts": { + "build": "tsc -b", + "test": "npm run build && node --test packages/*/dist/test/*.test.js", + "lint": "tsc -b --pretty false" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } +} diff --git a/typescript/packages/core/package.json b/typescript/packages/core/package.json new file mode 100644 index 0000000..b78eff8 --- /dev/null +++ b/typescript/packages/core/package.json @@ -0,0 +1,13 @@ +{ + "name": "@agent-ledger/core", + "version": "0.1.0", + "type": "module", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + } +} diff --git a/typescript/packages/core/src/canonical.ts b/typescript/packages/core/src/canonical.ts new file mode 100644 index 0000000..8d1b0f3 --- /dev/null +++ b/typescript/packages/core/src/canonical.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; + +import type { ProposedEvent } from "./types.js"; + +export function canonicalize(value: unknown): string { + if (value === null || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("canonical JSON requires finite numbers"); + return JSON.stringify(value); + } + if (typeof value === "string") { + assertNoLoneSurrogate(value); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + if (typeof value === "object") { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => { + assertNoLoneSurrogate(key); + return `${JSON.stringify(key)}:${canonicalize(object[key])}`; + }) + .join(",")}}`; + } + throw new TypeError(`value of type ${typeof value} is not JSON`); +} + +export function canonicalAppendDigest(events: readonly ProposedEvent[]): string { + return createHash("sha256").update(canonicalize(events)).digest("hex"); +} + +function assertNoLoneSurrogate(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) throw new TypeError("canonical JSON rejects lone surrogates"); + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new TypeError("canonical JSON rejects lone surrogates"); + } + } +} diff --git a/typescript/packages/core/src/index.ts b/typescript/packages/core/src/index.ts new file mode 100644 index 0000000..43a738b --- /dev/null +++ b/typescript/packages/core/src/index.ts @@ -0,0 +1,5 @@ +export * from "./canonical.js"; +export * from "./memory-store.js"; +export * from "./recorder.js"; +export * from "./store.js"; +export * from "./types.js"; diff --git a/typescript/packages/core/src/memory-store.ts b/typescript/packages/core/src/memory-store.ts new file mode 100644 index 0000000..1d30d02 --- /dev/null +++ b/typescript/packages/core/src/memory-store.ts @@ -0,0 +1,90 @@ +import { canonicalAppendDigest } from "./canonical.js"; +import type { EventStore } from "./store.js"; +import type { CommitReceipt, EventStream, ProposedEvent, StoredEvent } from "./types.js"; + +export class StreamConflict extends Error {} +export class IdempotencyViolation extends Error {} +export class DuplicateEvent extends Error {} + +export class MemoryEventStore implements EventStore { + readonly #streams = new Map(); + readonly #sessions = new Map(); + readonly #receipts = new Map(); + readonly #eventIds = new Set(); + + async append( + stream: EventStream, + expectedVersion: number, + appendId: string, + events: readonly ProposedEvent[], + ): Promise { + if (events.length === 0) throw new TypeError("append requires at least one event"); + if (events.some((event) => event.session_id !== stream.session_id)) { + throw new TypeError("all events must belong to the target stream's session"); + } + const batch = structuredClone(events) as ProposedEvent[]; + const digest = canonicalAppendDigest(batch); + const receiptKey = `${stream.session_id}\u0000${stream.stream_id}\u0000${appendId}`; + const previous = this.#receipts.get(receiptKey); + if (previous) { + if (previous.digest !== digest) throw new IdempotencyViolation(appendId); + return structuredClone(previous); + } + const streamKey = `${stream.session_id}\u0000${stream.stream_id}`; + const current = this.#streams.get(streamKey) ?? []; + if (current.length - 1 !== expectedVersion) throw new StreamConflict(stream.stream_id); + const batchEventIds = new Set(); + for (const event of batch) { + if (batchEventIds.has(event.event_id)) throw new DuplicateEvent(event.event_id); + batchEventIds.add(event.event_id); + if (this.#eventIds.has(eventKey(stream.session_id, event.event_id))) { + throw new DuplicateEvent(event.event_id); + } + } + + const session = this.#sessions.get(stream.session_id) ?? []; + const committedAt = new Date().toISOString(); + const stored = batch.map((event, offset): StoredEvent => ({ + ...event, + stream_id: stream.stream_id, + stream_version: expectedVersion + offset + 1, + commit_cursor: String(session.length + offset), + committed_at: committedAt, + })); + const receipt: CommitReceipt = { + stream: structuredClone(stream), + append_id: appendId, + digest, + first_version: stored[0]!.stream_version, + last_version: stored.at(-1)!.stream_version, + first_cursor: stored[0]!.commit_cursor, + last_cursor: stored.at(-1)!.commit_cursor, + event_ids: stored.map((event) => event.event_id), + committed_at: committedAt, + }; + this.#streams.set(streamKey, [...current, ...stored]); + this.#sessions.set(stream.session_id, [...session, ...stored]); + for (const event of stored) this.#eventIds.add(eventKey(stream.session_id, event.event_id)); + this.#receipts.set(receiptKey, receipt); + return structuredClone(receipt); + } + + async *readStream(stream: EventStream, afterVersion = -1): AsyncIterable { + const key = `${stream.session_id}\u0000${stream.stream_id}`; + for (const event of this.#streams.get(key) ?? []) { + if (event.stream_version > afterVersion) yield structuredClone(event); + } + } + + async *scanSession(sessionId: string, afterCursor = "-1"): AsyncIterable { + const cursor = Number.parseInt(afterCursor, 10); + if (!Number.isInteger(cursor) || cursor < -1) throw new TypeError(`invalid cursor ${afterCursor}`); + for (const event of this.#sessions.get(sessionId) ?? []) { + if (Number.parseInt(event.commit_cursor, 10) > cursor) yield structuredClone(event); + } + } +} + +function eventKey(sessionId: string, eventId: string): string { + return `${sessionId}\u0000${eventId}`; +} diff --git a/typescript/packages/core/src/recorder.ts b/typescript/packages/core/src/recorder.ts new file mode 100644 index 0000000..cf4eb1c --- /dev/null +++ b/typescript/packages/core/src/recorder.ts @@ -0,0 +1,129 @@ +import type { EventStore } from "./store.js"; +import { proposedEvent, type Actor, type AttemptHandle, type EventStream, type JsonValue, type StoredEvent } from "./types.js"; + +export interface RecorderOptions { + store: EventStore; + sessionId: string; + runId: string; + actor: Actor; + streamId?: string; + expectedVersion?: number; +} + +export class SessionRecorder { + readonly store: EventStore; + readonly stream: EventStream; + readonly runId: string; + readonly actor: Actor; + #expectedVersion: number; + #tail = Promise.resolve(); + + constructor(options: RecorderOptions) { + this.store = options.store; + this.runId = options.runId; + this.actor = options.actor; + this.stream = { session_id: options.sessionId, stream_id: options.streamId ?? options.runId }; + this.#expectedVersion = options.expectedVersion ?? -1; + } + + static async resume(options: Omit): Promise { + let version = -1; + const stream = { session_id: options.sessionId, stream_id: options.streamId ?? options.runId }; + for await (const event of options.store.readStream(stream)) version = event.stream_version; + return new SessionRecorder({ ...options, expectedVersion: version }); + } + + async record( + eventType: string, + options: { + payload?: { [key: string]: JsonValue }; + stepId?: string; + attemptId?: string; + appendId?: string; + } = {}, + ): Promise { + return this.#serialize(async () => { + const event = proposedEvent({ + event_type: eventType, + session_id: this.stream.session_id, + run_id: this.runId, + actor: this.actor, + ...(options.stepId === undefined ? {} : { step_id: options.stepId }), + ...(options.attemptId === undefined ? {} : { attempt_id: options.attemptId }), + payload: options.payload ?? {}, + }); + const receipt = await this.store.append( + this.stream, + this.#expectedVersion, + options.appendId ?? crypto.randomUUID(), + [event], + ); + this.#expectedVersion = receipt.last_version; + return { + ...event, + stream_id: this.stream.stream_id, + stream_version: receipt.first_version, + commit_cursor: receipt.first_cursor, + committed_at: receipt.committed_at, + }; + }); + } + + async beforeModelCall(stepId: string, payload: { [key: string]: JsonValue }): Promise { + return this.#beforeCall("model", stepId, payload); + } + + async beforeToolCall(stepId: string, payload: { [key: string]: JsonValue }): Promise { + return this.#beforeCall("tool", stepId, payload); + } + + modelCompleted(attempt: AttemptHandle, payload: { [key: string]: JsonValue }): Promise { + return this.#finishCall(attempt, "model.completed", payload); + } + + modelFailed(attempt: AttemptHandle, error: unknown): Promise { + return this.#finishCall(attempt, "model.failed", errorPayload(error)); + } + + toolCompleted(attempt: AttemptHandle, payload: { [key: string]: JsonValue }): Promise { + return this.#finishCall(attempt, "tool.completed", payload); + } + + toolFailed(attempt: AttemptHandle, error: unknown): Promise { + return this.#finishCall(attempt, "tool.failed", errorPayload(error)); + } + + async #beforeCall( + kind: "model" | "tool", + stepId: string, + payload: { [key: string]: JsonValue }, + ): Promise { + const attemptId = crypto.randomUUID(); + const event = await this.record(`${kind}.requested`, { stepId, attemptId, payload }); + return { kind, step_id: stepId, attempt_id: attemptId, requested_event_id: event.event_id }; + } + + #finishCall( + attempt: AttemptHandle, + eventType: string, + payload: { [key: string]: JsonValue }, + ): Promise { + return this.record(eventType, { stepId: attempt.step_id, attemptId: attempt.attempt_id, payload }); + } + + async #serialize(operation: () => Promise): Promise { + const ready = this.#tail; + let release!: () => void; + this.#tail = new Promise((resolve) => { release = resolve; }); + await ready; + try { + return await operation(); + } finally { + release(); + } + } +} + +function errorPayload(error: unknown): { [key: string]: JsonValue } { + return { error: error instanceof Error ? error.message : String(error) }; +} diff --git a/typescript/packages/core/src/store.ts b/typescript/packages/core/src/store.ts new file mode 100644 index 0000000..2f698e1 --- /dev/null +++ b/typescript/packages/core/src/store.ts @@ -0,0 +1,12 @@ +import type { CommitReceipt, EventStream, ProposedEvent, StoredEvent } from "./types.js"; + +export interface EventStore { + append( + stream: EventStream, + expectedVersion: number, + appendId: string, + events: readonly ProposedEvent[], + ): Promise; + readStream(stream: EventStream, afterVersion?: number): AsyncIterable; + scanSession(sessionId: string, afterCursor?: string): AsyncIterable; +} diff --git a/typescript/packages/core/src/types.ts b/typescript/packages/core/src/types.ts new file mode 100644 index 0000000..bb8934e --- /dev/null +++ b/typescript/packages/core/src/types.ts @@ -0,0 +1,88 @@ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export type ActorType = "agent" | "orchestrator" | "tool" | "human" | "system"; + +export interface Actor { + type: ActorType; + id: string; + framework?: string; +} + +export interface EventStream { + session_id: string; + stream_id: string; +} + +export interface ProposedEvent { + schema_version: "1.0"; + event_id: string; + event_type: string; + session_id: string; + run_id: string; + actor: Actor; + occurred_at: string; + step_id?: string; + attempt_id?: string; + parent_run_id?: string; + caused_by_event_id?: string; + payload: { [key: string]: JsonValue }; + extensions: { [key: string]: JsonValue }; +} + +export interface StoredEvent extends ProposedEvent { + stream_id: string; + stream_version: number; + commit_cursor: string; + committed_at: string; +} + +export interface CommitReceipt { + stream: EventStream; + append_id: string; + digest: string; + first_version: number; + last_version: number; + first_cursor: string; + last_cursor: string; + event_ids: string[]; + committed_at: string; +} + +export type RecordingGuarantee = "strict" | "best_effort" | "unsupported"; +export type RecoveryMode = "native_store" | "snapshot" | "checkpoint" | "unsupported"; + +export interface AdapterDescriptor { + schema_version: "1.0"; + adapter_id: string; + adapter_version: string; + framework: string; + framework_version: string; + capabilities: { + model_prewrite: RecordingGuarantee; + tool_prewrite: RecordingGuarantee; + outcome_gate: RecordingGuarantee; + recovery: RecoveryMode; + preserves_native_state: boolean; + }; +} + +export interface AttemptHandle { + kind: "model" | "tool"; + step_id: string; + attempt_id: string; + requested_event_id: string; +} + +export function proposedEvent( + event: Omit & + Partial>, +): ProposedEvent { + return { + schema_version: "1.0", + event_id: event.event_id ?? crypto.randomUUID(), + occurred_at: event.occurred_at ?? new Date().toISOString(), + payload: event.payload ?? {}, + extensions: event.extensions ?? {}, + ...event, + }; +} diff --git a/typescript/packages/core/test/core.test.ts b/typescript/packages/core/test/core.test.ts new file mode 100644 index 0000000..b005efe --- /dev/null +++ b/typescript/packages/core/test/core.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { test } from "node:test"; + +import { canonicalAppendDigest, DuplicateEvent, MemoryEventStore, proposedEvent } from "../src/index.js"; + +test("event streams order framework state independently from semantic runs", async () => { + const store = new MemoryEventStore(); + const stream = { session_id: "session", stream_id: "framework/pi/native" }; + const first = proposedEvent({ + event_id: "event-1", + occurred_at: "2026-01-02T03:04:05.000Z", + event_type: "framework.state.recorded", + session_id: "session", + run_id: "run-1", + actor: { type: "agent", id: "pi" }, + }); + const second = { ...first, event_id: "event-2", run_id: "run-2" }; + + await store.append(stream, -1, "append-1", [first]); + await store.append(stream, 0, "append-2", [second]); + + const events = []; + for await (const event of store.readStream(stream)) events.push(event); + assert.deepEqual(events.map((event) => event.run_id), ["run-1", "run-2"]); + assert.equal(canonicalAppendDigest([first]), canonicalAppendDigest([structuredClone(first)])); +}); + +test("append digest matches the cross-language vector", async () => { + const vector = JSON.parse( + await readFile(resolve(process.cwd(), "../conformance/vectors/append.json"), "utf8"), + ) as { events: Parameters[0]; sha256: string }; + assert.equal(canonicalAppendDigest(vector.events), vector.sha256); +}); + +test("event id uniqueness is scoped to a session", async () => { + const store = new MemoryEventStore(); + const original = proposedEvent({ + event_id: "shared-event-id", + occurred_at: "2026-01-02T03:04:05.000Z", + event_type: "test.recorded", + session_id: "session-1", + run_id: "run-1", + actor: { type: "agent", id: "test" }, + }); + const reused = { ...original, session_id: "session-2", run_id: "run-2" }; + + await store.append({ session_id: "session-1", stream_id: "run-1" }, -1, "append-1", [original]); + await store.append({ session_id: "session-2", stream_id: "run-2" }, -1, "append-2", [reused]); +}); + +test("duplicate event ids reject the whole append batch", async () => { + const store = new MemoryEventStore(); + const stream = { session_id: "session", stream_id: "run" }; + const first = proposedEvent({ + event_id: "duplicate-event", + occurred_at: "2026-01-02T03:04:05.000Z", + event_type: "test.first", + session_id: stream.session_id, + run_id: stream.stream_id, + actor: { type: "agent", id: "test" }, + }); + + await assert.rejects( + store.append(stream, -1, "append", [first, { ...first, event_type: "test.second" }]), + DuplicateEvent, + ); + const stored = []; + for await (const event of store.readStream(stream)) stored.push(event); + assert.deepEqual(stored, []); +}); diff --git a/typescript/packages/core/tsconfig.json b/typescript/packages/core/tsconfig.json new file mode 100644 index 0000000..92239e0 --- /dev/null +++ b/typescript/packages/core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "exactOptionalPropertyTypes": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/typescript/packages/pi/package.json b/typescript/packages/pi/package.json new file mode 100644 index 0000000..ba717cd --- /dev/null +++ b/typescript/packages/pi/package.json @@ -0,0 +1,22 @@ +{ + "name": "@agent-ledger/pi", + "version": "0.1.0", + "type": "module", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "dependencies": { + "@agent-ledger/core": "0.1.0" + }, + "peerDependencies": { + "@earendil-works/pi-agent-core": ">=0.80 <1" + }, + "devDependencies": { + "@earendil-works/pi-agent-core": "0.80.6" + } +} diff --git a/typescript/packages/pi/src/harness.ts b/typescript/packages/pi/src/harness.ts new file mode 100644 index 0000000..1f0e1d9 --- /dev/null +++ b/typescript/packages/pi/src/harness.ts @@ -0,0 +1,129 @@ +import { + type AdapterDescriptor, + type AttemptHandle, + type JsonValue, + SessionRecorder, +} from "@agent-ledger/core"; + +export const PI_ADAPTER: AdapterDescriptor = { + schema_version: "1.0", + adapter_id: "pi-agent-harness", + adapter_version: "1", + framework: "pi-agent-core", + framework_version: ">=0.80 <1", + capabilities: { + model_prewrite: "strict", + tool_prewrite: "strict", + outcome_gate: "strict", + recovery: "native_store", + preserves_native_state: true, + }, +}; + +export interface PiHarnessLike { + on(type: any, handler: (event: any) => any): () => void; + subscribe(listener: (event: any) => Promise | void): () => void; +} + +export function bindPiHarness(harness: PiHarnessLike, recorder: SessionRecorder): () => void { + const disposers: Array<() => void> = []; + const pendingTools = new Map(); + const pendingModels: AttemptHandle[] = []; + let turn = 0; + let stepId = crypto.randomUUID(); + let context: JsonValue = []; + let turnFailure: string | undefined; + let runFailure: string | undefined; + + disposers.push(harness.on("context", (event) => { + context = asJson(event.messages); + return { messages: event.messages }; + })); + disposers.push(harness.on("before_provider_request", async (event) => { + const attempt = await recorder.beforeModelCall(stepId, { + model: asJson(modelIdentity(event.model)), + context, + }); + pendingModels.push(attempt); + return undefined; + })); + disposers.push(harness.on("tool_call", async (event) => { + const attempt = await recorder.beforeToolCall(stepId, { + tool_call_id: event.toolCallId, + tool_name: event.toolName, + input: asJson(event.input), + }); + pendingTools.set(event.toolCallId, attempt); + return undefined; + })); + disposers.push(harness.on("tool_result", async (event) => { + const attempt = pendingTools.get(event.toolCallId); + if (!attempt) return undefined; + if (event.isError) await recorder.toolFailed(attempt, new Error(textContent(event.content))); + else await recorder.toolCompleted(attempt, { content: asJson(event.content), details: asJson(event.details) }); + pendingTools.delete(event.toolCallId); + return undefined; + })); + disposers.push(harness.subscribe(async (event) => { + switch (event.type) { + case "agent_start": + await recorder.record("run.started", { payload: { adapter: PI_ADAPTER.adapter_id } }); + break; + case "turn_start": + turn += 1; + stepId = crypto.randomUUID(); + turnFailure = undefined; + await recorder.record("step.started", { stepId, payload: { turn } }); + break; + case "message_end": { + if (event.message?.role !== "assistant") break; + const stopReason = event.message.stopReason ?? event.message.stop_reason; + if (stopReason === "error" || stopReason === "aborted") { + turnFailure = event.message.errorMessage ?? `Pi model stopped with ${stopReason}`; + runFailure = turnFailure; + } + const attempt = pendingModels.shift(); + if (!attempt) break; + if (turnFailure) await recorder.modelFailed(attempt, new Error(turnFailure)); + else { + await recorder.modelCompleted(attempt, { message: asJson(event.message) }); + } + break; + } + case "turn_end": + await recorder.record(turnFailure ? "step.failed" : "step.completed", { + stepId, + payload: turnFailure ? { turn, error: turnFailure } : { turn }, + }); + break; + case "agent_end": + await recorder.record(runFailure ? "run.failed" : "run.completed", { + payload: runFailure ? { turn_count: turn, error: runFailure } : { turn_count: turn }, + }); + break; + } + })); + + return () => { + for (const dispose of disposers.reverse()) dispose(); + }; +} + +function asJson(value: unknown): JsonValue { + if (value === undefined) return null; + return JSON.parse(JSON.stringify(value)) as JsonValue; +} + +function modelIdentity(model: unknown): Record { + if (typeof model !== "object" || model === null) return {}; + const candidate = model as Record; + return { id: candidate.id, provider: candidate.provider }; +} + +function textContent(content: unknown): string { + if (!Array.isArray(content)) return "Pi tool failed"; + return content + .filter((item): item is { text: string } => typeof item === "object" && item !== null && "text" in item) + .map((item) => item.text) + .join("\n") || "Pi tool failed"; +} diff --git a/typescript/packages/pi/src/index.ts b/typescript/packages/pi/src/index.ts new file mode 100644 index 0000000..71293d1 --- /dev/null +++ b/typescript/packages/pi/src/index.ts @@ -0,0 +1,2 @@ +export * from "./harness.js"; +export * from "./session-storage.js"; diff --git a/typescript/packages/pi/src/session-storage.ts b/typescript/packages/pi/src/session-storage.ts new file mode 100644 index 0000000..68c6227 --- /dev/null +++ b/typescript/packages/pi/src/session-storage.ts @@ -0,0 +1,172 @@ +import { + type Actor, + type EventStore, + type JsonValue, + SessionRecorder, + type StoredEvent, +} from "@agent-ledger/core"; +import type { + LeafEntry, + SessionMetadata, + SessionStorage, + SessionTreeEntry, +} from "@earendil-works/pi-agent-core"; + +export interface LedgerPiSessionStorageOptions { + store: EventStore; + sessionId: string; + runId: string; + actor: Actor; + metadata: TMetadata; +} + +const CREATED = "framework.pi.session.created"; +const ENTRY_APPENDED = "framework.pi.entry.appended"; + +/** Pi-compatible native session storage; Pi remains the owner of context reconstruction. */ +export class LedgerPiSessionStorage + implements SessionStorage { + readonly #metadata: TMetadata; + readonly #recorder: SessionRecorder; + readonly #entries: SessionTreeEntry[] = []; + readonly #byId = new Map(); + readonly #labels = new Map(); + #leafId: string | null = null; + + private constructor(metadata: TMetadata, recorder: SessionRecorder) { + this.#metadata = structuredClone(metadata); + this.#recorder = recorder; + } + + static async create( + options: LedgerPiSessionStorageOptions, + ): Promise> { + const recorder = new SessionRecorder({ + store: options.store, + sessionId: options.sessionId, + runId: options.runId, + actor: options.actor, + streamId: streamId(options.metadata.id), + }); + const storage = new LedgerPiSessionStorage(options.metadata, recorder); + await recorder.record(CREATED, { payload: { metadata: asJson(options.metadata) } }); + return storage; + } + + static async open(options: { + store: EventStore; + sessionId: string; + nativeSessionId: string; + runId: string; + actor: Actor; + }): Promise> { + const stream = { session_id: options.sessionId, stream_id: streamId(options.nativeSessionId) }; + const events: StoredEvent[] = []; + for await (const event of options.store.readStream(stream)) events.push(event); + const created = events.find((event) => event.event_type === CREATED); + if (!created) throw new Error(`Pi session ${options.nativeSessionId} does not exist`); + const metadata = created.payload.metadata as unknown as TMetadata; + const recorder = await SessionRecorder.resume({ + store: options.store, + sessionId: options.sessionId, + runId: options.runId, + actor: options.actor, + streamId: stream.stream_id, + }); + const storage = new LedgerPiSessionStorage(metadata, recorder); + for (const event of events) { + if (event.event_type === ENTRY_APPENDED) storage.#applyEntry(event.payload.entry as unknown as SessionTreeEntry); + } + return storage; + } + + async getMetadata(): Promise { + return structuredClone(this.#metadata); + } + + async getLeafId(): Promise { + return this.#leafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.#byId.has(leafId)) throw new Error(`Pi entry ${leafId} not found`); + const entry: LeafEntry = { + type: "leaf", + id: await this.createEntryId(), + parentId: this.#leafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + await this.appendEntry(entry); + } + + async createEntryId(): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const id = crypto.randomUUID().slice(-8); + if (!this.#byId.has(id)) return id; + } + return crypto.randomUUID(); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + if (this.#byId.has(entry.id)) throw new Error(`Pi entry ${entry.id} already exists`); + await this.#recorder.record(ENTRY_APPENDED, { payload: { entry: asJson(entry) } }); + this.#applyEntry(entry); + } + + async getEntry(id: string): Promise { + const entry = this.#byId.get(id); + return entry ? structuredClone(entry) : undefined; + } + + async findEntries( + type: TType, + ): Promise>> { + return this.#entries + .filter((entry) => entry.type === type) + .map((entry) => structuredClone(entry) as Extract); + } + + async getLabel(id: string): Promise { + return this.#labels.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) return []; + const path: SessionTreeEntry[] = []; + let current = this.#byId.get(leafId); + if (!current) throw new Error(`Pi entry ${leafId} not found`); + while (current) { + path.unshift(structuredClone(current)); + if (current.parentId === null) break; + current = this.#byId.get(current.parentId); + if (!current) throw new Error("Pi session contains a dangling parent reference"); + } + return path; + } + + async getEntries(): Promise { + return structuredClone(this.#entries); + } + + #applyEntry(entry: SessionTreeEntry): void { + const snapshot = structuredClone(entry); + this.#entries.push(snapshot); + this.#byId.set(snapshot.id, snapshot); + this.#leafId = snapshot.type === "leaf" ? (snapshot.targetId as string | null) : snapshot.id; + if (snapshot.type === "label") { + const targetId = snapshot.targetId as string; + const label = typeof snapshot.label === "string" ? snapshot.label.trim() : ""; + if (label) this.#labels.set(targetId, label); + else this.#labels.delete(targetId); + } + } +} + +function streamId(nativeSessionId: string): string { + return `framework/pi/${nativeSessionId}`; +} + +function asJson(value: unknown): JsonValue { + return JSON.parse(JSON.stringify(value)) as JsonValue; +} diff --git a/typescript/packages/pi/test/pi.test.ts b/typescript/packages/pi/test/pi.test.ts new file mode 100644 index 0000000..ae54dc2 --- /dev/null +++ b/typescript/packages/pi/test/pi.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + type CommitReceipt, + type EventStream, + MemoryEventStore, + type ProposedEvent, + SessionRecorder, +} from "@agent-ledger/core"; + +import { bindPiHarness, LedgerPiSessionStorage } from "../src/index.js"; + +test("Pi native entries round-trip through ledger storage", async () => { + const store = new MemoryEventStore(); + const actor = { type: "agent" as const, id: "pi", framework: "pi" }; + const storage = await LedgerPiSessionStorage.create({ + store, + sessionId: "ledger-session", + runId: "runtime-1", + actor, + metadata: { id: "pi-session", createdAt: "2026-01-02T03:04:05.000Z" }, + }); + await storage.appendEntry({ + type: "message", + id: "entry-1", + parentId: null, + timestamp: "2026-01-02T03:04:06.000Z", + message: { role: "user", content: "hello", timestamp: Date.now() }, + }); + + const restored = await LedgerPiSessionStorage.open({ + store, + sessionId: "ledger-session", + nativeSessionId: "pi-session", + runId: "runtime-2", + actor, + }); + + assert.equal(await restored.getLeafId(), "entry-1"); + assert.deepEqual(await restored.getPathToRoot("entry-1"), await storage.getEntries()); +}); + +test("Pi direct harness hooks record model boundaries", async () => { + const store = new MemoryEventStore(); + const recorder = new SessionRecorder({ + store, + sessionId: "session", + runId: "run", + actor: { type: "agent", id: "pi" }, + }); + const harness = new FakeHarness(); + bindPiHarness(harness, recorder); + + await harness.emit("turn_start", {}); + await harness.hook("context", { messages: [{ role: "user", content: "hello" }] }); + await harness.hook("before_provider_request", { model: { id: "model", provider: "test" } }); + await harness.emit("message_end", { message: { role: "assistant", content: "done", stopReason: "stop" } }); + await harness.emit("turn_end", {}); + + const events = []; + for await (const event of store.readStream({ session_id: "session", stream_id: "run" })) events.push(event); + assert.deepEqual(events.map((event) => event.event_type), [ + "step.started", + "model.requested", + "model.completed", + "step.completed", + ]); +}); + +test("Pi direct harness hook fails closed before model execution", async () => { + const store = new ModelPrewriteFailingStore(); + const recorder = new SessionRecorder({ + store, + sessionId: "session", + runId: "run", + actor: { type: "agent", id: "pi" }, + }); + const harness = new FakeHarness(); + bindPiHarness(harness, recorder); + await harness.emit("turn_start", {}); + let modelStarted = false; + + await assert.rejects(async () => { + await harness.hook("before_provider_request", { model: { id: "model", provider: "test" } }); + modelStarted = true; + }, /prewrite failed/); + assert.equal(modelStarted, false); +}); + +test("Pi model errors fail the enclosing step and run", async () => { + const store = new MemoryEventStore(); + const recorder = new SessionRecorder({ + store, + sessionId: "session", + runId: "run", + actor: { type: "agent", id: "pi" }, + }); + const harness = new FakeHarness(); + bindPiHarness(harness, recorder); + + await harness.emit("agent_start", {}); + await harness.emit("turn_start", {}); + await harness.hook("before_provider_request", { model: { id: "model", provider: "test" } }); + await harness.emit("message_end", { + message: { role: "assistant", stopReason: "error", errorMessage: "rate limited" }, + }); + await harness.emit("turn_end", {}); + await harness.emit("agent_end", {}); + + const events = []; + for await (const event of store.readStream({ session_id: "session", stream_id: "run" })) events.push(event); + assert.deepEqual(events.map((event) => event.event_type), [ + "run.started", + "step.started", + "model.requested", + "model.failed", + "step.failed", + "run.failed", + ]); +}); + +class FakeHarness { + readonly #hooks = new Map any>>(); + readonly #listeners: Array<(event: any) => Promise | void> = []; + + on(type: any, handler: (event: any) => any): () => void { + const handlers = this.#hooks.get(String(type)) ?? []; + handlers.push(handler); + this.#hooks.set(String(type), handlers); + return () => { handlers.splice(handlers.indexOf(handler), 1); }; + } + + subscribe(listener: (event: any) => Promise | void): () => void { + this.#listeners.push(listener); + return () => { this.#listeners.splice(this.#listeners.indexOf(listener), 1); }; + } + + async hook(type: string, event: Record): Promise { + for (const handler of this.#hooks.get(type) ?? []) await handler({ type, ...event }); + } + + async emit(type: string, event: Record): Promise { + for (const listener of this.#listeners) await listener({ type, ...event }); + } +} + +class ModelPrewriteFailingStore extends MemoryEventStore { + override async append( + stream: EventStream, + expectedVersion: number, + appendId: string, + events: readonly ProposedEvent[], + ): Promise { + if (events.some((event) => event.event_type === "model.requested")) throw new Error("prewrite failed"); + return super.append(stream, expectedVersion, appendId, events); + } +} diff --git a/typescript/packages/pi/tsconfig.json b/typescript/packages/pi/tsconfig.json new file mode 100644 index 0000000..5f49538 --- /dev/null +++ b/typescript/packages/pi/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "exactOptionalPropertyTypes": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "references": [{ "path": "../core" }] +} diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 0000000..acd27a2 --- /dev/null +++ b/typescript/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./packages/core" }, + { "path": "./packages/pi" } + ] +} From b43e035b6a176374d15865b8c43ea98010a21d40 Mon Sep 17 00:00:00 2001 From: "liqiankun.1111" Date: Sun, 9 Aug 2026 12:28:37 +0800 Subject: [PATCH 2/3] fix: support MySQL 8 authentication --- python/pyproject.toml | 3 +- python/uv.lock | 167 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 823c53a..d3b428e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -29,13 +29,14 @@ Issues = "https://github.com/compforge/agent-ledger/issues" [project.optional-dependencies] redis = ["redis>=5,<7"] sql = ["SQLAlchemy[asyncio]>=2.0,<3", "aiosqlite>=0.20,<1"] -mysql = ["SQLAlchemy[asyncio]>=2.0,<3", "asyncmy>=0.2,<1"] +mysql = ["SQLAlchemy[asyncio]>=2.0,<3", "asyncmy>=0.2,<1", "cryptography>=42"] postgres = ["SQLAlchemy[asyncio]>=2.0,<3", "asyncpg>=0.30,<1"] all = [ "redis>=5,<7", "SQLAlchemy[asyncio]>=2.0,<3", "aiosqlite>=0.20,<1", "asyncmy>=0.2,<1", + "cryptography>=42", "asyncpg>=0.30,<1", ] diff --git a/python/uv.lock b/python/uv.lock index 134ea65..e370504 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -20,11 +20,13 @@ all = [ { name = "aiosqlite" }, { name = "asyncmy" }, { name = "asyncpg" }, + { name = "cryptography" }, { name = "redis" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] mysql = [ { name = "asyncmy" }, + { name = "cryptography" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] postgres = [ @@ -56,6 +58,8 @@ requires-dist = [ { name = "asyncmy", marker = "extra == 'mysql'", specifier = ">=0.2,<1" }, { name = "asyncpg", marker = "extra == 'all'", specifier = ">=0.30,<1" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.30,<1" }, + { name = "cryptography", marker = "extra == 'all'", specifier = ">=42" }, + { name = "cryptography", marker = "extra == 'mysql'", specifier = ">=42" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "redis", marker = "extra == 'all'", specifier = ">=5,<7" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=5,<7" }, @@ -208,6 +212,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -217,6 +319,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "greenlet" version = "3.5.4" @@ -532,6 +690,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" From 0871630644fd2e1bcac6a0c8ce561be14c73402e Mon Sep 17 00:00:00 2001 From: "liqiankun.1111" Date: Sun, 9 Aug 2026 13:09:33 +0800 Subject: [PATCH 3/3] fix: address adapter review findings --- go/adapters/agentgo/adapter_test.go | 54 +++++++++++++++++++++++++++++ go/adapters/agentgo/model.go | 6 +++- go/memory_store.go | 6 ++-- go/recorder.go | 4 +++ go/store_test.go | 10 ++++++ python/src/agent_ledger/recorder.py | 11 +++--- python/tests/test_recorder.py | 31 +++++++++++++++++ python/tests/test_store_contract.py | 9 +++++ 8 files changed, 124 insertions(+), 7 deletions(-) diff --git a/go/adapters/agentgo/adapter_test.go b/go/adapters/agentgo/adapter_test.go index da493b8..9a498c7 100644 --- a/go/adapters/agentgo/adapter_test.go +++ b/go/adapters/agentgo/adapter_test.go @@ -94,6 +94,43 @@ func TestWrappedModelRecordsPhysicalAttempt(t *testing.T) { } } +func TestWrappedModelEarlyCloseHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + store := agentledger.NewMemoryEventStore() + adapter := newTestAdapter(t, ctx, store) + stream, err := adapter.WrapModel(nonTerminalStreamModel{}).GenerateStream(ctx, nil, nil) + if err != nil { + t.Fatalf("generate stream: %v", err) + } + + deadline := time.Now().Add(time.Second) + for len(stream) < 16 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if len(stream) != 16 { + t.Fatalf("buffered events = %d, want 16", len(stream)) + } + cancel() + + var events []agentgo.StreamEvent + closeDeadline := time.After(time.Second) +collect: + for { + select { + case event, ok := <-stream: + if !ok { + break collect + } + events = append(events, event) + case <-closeDeadline: + t.Fatal("wrapped stream did not close after cancellation") + } + } + if len(events) != 16 { + t.Fatalf("forwarded events = %d, want 16 without a post-cancel terminal event", len(events)) + } +} + func newTestAdapter(t *testing.T, ctx context.Context, store agentledger.EventStore) *Adapter { t.Helper() adapter, err := New(ctx, Config{ @@ -134,6 +171,23 @@ func (*countingModel) GenerateStream(context.Context, []agentgo.Message, []agent func (*countingModel) SupportsTools() bool { return true } +type nonTerminalStreamModel struct{} + +func (nonTerminalStreamModel) Generate(context.Context, []agentgo.Message, []agentgo.ToolSpec, ...agentgo.CallOption) (*agentgo.LLMResponse, error) { + return nil, errors.New("not implemented") +} + +func (nonTerminalStreamModel) GenerateStream(context.Context, []agentgo.Message, []agentgo.ToolSpec, ...agentgo.CallOption) (<-chan agentgo.StreamEvent, error) { + stream := make(chan agentgo.StreamEvent, 16) + for range 16 { + stream <- agentgo.StreamEvent{} + } + close(stream) + return stream, nil +} + +func (nonTerminalStreamModel) SupportsTools() bool { return true } + type failingStore struct { agentledger.EventStore eventType string diff --git a/go/adapters/agentgo/model.go b/go/adapters/agentgo/model.go index 275658b..6906bcd 100644 --- a/go/adapters/agentgo/model.go +++ b/go/adapters/agentgo/model.go @@ -74,7 +74,11 @@ func (m *recordingModel) GenerateStream(ctx context.Context, messages []agentgo. if err := m.adapter.runtimeRecorder.ModelFailed(ctx, attempt, failure); err != nil { failure = fmt.Errorf("%v; record failure: %w", failure, err) } - output <- agentgo.StreamEvent{Type: agentgo.StreamEventError, Err: failure} + select { + case output <- agentgo.StreamEvent{Type: agentgo.StreamEventError, Err: failure}: + case <-ctx.Done(): + return + } } }() return output, nil diff --git a/go/memory_store.go b/go/memory_store.go index dcff028..c8eb1a7 100644 --- a/go/memory_store.go +++ b/go/memory_store.go @@ -121,8 +121,9 @@ func (s *MemoryEventStore) Append( func (s *MemoryEventStore) Load(ctx context.Context, stream EventStream, afterVersion int64) iter.Seq2[StoredEvent, error] { return func(yield func(StoredEvent, error) bool) { s.mu.Lock() - events, err := clone(s.streams[streamKey(stream)]) + snapshot := append([]StoredEvent(nil), s.streams[streamKey(stream)]...) s.mu.Unlock() + events, err := clone(snapshot) if err != nil { yield(StoredEvent{}, err) return @@ -151,8 +152,9 @@ func (s *MemoryEventStore) ScanSession(ctx context.Context, sessionID, afterCurs return } s.mu.Lock() - events, cloneErr := clone(s.sessions[sessionID]) + snapshot := append([]StoredEvent(nil), s.sessions[sessionID]...) s.mu.Unlock() + events, cloneErr := clone(snapshot) if cloneErr != nil { yield(StoredEvent{}, cloneErr) return diff --git a/go/recorder.go b/go/recorder.go index d90dba3..ee29018 100644 --- a/go/recorder.go +++ b/go/recorder.go @@ -2,6 +2,7 @@ package agentledger import ( "context" + "errors" "fmt" "sync" ) @@ -43,6 +44,9 @@ func NewSessionRecorder(options RecorderOptions) *SessionRecorder { } func ResumeRecorder(ctx context.Context, options RecorderOptions) (*SessionRecorder, error) { + if options.ExpectedVersion != nil { + return nil, errors.New("resume recorder discovers the stream head; expected version must be nil") + } recorder := NewSessionRecorder(options) expectedVersion := int64(-1) for event, err := range options.Store.Load(ctx, recorder.stream, -1) { diff --git a/go/store_test.go b/go/store_test.go index 35ad9ba..12f0047 100644 --- a/go/store_test.go +++ b/go/store_test.go @@ -86,3 +86,13 @@ func TestDuplicateEventIDRejectsWholeBatch(t *testing.T) { t.Fatalf("unexpected stored event %#v, error %v", event, err) } } + +func TestResumeRecorderRejectsExpectedVersion(t *testing.T) { + expectedVersion := int64(0) + _, err := ResumeRecorder(context.Background(), RecorderOptions{ + Store: NewMemoryEventStore(), ExpectedVersion: &expectedVersion, + }) + if err == nil { + t.Fatal("resume accepted an explicit expected version") + } +} diff --git a/python/src/agent_ledger/recorder.py b/python/src/agent_ledger/recorder.py index cd0bb6c..e4deb40 100644 --- a/python/src/agent_ledger/recorder.py +++ b/python/src/agent_ledger/recorder.py @@ -28,7 +28,7 @@ class AttemptHandle(BaseModel): class SessionRecorder: - """Framework-facing recorder that serializes writes to one execution stream.""" + """Framework-facing recorder that serializes writes to one event stream.""" def __init__( self, @@ -37,6 +37,7 @@ def __init__( session_id: str, run_id: str, actor: Actor, + stream_id: str | None = None, expected_version: int = -1, parent_run_id: str | None = None, caused_by_event_id: str | None = None, @@ -45,7 +46,7 @@ def __init__( raise ValueError("parent_run_id and caused_by_event_id must be set together") self.store = store self.run_id = run_id - self.stream = EventStream(session_id=session_id, stream_id=run_id) + self.stream = EventStream(session_id=session_id, stream_id=stream_id or run_id) self.actor = actor self.expected_version = expected_version self.parent_run_id = parent_run_id @@ -60,14 +61,15 @@ async def resume( session_id: str, run_id: str, actor: Actor, + stream_id: str | None = None, ) -> Self: - stream = EventStream(session_id=session_id, stream_id=run_id) + stream = EventStream(session_id=session_id, stream_id=stream_id or run_id) expected_version = -1 parent_run_id: str | None = None caused_by_event_id: str | None = None async for event in store.read_stream(stream): expected_version = event.stream_version - if event.parent_run_id is not None: + if event.run_id == run_id and event.parent_run_id is not None: parent_run_id = event.parent_run_id caused_by_event_id = event.caused_by_event_id return cls( @@ -75,6 +77,7 @@ async def resume( session_id=session_id, run_id=run_id, actor=actor, + stream_id=stream.stream_id, expected_version=expected_version, parent_run_id=parent_run_id, caused_by_event_id=caused_by_event_id, diff --git a/python/tests/test_recorder.py b/python/tests/test_recorder.py index bed86f7..74ffae8 100644 --- a/python/tests/test_recorder.py +++ b/python/tests/test_recorder.py @@ -114,6 +114,37 @@ async def test_recorder_resume_continues_at_last_version() -> None: assert [event.stream_version for event in events] == [0, 1] +async def test_recorder_state_stream_spans_runtime_runs() -> None: + store = MemoryEventStore() + session_id = str(uuid4()) + stream_id = "framework/test/native-session" + actor = Actor(type="agent", id="test", framework="test") + original = SessionRecorder( + store=store, + session_id=session_id, + run_id="runtime-1", + actor=actor, + stream_id=stream_id, + parent_run_id="orchestrator-run", + caused_by_event_id="delegate-event", + ) + await original.start_run() + + resumed = await SessionRecorder.resume( + store=store, + session_id=session_id, + run_id="runtime-2", + actor=actor, + stream_id=stream_id, + ) + await resumed.start_run() + + events = [event async for event in store.read_stream(original.stream)] + assert [event.run_id for event in events] == ["runtime-1", "runtime-2"] + assert [event.stream_version for event in events] == [0, 1] + assert [event.parent_run_id for event in events] == ["orchestrator-run", None] + + def _recorder(store: MemoryEventStore) -> SessionRecorder: return SessionRecorder( store=store, diff --git a/python/tests/test_store_contract.py b/python/tests/test_store_contract.py index 8c989c5..72ad735 100644 --- a/python/tests/test_store_contract.py +++ b/python/tests/test_store_contract.py @@ -126,6 +126,15 @@ async def test_event_id_uniqueness_is_scoped_to_session(event_store: EventStore) [event(second_session, second_run, event_id=event_id)], ) + first_events = await collect( + event_store.read_stream(EventStream(session_id=first_session, stream_id=first_run)) + ) + second_events = await collect( + event_store.read_stream(EventStream(session_id=second_session, stream_id=second_run)) + ) + assert [item.event_id for item in first_events] == [event_id] + assert [item.event_id for item in second_events] == [event_id] + async def test_session_scan_merges_runs_and_pages(event_store: EventStore) -> None: session_id = str(uuid4())