From c37abcba15d2c4c152f9f873137637d15064f4e7 Mon Sep 17 00:00:00 2001 From: luxwxin Date: Wed, 29 Jul 2026 17:59:28 +0800 Subject: [PATCH] feat(samples): add external session storage examples Add self-contained Redis and PostgreSQL SessionStore adapters for TypeScript and Python. Demonstrate cross-host session resume after removing the local transcript, document storage behavior and production considerations, and register the samples in CI and repository indexes. Update every sample to the latest published SDK versions and regenerate the workspace lockfiles. --- .github/workflows/ci.yml | 4 +- CONTRIBUTING.md | 18 +- README.md | 12 +- README.zh-CN.md | 10 +- python/README.md | 5 +- python/ask-user-question/requirements.txt | 2 +- python/code-review/requirements.txt | 2 +- python/custom-tools/requirements.txt | 2 +- python/external-session-storage/README.md | 143 ++++++++ .../postgres_sample.py | 278 +++++++++++++++ .../external-session-storage/redis_sample.py | 258 ++++++++++++++ .../external-session-storage/requirements.txt | 3 + python/hooks/requirements.txt | 2 +- python/model-selection/requirements.txt | 2 +- .../multi-turn-conversation/requirements.txt | 2 +- python/pyproject.toml | 8 +- python/quickstart/requirements.txt | 2 +- python/streaming-chat/requirements.txt | 2 +- python/subagents/requirements.txt | 2 +- python/tool-permissions/requirements.txt | 2 +- python/uv.lock | 108 +++++- typescript/README.md | 4 +- typescript/ask-user-question/package.json | 2 +- typescript/code-review/package.json | 2 +- typescript/custom-tools/package.json | 2 +- typescript/external-session-storage/README.md | 142 ++++++++ .../external-session-storage/package.json | 21 ++ .../postgres-sample.ts | 271 +++++++++++++++ .../external-session-storage/redis-sample.ts | 265 ++++++++++++++ typescript/hooks/package.json | 2 +- typescript/model-selection/package.json | 2 +- .../multi-turn-conversation/package.json | 2 +- typescript/package-lock.json | 328 ++++++++++++++++-- typescript/package.json | 3 +- typescript/quickstart/package.json | 2 +- typescript/streaming-chat/package.json | 2 +- typescript/subagents/package.json | 2 +- typescript/tool-permissions/package.json | 2 +- 38 files changed, 1827 insertions(+), 94 deletions(-) create mode 100644 python/external-session-storage/README.md create mode 100644 python/external-session-storage/postgres_sample.py create mode 100644 python/external-session-storage/redis_sample.py create mode 100644 python/external-session-storage/requirements.txt create mode 100644 typescript/external-session-storage/README.md create mode 100644 typescript/external-session-storage/package.json create mode 100644 typescript/external-session-storage/postgres-sample.ts create mode 100644 typescript/external-session-storage/redis-sample.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d36c2c..f1b1d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: working-directory: python - name: Type-check samples run: | - for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents; do - uv run mypy "$sample/main.py" + for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents external-session-storage; do + uv run mypy "$sample" done working-directory: python diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c9579a..0c201e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,11 +46,12 @@ Use an existing sample (for example, `quickstart`) as a template, then register the new sample everywhere it needs to appear. Replace `` with your kebab-case sample name. -1. **Create the TypeScript sample** at `typescript//` with `index.ts`, - `README.md`, and `package.json`. Copy `package.json` from an existing sample - and update the `name` field to `@qoder-samples/typescript-`. -2. **Create the Python sample** at `python//` with `main.py`, - `README.md`, and `requirements.txt`. +1. **Create the TypeScript sample** at `typescript//` with one or more + runnable `.ts` entry files, `README.md`, and `package.json`. Copy + `package.json` from an existing sample and update the `name` field to + `@qoder-samples/typescript-`. +2. **Create the Python sample** at `python//` with one or more runnable + `.py` entry files, `README.md`, and `requirements.txt`. 3. **Register the TypeScript workspace:** add `` to the `workspaces` array in `typescript/package.json`. 4. **Register the Python type check:** add `` to the `for sample in ...` @@ -59,7 +60,8 @@ kebab-case sample name. in `README.zh-CN.md`. Keep the TypeScript and Python versions behaviorally equivalent so readers can -switch between languages. +switch between languages. When one concept has multiple independently copyable +variants, keep them in one sample directory and list every entry in its README. ## Running the checks @@ -69,8 +71,8 @@ request: ```bash cd typescript && npm ci && npm run check --workspaces cd ../python && uv sync && uv run ruff check . && uv run ruff format --check . -for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents; do - uv run mypy "$sample/main.py" +for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents external-session-storage; do + uv run mypy "$sample" done ``` diff --git a/README.md b/README.md index 48c848c..5ce3dec 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ Every sample is available in TypeScript and Python. | Hooks | Add lifecycle observation, context injection, and tool policy | [Open](typescript/hooks) | [Open](python/hooks) | | Custom tools | Expose application functions as in-process MCP tools | [Open](typescript/custom-tools) | [Open](python/custom-tools) | | Subagents | Delegate a task to specialized SDK-defined agents | [Open](typescript/subagents) | [Open](python/subagents) | +| External session storage | Resume a session on another runtime through Redis or PostgreSQL | [Open](typescript/external-session-storage) | [Open](python/external-session-storage) | ## Setup -These samples read a Personal Access Token from the environment. See -[SDK Authentication](https://docs.qoder.com/en/cli/sdk/authentication) for the +Unless a sample README says otherwise, samples read a Personal Access Token +from the environment. See +[SDK Authentication](https://docs.qoder.com/en/cli/sdk/authentication) for setup and other supported authentication methods. ```bash @@ -45,10 +47,10 @@ commands. The sample manifests declare compatible SDK version ranges, while the repository lockfiles record the exact versions used by CI. -Last verified on July 20, 2026: +Last verified on July 29, 2026: -- TypeScript SDK 1.0.15 -- Python SDK 1.0.9 +- TypeScript SDK 1.0.16 +- Python SDK 1.0.10 ## License and terms diff --git a/README.zh-CN.md b/README.zh-CN.md index d3dc3a5..68f575a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,10 +17,12 @@ TypeScript 和 Python 实现。 | Hooks | 添加生命周期观测、上下文注入和工具策略 | [查看](typescript/hooks) | [查看](python/hooks) | | Custom tools | 将应用函数注册为进程内 MCP 工具 | [查看](typescript/custom-tools) | [查看](python/custom-tools) | | Subagents | 将任务委派给 SDK 定义的专业子 Agent | [查看](typescript/subagents) | [查看](python/subagents) | +| External session storage | 通过 Redis 或 PostgreSQL 在另一个运行实例上恢复会话 | [查看](typescript/external-session-storage) | [查看](python/external-session-storage) | ## 准备工作 -这些示例从环境变量读取 Personal Access Token。认证配置和其他可用认证方式见 +除非示例 README 另有说明,否则示例从环境变量读取 Personal Access Token。认证 +配置和其他可用认证方式见 [SDK 认证文档](https://docs.qoder.com/en/cli/sdk/authentication)。 ```bash @@ -36,10 +38,10 @@ TypeScript 需要 Node.js 18 或更高版本,Python 需要 Python 3.10 或更 各示例的依赖清单声明了兼容的 SDK 版本范围,仓库锁文件则记录了 CI 使用的确切版本。 -最近一次验证时间:2026 年 7 月 20 日: +最近一次验证时间:2026 年 7 月 29 日: -- TypeScript SDK 1.0.15 -- Python SDK 1.0.9 +- TypeScript SDK 1.0.16 +- Python SDK 1.0.10 ## 许可与条款 diff --git a/python/README.md b/python/README.md index fb2a3d1..4bfd5cd 100644 --- a/python/README.md +++ b/python/README.md @@ -1,8 +1,9 @@ # Python samples Each directory is an independent Python project. Create a virtual environment -inside the sample directory, install its requirements, and run `main.py`. -Complete the repository-level setup before running a sample. +inside the sample directory, install its requirements, and run the entry file +documented in its README. Complete the repository-level setup before running a +sample. The repository-level development environment uses `uv` only for maintainer checks; users do not need `uv` to run a sample. diff --git a/python/ask-user-question/requirements.txt b/python/ask-user-question/requirements.txt index 621626d..c72e900 100644 --- a/python/ask-user-question/requirements.txt +++ b/python/ask-user-question/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/code-review/requirements.txt b/python/code-review/requirements.txt index 621626d..c72e900 100644 --- a/python/code-review/requirements.txt +++ b/python/code-review/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/custom-tools/requirements.txt b/python/custom-tools/requirements.txt index 621626d..c72e900 100644 --- a/python/custom-tools/requirements.txt +++ b/python/custom-tools/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/external-session-storage/README.md b/python/external-session-storage/README.md new file mode 100644 index 0000000..63b0971 --- /dev/null +++ b/python/external-session-storage/README.md @@ -0,0 +1,143 @@ +# External session storage with Redis or PostgreSQL + +Copy and run one self-contained sample for the storage backend used by the +application: + +| Sample | Backend | Run | +| --- | --- | --- | +| [`redis_sample.py`](redis_sample.py) | Redis lists and indexes | `python redis_sample.py /path/to/repository` | +| [`postgres_sample.py`](postgres_sample.py) | PostgreSQL JSONB rows | `python postgres_sample.py /path/to/repository` | + +Each file contains both a complete `SessionStore` adapter and the same +end-to-end handoff: + +```text +host A shared store host B +local transcript -- append --> Redis/PostgreSQL <-- load -- no transcript + | | + creates session resumes session + | + local transcript is deleted before host B starts +``` + +The application asks Host A to remember a unique marker, deletes its local +transcript, resumes the session through the external store, and verifies that +Host B returns the marker. + +Complete the [repository setup](../../README.md#setup), then: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Both samples use the same absolute workspace path for Host A and Host B. +Session storage uses the canonical workspace path to identify the project. + +## Redis sample + +Start Redis: + +```bash +docker run --rm --name qoder-session-redis \ + -p 6379:6379 \ + redis:7-alpine +``` + +In another terminal: + +```bash +python redis_sample.py /path/to/repository +``` + +The default connection is `redis://127.0.0.1:6379`. Override it when needed: + +```bash +export QODER_SAMPLE_REDIS_URL="redis://127.0.0.1:6379" +``` + +`redis_sample.py` is independently copyable. It needs `qoder-agent-sdk` and +`redis`, and stores: + +```text +entries:::main Redis list, append order +entries:::child:* Redis list, append order +sessions: sorted set, modification time +subkeys:: set of child transcript paths +``` + +Project keys, session IDs, and child paths are base64url-encoded before they +become Redis key segments. + +## PostgreSQL sample + +Start PostgreSQL: + +```bash +docker run --rm --name qoder-session-postgres \ + -e POSTGRES_USER=qoder \ + -e POSTGRES_PASSWORD=qoder \ + -e POSTGRES_DB=qoder_session_store \ + -p 5432:5432 \ + postgres:17-alpine +``` + +In another terminal: + +```bash +python postgres_sample.py /path/to/repository +``` + +The default connection uses the development credentials from the Docker +command. Override it when needed: + +```bash +export QODER_SAMPLE_POSTGRES_URL="postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store" +``` + +`postgres_sample.py` is independently copyable. It needs `qoder-agent-sdk` and +`asyncpg`. The adapter creates `qoder_session_entries`: each transcript entry +is one JSONB row, the increasing `seq` column preserves replay order, and the +`(project_key, session_id, subpath, seq)` index supports loading main and child +transcripts. + +## SessionStore behavior + +The SDK-facing application code needs only a store instance: + +```python +options = QoderAgentOptions( + auth=access_token_from_env(), + cwd=workspace, + session_store=store, + resume=session_id, +) + +async for message in query(prompt=prompt, options=options): + ... +``` + +`append()` receives newly committed entries. `load()` returns the complete +history for one key in append order. `list_sessions()`, `list_subkeys()`, and +`delete()` enable latest-session resume, child-agent restoration, and deletion. + +Each sample removes its local and external transcript data during cleanup. + +## Production considerations + +These adapters are readable reference implementations, not production-ready +database packages. Before deployment: + +- isolate tenants in the Redis prefix or PostgreSQL schema +- define retention, backup, encryption, and access control +- serialize concurrent writers for the same session +- account for a failed `append()` being retried with the same entries +- monitor `SDKMirrorErrorMessage`; the conversation can succeed even when an + external write fails +- configure Redis eviction and TTL behavior for transcript retention +- size the PostgreSQL pool and schedule deletion of expired rows +- add backend-specific load, concurrency, failover, and cleanup tests + +SessionStore contains transcript history only. It does not store credentials, +application settings, file checkpoints, or external tool side effects. diff --git a/python/external-session-storage/postgres_sample.py b/python/external-session-storage/postgres_sample.py new file mode 100644 index 0000000..3d71723 --- /dev/null +++ b/python/external-session-storage/postgres_sample.py @@ -0,0 +1,278 @@ +"""Runnable PostgreSQL SessionStore reference sample.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import re +import uuid +from pathlib import Path +from typing import cast + +import asyncpg +from qoder_agent_sdk import ( + QoderAgentOptions, + ResultMessage, + SDKMirrorErrorMessage, + SessionKey, + SessionListSubkeysKey, + SessionStore, + SessionStoreEntry, + SessionStoreListEntry, + access_token_from_env, + delete_session, + delete_session_via_store, + get_session_info, + query, +) + +_SQL_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class PostgresSessionStore(SessionStore): + """Store one transcript entry per PostgreSQL JSONB row.""" + + def __init__( + self, + pool: asyncpg.Pool, + table_name: str = "qoder_session_entries", + ) -> None: + if not _SQL_IDENTIFIER.fullmatch(table_name): + raise ValueError( + f"Invalid table name {table_name!r}. " + "Use letters, digits, and underscores." + ) + self._pool = pool + self._table_name = table_name + + async def ensure_schema(self) -> None: + """Create the entry table and lookup index when absent.""" + + await self._pool.execute( + f""" + CREATE TABLE IF NOT EXISTS {self._table_name} ( + seq BIGSERIAL PRIMARY KEY, + project_key TEXT NOT NULL, + session_id TEXT NOT NULL, + subpath TEXT NOT NULL DEFAULT '', + entry JSONB NOT NULL, + modified_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ); + CREATE INDEX IF NOT EXISTS {self._table_name}_lookup_idx + ON {self._table_name} + (project_key, session_id, subpath, seq); + """ + ) + + async def append( + self, + key: SessionKey, + entries: list[SessionStoreEntry], + ) -> None: + if not entries: + return + + await self._pool.execute( + f""" + INSERT INTO {self._table_name} + (project_key, session_id, subpath, entry) + SELECT $1, $2, $3, value + FROM unnest($4::jsonb[]) WITH ORDINALITY AS item(value, position) + ORDER BY position + """, + key["project_key"], + key["session_id"], + key.get("subpath", ""), + [json.dumps(entry, separators=(",", ":")) for entry in entries], + ) + + async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: + rows = await self._pool.fetch( + f""" + SELECT entry + FROM {self._table_name} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + ORDER BY seq + """, + key["project_key"], + key["session_id"], + key.get("subpath", ""), + ) + if not rows: + return None + entries: list[SessionStoreEntry] = [] + for row in rows: + value = row["entry"] + decoded = json.loads(value) if isinstance(value, str) else value + entries.append(cast(SessionStoreEntry, decoded)) + return entries + + async def list_sessions( + self, + project_key: str, + ) -> list[SessionStoreListEntry]: + rows = await self._pool.fetch( + f""" + SELECT session_id, + EXTRACT(EPOCH FROM MAX(modified_at)) * 1000 AS mtime + FROM {self._table_name} + WHERE project_key = $1 AND subpath = '' + GROUP BY session_id + ORDER BY mtime DESC + """, + project_key, + ) + return [ + { + "session_id": cast(str, row["session_id"]), + "mtime": int(row["mtime"]), + } + for row in rows + ] + + async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]: + rows = await self._pool.fetch( + f""" + SELECT DISTINCT subpath + FROM {self._table_name} + WHERE project_key = $1 + AND session_id = $2 + AND subpath <> '' + ORDER BY subpath + """, + key["project_key"], + key["session_id"], + ) + return [cast(str, row["subpath"]) for row in rows] + + async def delete(self, key: SessionKey) -> None: + subpath = key.get("subpath") + if subpath is None: + await self._pool.execute( + f""" + DELETE FROM {self._table_name} + WHERE project_key = $1 AND session_id = $2 + """, + key["project_key"], + key["session_id"], + ) + return + + await self._pool.execute( + f""" + DELETE FROM {self._table_name} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + """, + key["project_key"], + key["session_id"], + subpath, + ) + + +async def run_query( + *, + workspace: Path, + prompt: str, + store: SessionStore, + resume: str | None = None, +) -> ResultMessage: + options = QoderAgentOptions( + auth=access_token_from_env(), + cwd=workspace, + tools=[], + max_turns=1, + model="auto", + session_store=store, + resume=resume, + ) + result: ResultMessage | None = None + async for message in query(prompt=prompt, options=options): + if isinstance(message, SDKMirrorErrorMessage): + raise RuntimeError( + f"SessionStore could not persist {message.key}: {message.error}" + ) + if isinstance(message, ResultMessage): + if message.subtype != "success": + raise RuntimeError("\n".join(message.errors or [message.subtype])) + result = message + + if result is None: + raise RuntimeError("The query ended without a success result.") + return result + + +async def run(workspace: Path) -> None: + pool = await asyncpg.create_pool( + os.getenv( + "QODER_SAMPLE_POSTGRES_URL", + "postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store", + ) + ) + if pool is None: + raise RuntimeError("Could not create the PostgreSQL connection pool.") + store = PostgresSessionStore(pool) + marker = f"session-storage-{uuid.uuid4()}" + session_id: str | None = None + + try: + await store.ensure_schema() + print("[host-a] Starting a session with PostgreSQL storage.") + first = await run_query( + workspace=workspace, + store=store, + prompt=( + f"Remember this exact deployment marker: {marker}. " + "Reply only that it is stored." + ), + ) + session_id = first.session_id + delete_session(session_id, str(workspace)) + print(f"[host-a] Stored session {session_id}; local transcript deleted.") + + print("[host-b] Starting without host A's local transcript.") + resumed = await run_query( + workspace=workspace, + store=store, + resume=session_id, + prompt=( + "What exact deployment marker did I ask you to remember? " + "Reply only with the marker." + ), + ) + print(f"[host-b] Resumed session {resumed.session_id}.") + print(f"[host-b] Agent response: {resumed.result}") + + if marker not in (resumed.result or ""): + raise RuntimeError( + "The resumed response did not contain the stored marker." + ) + print("[app] External session handoff verified.") + finally: + try: + if session_id: + if get_session_info(session_id, str(workspace)) is not None: + delete_session(session_id, str(workspace)) + await delete_session_via_store(store, session_id, workspace) + print("[app] Deleted sample transcript.") + finally: + await pool.close() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("workspace", nargs="?", type=Path, default=Path.cwd()) + workspace = cast(Path, parser.parse_args().workspace).resolve() + asyncio.run(run(workspace)) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, ValueError) as error: + raise SystemExit(str(error)) from error diff --git a/python/external-session-storage/redis_sample.py b/python/external-session-storage/redis_sample.py new file mode 100644 index 0000000..d18ed9b --- /dev/null +++ b/python/external-session-storage/redis_sample.py @@ -0,0 +1,258 @@ +"""Runnable Redis SessionStore reference sample.""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +import time +import uuid +from pathlib import Path +from typing import cast + +from qoder_agent_sdk import ( + QoderAgentOptions, + ResultMessage, + SDKMirrorErrorMessage, + SessionKey, + SessionListSubkeysKey, + SessionStore, + SessionStoreEntry, + SessionStoreListEntry, + access_token_from_env, + delete_session, + delete_session_via_store, + get_session_info, + query, +) +from redis.asyncio import Redis + +_MAIN_TRANSCRIPT = "main" + + +def _encoded(value: str) -> str: + encoded = base64.urlsafe_b64encode(value.encode()).decode() + return encoded.rstrip("=") + + +class RedisSessionStore(SessionStore): + """Store transcript entries in Redis lists with lightweight indexes.""" + + def __init__( + self, + client: Redis, + prefix: str = "qoder:samples:session-storage", + ) -> None: + self._client = client + self._prefix = prefix.rstrip(":") + + async def append( + self, + key: SessionKey, + entries: list[SessionStoreEntry], + ) -> None: + if not entries: + return + + transaction = self._client.pipeline(transaction=True) + transaction.rpush( + self._entries_key(key), + *(json.dumps(entry, separators=(",", ":")) for entry in entries), + ) + subpath = key.get("subpath") + if subpath is None: + transaction.zadd( + self._sessions_key(key["project_key"]), + {key["session_id"]: int(time.time() * 1000)}, + ) + else: + transaction.sadd(self._subkeys_key(key), subpath) + await transaction.execute() + + async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: + values = cast( + list[str], + await self._client.lrange(self._entries_key(key), 0, -1), + ) + if not values: + return None + return [cast(SessionStoreEntry, json.loads(value)) for value in values] + + async def list_sessions( + self, + project_key: str, + ) -> list[SessionStoreListEntry]: + values = cast( + list[tuple[str, float]], + await self._client.zrange( + self._sessions_key(project_key), + 0, + -1, + desc=True, + withscores=True, + ), + ) + return [ + {"session_id": session_id, "mtime": int(mtime)} + for session_id, mtime in values + ] + + async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]: + values = cast( + set[str], + await self._client.smembers(self._subkeys_key(key)), + ) + return sorted(values) + + async def delete(self, key: SessionKey) -> None: + subpath = key.get("subpath") + if subpath is not None: + transaction = self._client.pipeline(transaction=True) + transaction.delete(self._entries_key(key)) + transaction.srem(self._subkeys_key(key), subpath) + await transaction.execute() + return + + subpaths = cast( + set[str], + await self._client.smembers(self._subkeys_key(key)), + ) + keys = [self._entries_key(key), self._subkeys_key(key)] + keys.extend( + self._entries_key({**key, "subpath": subpath}) for subpath in subpaths + ) + transaction = self._client.pipeline(transaction=True) + transaction.delete(*keys) + transaction.zrem( + self._sessions_key(key["project_key"]), + key["session_id"], + ) + await transaction.execute() + + def _entries_key(self, key: SessionKey) -> str: + subpath = key.get("subpath") + transcript = ( + f"child:{_encoded(subpath)}" if subpath is not None else _MAIN_TRANSCRIPT + ) + return ":".join( + [ + self._prefix, + "entries", + _encoded(key["project_key"]), + _encoded(key["session_id"]), + transcript, + ] + ) + + def _sessions_key(self, project_key: str) -> str: + return ":".join([self._prefix, "sessions", _encoded(project_key)]) + + def _subkeys_key(self, key: SessionListSubkeysKey) -> str: + return ":".join( + [ + self._prefix, + "subkeys", + _encoded(key["project_key"]), + _encoded(key["session_id"]), + ] + ) + + +async def run_query( + *, + workspace: Path, + prompt: str, + store: SessionStore, + resume: str | None = None, +) -> ResultMessage: + options = QoderAgentOptions( + auth=access_token_from_env(), + cwd=workspace, + tools=[], + max_turns=1, + model="auto", + session_store=store, + resume=resume, + ) + result: ResultMessage | None = None + async for message in query(prompt=prompt, options=options): + if isinstance(message, SDKMirrorErrorMessage): + raise RuntimeError( + f"SessionStore could not persist {message.key}: {message.error}" + ) + if isinstance(message, ResultMessage): + if message.subtype != "success": + raise RuntimeError("\n".join(message.errors or [message.subtype])) + result = message + + if result is None: + raise RuntimeError("The query ended without a success result.") + return result + + +async def run(workspace: Path) -> None: + client = Redis.from_url( + os.getenv("QODER_SAMPLE_REDIS_URL", "redis://127.0.0.1:6379"), + decode_responses=True, + ) + store = RedisSessionStore(client) + marker = f"session-storage-{uuid.uuid4()}" + session_id: str | None = None + + try: + print("[host-a] Starting a session with Redis storage.") + first = await run_query( + workspace=workspace, + store=store, + prompt=( + f"Remember this exact deployment marker: {marker}. " + "Reply only that it is stored." + ), + ) + session_id = first.session_id + delete_session(session_id, str(workspace)) + print(f"[host-a] Stored session {session_id}; local transcript deleted.") + + print("[host-b] Starting without host A's local transcript.") + resumed = await run_query( + workspace=workspace, + store=store, + resume=session_id, + prompt=( + "What exact deployment marker did I ask you to remember? " + "Reply only with the marker." + ), + ) + print(f"[host-b] Resumed session {resumed.session_id}.") + print(f"[host-b] Agent response: {resumed.result}") + + if marker not in (resumed.result or ""): + raise RuntimeError( + "The resumed response did not contain the stored marker." + ) + print("[app] External session handoff verified.") + finally: + try: + if session_id: + if get_session_info(session_id, str(workspace)) is not None: + delete_session(session_id, str(workspace)) + await delete_session_via_store(store, session_id, workspace) + print("[app] Deleted sample transcript.") + finally: + await client.aclose() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("workspace", nargs="?", type=Path, default=Path.cwd()) + workspace = cast(Path, parser.parse_args().workspace).resolve() + asyncio.run(run(workspace)) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, ValueError) as error: + raise SystemExit(str(error)) from error diff --git a/python/external-session-storage/requirements.txt b/python/external-session-storage/requirements.txt new file mode 100644 index 0000000..895f096 --- /dev/null +++ b/python/external-session-storage/requirements.txt @@ -0,0 +1,3 @@ +qoder-agent-sdk>=1.0.10,<2 +redis>=5,<9 +asyncpg>=0.29,<1 diff --git a/python/hooks/requirements.txt b/python/hooks/requirements.txt index 621626d..c72e900 100644 --- a/python/hooks/requirements.txt +++ b/python/hooks/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/model-selection/requirements.txt b/python/model-selection/requirements.txt index 621626d..c72e900 100644 --- a/python/model-selection/requirements.txt +++ b/python/model-selection/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/multi-turn-conversation/requirements.txt b/python/multi-turn-conversation/requirements.txt index 621626d..c72e900 100644 --- a/python/multi-turn-conversation/requirements.txt +++ b/python/multi-turn-conversation/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/pyproject.toml b/python/pyproject.toml index 488b730..782cbb3 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -3,12 +3,14 @@ name = "qoder-agent-sdk-samples-python" version = "0.0.0" requires-python = ">=3.10" dependencies = [ - "qoder-agent-sdk>=1.0.9,<2", + "qoder-agent-sdk>=1.0.10,<2", ] [dependency-groups] dev = [ + "asyncpg>=0.29,<1", "mypy>=1.17,<2", + "redis>=5,<9", "ruff>=0.12,<1", ] @@ -25,3 +27,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "PTH"] [tool.mypy] python_version = "3.10" strict = true + +[[tool.mypy.overrides]] +module = ["asyncpg"] +ignore_missing_imports = true diff --git a/python/quickstart/requirements.txt b/python/quickstart/requirements.txt index 621626d..c72e900 100644 --- a/python/quickstart/requirements.txt +++ b/python/quickstart/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/streaming-chat/requirements.txt b/python/streaming-chat/requirements.txt index 621626d..c72e900 100644 --- a/python/streaming-chat/requirements.txt +++ b/python/streaming-chat/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/subagents/requirements.txt b/python/subagents/requirements.txt index 621626d..c72e900 100644 --- a/python/subagents/requirements.txt +++ b/python/subagents/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/tool-permissions/requirements.txt b/python/tool-permissions/requirements.txt index 621626d..c72e900 100644 --- a/python/tool-permissions/requirements.txt +++ b/python/tool-permissions/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/uv.lock b/python/uv.lock index c588c34..de623a2 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -34,6 +34,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -245,7 +313,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -739,22 +807,22 @@ wheels = [ [[package]] name = "qoder-agent-sdk" -version = "1.0.9" +version = "1.0.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/4b/365b30bdce7ba4b15ead11c0c8a452d07e144f6e0ac361ca719b42d785b5/qoder_agent_sdk-1.0.9.tar.gz", hash = "sha256:55e6df3679cef9e78476646309cf3ccbe2763a24b2c2090b6396681bb8f7a861", size = 351954, upload-time = "2026-07-16T13:27:12.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/8c/95c83f3625103c8504532cab111d121d610773c6a6b21a38aff4b81a2ea6/qoder_agent_sdk-1.0.10.tar.gz", hash = "sha256:2c29e077fa8e75758d1bc4e014f07027a338c8f63521933455d12c13d0478c25", size = 406243, upload-time = "2026-07-29T02:38:17.553Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/26/913f95260f645c4775adfc3a496248d51abb219c26a3e37a59f822a41987/qoder_agent_sdk-1.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1239d42e2c82cb4d67f4abc451e7baae696e8b803a1d42a7eb6ee9d2811493ee", size = 37734017, upload-time = "2026-07-16T13:26:38.023Z" }, - { url = "https://files.pythonhosted.org/packages/43/7f/84e5ee41d10e7ad3b313bbe9ddf8585e1ec149c2e1c21647be8a2cd080d6/qoder_agent_sdk-1.0.9-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:360f25389bd795146bd6a7bcae4abac7ada2ffe115448e1b6e8b95d65005d4b3", size = 41727133, upload-time = "2026-07-16T13:26:42.917Z" }, - { url = "https://files.pythonhosted.org/packages/24/b0/16c0e65e62092402d538e6a3154c223294ef9f899e6d997c9cc263e76418/qoder_agent_sdk-1.0.9-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:888a46133abe1524823c18f1874cd84aad36c3e3ba708884c8d91498efd34c43", size = 49864726, upload-time = "2026-07-16T13:26:47.69Z" }, - { url = "https://files.pythonhosted.org/packages/62/14/1e8a57896c38f147f0c98f58e58d9994ef1650c3dc5fd03f139a81395c89/qoder_agent_sdk-1.0.9-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:c127d606046263a6acc545d8c1271d9885708e6fab5b5428bd893567af12b296", size = 50064858, upload-time = "2026-07-16T13:26:53.181Z" }, - { url = "https://files.pythonhosted.org/packages/8d/84/e6059d2e0c18dec8f615655b8fa4b027c1d3777d3f4d60c44af2ba3cb6e0/qoder_agent_sdk-1.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6319a2dd209ee56b177d60a8fb0e1fd536785030096e49f51b17f1f7c789e2d", size = 48560014, upload-time = "2026-07-16T13:26:57.85Z" }, - { url = "https://files.pythonhosted.org/packages/2b/73/eb60a43c2dd8c5cd59bc1f2ec63a72b94c72d26034a7bfa357df6b2eef01/qoder_agent_sdk-1.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f3cbff5cc0687a0768cd554477b47be381b4490258ce8d903eb769e633c01a3", size = 49196341, upload-time = "2026-07-16T13:27:03.659Z" }, - { url = "https://files.pythonhosted.org/packages/33/9d/2a0c2cbd2a357be558e7af3b57f4a9bc48dee3533b6ad14f6c383c754ef4/qoder_agent_sdk-1.0.9-py3-none-win_amd64.whl", hash = "sha256:e4c85e882022e73f9327dd892b0c42b8ffc11ce5d55a3c06a192b07eafd31dd6", size = 59921947, upload-time = "2026-07-16T13:27:09.625Z" }, + { url = "https://files.pythonhosted.org/packages/f2/18/fff90bdcc548f61f091ae03a9d788ca739c62d9fafdf1cbe46771bf30590/qoder_agent_sdk-1.0.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8d18e2593a4dad792d4af60f265b37e37f88dc83c7ee8dd7dc3155c6c5387a51", size = 38021797, upload-time = "2026-07-29T02:37:43.847Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/1a1910527da7d387661ba1eb95afb995154df8322df4fd6f287b814e07e2/qoder_agent_sdk-1.0.10-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cde668ab49ef7234d6558afce7c0aa3798f8c1efbe6ae062378ca8945c3061a1", size = 42022149, upload-time = "2026-07-29T02:37:49.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/9b8c6c8848a56de15acc2785b75022832e264c971785f63d96a7269fa52e/qoder_agent_sdk-1.0.10-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:08ecf4dd1ce9be880a2789e2804cf588dcea250ce6684bc8196c968fd13622a2", size = 50137536, upload-time = "2026-07-29T02:37:54.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fa/c5a94bca2fceb7e2ce122cf12807126e020f54062c4c180c70871fdc77cc/qoder_agent_sdk-1.0.10-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:c00086750fac739318698f461e1ad88e417af4c39f37741d0f717473da0b164f", size = 50351700, upload-time = "2026-07-29T02:37:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/c9c751a9a0b1a6deeba0650f127df1846ed2c2ad58581e122a250d26fb2a/qoder_agent_sdk-1.0.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d4f51aa7ae8f37bfe6259c3775b617fdf588afc67db9b196bc667da701de329", size = 48847837, upload-time = "2026-07-29T02:38:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e3/011493b6cf7d38323b13317731d4e5f274db8828a696fa1108dc91b4c673/qoder_agent_sdk-1.0.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c19822bfc85d94c9dcb17187dd6353c89191a4db1852ec4a99a65c6ef398d76b", size = 49478352, upload-time = "2026-07-29T02:38:09.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cf/ebae3ad182b55f8d6c1aa4abeff468f13cf7ea0f725a8bd25dccb75899e1/qoder_agent_sdk-1.0.10-py3-none-win_amd64.whl", hash = "sha256:ba57c2bd2e76ce46b3395a729793b5306a67d50559558e22333835fa11b95bab", size = 60205845, upload-time = "2026-07-29T02:38:14.927Z" }, ] [[package]] @@ -767,19 +835,35 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "asyncpg" }, { name = "mypy" }, + { name = "redis" }, { name = "ruff" }, ] [package.metadata] -requires-dist = [{ name = "qoder-agent-sdk", specifier = ">=1.0.9,<2" }] +requires-dist = [{ name = "qoder-agent-sdk", specifier = ">=1.0.10,<2" }] [package.metadata.requires-dev] dev = [ + { name = "asyncpg", specifier = ">=0.29,<1" }, { name = "mypy", specifier = ">=1.17,<2" }, + { name = "redis", specifier = ">=5,<9" }, { name = "ruff", specifier = ">=0.12,<1" }, ] +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + [[package]] name = "referencing" version = "0.37.0" diff --git a/typescript/README.md b/typescript/README.md index 782be03..0788e3f 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -7,5 +7,5 @@ once, run: npm install ``` -You can then run a sample from its directory with `npm start`. Complete the -repository-level setup before running a sample. +Run a sample with the script documented in its README; most use `npm start`. +Complete the repository-level setup before running a sample. diff --git a/typescript/ask-user-question/package.json b/typescript/ask-user-question/package.json index 2b4e93a..0e64a08 100644 --- a/typescript/ask-user-question/package.json +++ b/typescript/ask-user-question/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/code-review/package.json b/typescript/code-review/package.json index ae523b5..fa2acf0 100644 --- a/typescript/code-review/package.json +++ b/typescript/code-review/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/custom-tools/package.json b/typescript/custom-tools/package.json index e7750a9..b6c3ac6 100644 --- a/typescript/custom-tools/package.json +++ b/typescript/custom-tools/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15", + "@qoder-ai/qoder-agent-sdk": "^1.0.16", "zod": "^4.0.0" }, "devDependencies": { diff --git a/typescript/external-session-storage/README.md b/typescript/external-session-storage/README.md new file mode 100644 index 0000000..1860dbe --- /dev/null +++ b/typescript/external-session-storage/README.md @@ -0,0 +1,142 @@ +# External session storage with Redis or PostgreSQL + +Copy and run one self-contained sample for the storage backend used by the +application: + +| Sample | Backend | Run | +| --- | --- | --- | +| [`redis-sample.ts`](redis-sample.ts) | Redis lists and indexes | `npm run redis -- /path/to/repository` | +| [`postgres-sample.ts`](postgres-sample.ts) | PostgreSQL JSONB rows | `npm run postgres -- /path/to/repository` | + +Each file contains both a complete `SessionStore` adapter and the same +end-to-end handoff: + +```text +host A shared store host B +local transcript -- append --> Redis/PostgreSQL <-- load -- no transcript + | | + creates session resumes session + | + local transcript is deleted before host B starts +``` + +The application asks Host A to remember a unique marker, deletes its local +transcript, resumes the session through the external store, and verifies that +Host B returns the marker. + +Complete the [repository setup](../../README.md#setup), then: + +```bash +npm install +``` + +Both samples use the same absolute workspace path for Host A and Host B. +Session storage uses the canonical workspace path to identify the project. + +## Redis sample + +Start Redis: + +```bash +docker run --rm --name qoder-session-redis \ + -p 6379:6379 \ + redis:7-alpine +``` + +In another terminal: + +```bash +npm run redis -- /path/to/repository +``` + +The default connection is `redis://127.0.0.1:6379`. Override it when needed: + +```bash +export QODER_SAMPLE_REDIS_URL="redis://127.0.0.1:6379" +``` + +`redis-sample.ts` is independently copyable. It needs +`@qoder-ai/qoder-agent-sdk` and `ioredis`, and stores: + +```text +entries:::main Redis list, append order +entries:::child:* Redis list, append order +sessions: sorted set, modification time +subkeys:: set of child transcript paths +``` + +Project keys, session IDs, and child paths are base64url-encoded before they +become Redis key segments. + +## PostgreSQL sample + +Start PostgreSQL: + +```bash +docker run --rm --name qoder-session-postgres \ + -e POSTGRES_USER=qoder \ + -e POSTGRES_PASSWORD=qoder \ + -e POSTGRES_DB=qoder_session_store \ + -p 5432:5432 \ + postgres:17-alpine +``` + +In another terminal: + +```bash +npm run postgres -- /path/to/repository +``` + +The default connection uses the development credentials from the Docker +command. Override it when needed: + +```bash +export QODER_SAMPLE_POSTGRES_URL="postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store" +``` + +`postgres-sample.ts` is independently copyable. It needs +`@qoder-ai/qoder-agent-sdk`, `pg`, and `@types/pg`. The adapter creates +`qoder_session_entries`: each transcript entry is one JSONB row, the increasing +`seq` column preserves replay order, and the +`(project_key, session_id, subpath, seq)` index supports loading main and child +transcripts. + +## SessionStore behavior + +The SDK-facing application code needs only a store instance: + +```ts +const stream = query({ + prompt, + options: { + auth: accessTokenFromEnv(), + cwd: workspace, + sessionStore: store, + resume: sessionId, + }, +}); +``` + +`append()` receives newly committed entries. `load()` returns the complete +history for one key in append order. `listSessions()`, `listSubkeys()`, and +`delete()` enable latest-session resume, child-agent restoration, and deletion. + +Each sample removes its local and external transcript data during cleanup. + +## Production considerations + +These adapters are readable reference implementations, not production-ready +database packages. Before deployment: + +- isolate tenants in the Redis prefix or PostgreSQL schema +- define retention, backup, encryption, and access control +- serialize concurrent writers for the same session +- account for a failed `append()` being retried with the same entries +- monitor `system/mirror_error`; the conversation can succeed even when an + external write fails +- configure Redis eviction and TTL behavior for transcript retention +- size the PostgreSQL pool and schedule deletion of expired rows +- add backend-specific load, concurrency, failover, and cleanup tests + +SessionStore contains transcript history only. It does not store credentials, +application settings, file checkpoints, or external tool side effects. diff --git a/typescript/external-session-storage/package.json b/typescript/external-session-storage/package.json new file mode 100644 index 0000000..75f5d23 --- /dev/null +++ b/typescript/external-session-storage/package.json @@ -0,0 +1,21 @@ +{ + "name": "@qoder-samples/typescript-external-session-storage", + "private": true, + "type": "module", + "scripts": { + "redis": "tsx redis-sample.ts", + "postgres": "tsx postgres-sample.ts", + "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node redis-sample.ts postgres-sample.ts" + }, + "dependencies": { + "@qoder-ai/qoder-agent-sdk": "^1.0.16", + "ioredis": "^5.7.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/pg": "^8.15.5", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/typescript/external-session-storage/postgres-sample.ts b/typescript/external-session-storage/postgres-sample.ts new file mode 100644 index 0000000..e262acf --- /dev/null +++ b/typescript/external-session-storage/postgres-sample.ts @@ -0,0 +1,271 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + accessTokenFromEnv, + deleteSession, + getSessionInfo, + query, + type SDKResultSuccess, + type SessionKey, + type SessionStore, + type SessionStoreEntry, +} from "@qoder-ai/qoder-agent-sdk"; +import pg, { type Pool } from "pg"; + +const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * PostgreSQL reference adapter with one row per transcript entry. + * + * The increasing seq column preserves the order returned by load(). + */ +export class PostgresSessionStore implements SessionStore { + constructor( + private readonly pool: Pool, + private readonly tableName = "qoder_session_entries", + ) { + if (!SQL_IDENTIFIER.test(tableName)) { + throw new Error( + `Invalid table name ${JSON.stringify(tableName)}. Use letters, digits, and underscores.`, + ); + } + } + + async ensureSchema(): Promise { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS ${this.tableName} ( + seq BIGSERIAL PRIMARY KEY, + project_key TEXT NOT NULL, + session_id TEXT NOT NULL, + subpath TEXT NOT NULL DEFAULT '', + entry JSONB NOT NULL, + modified_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ) + `); + await this.pool.query(` + CREATE INDEX IF NOT EXISTS ${this.tableName}_lookup_idx + ON ${this.tableName} (project_key, session_id, subpath, seq) + `); + } + + async append( + key: SessionKey, + entries: SessionStoreEntry[], + ): Promise { + if (entries.length === 0) return; + + const values: unknown[] = [ + key.projectKey, + key.sessionId, + key.subpath ?? "", + ]; + const rows = entries.map((entry, index) => { + values.push(JSON.stringify(entry)); + return `($1, $2, $3, $${index + 4}::jsonb)`; + }); + await this.pool.query( + ` + INSERT INTO ${this.tableName} + (project_key, session_id, subpath, entry) + VALUES ${rows.join(", ")} + `, + values, + ); + } + + async load(key: SessionKey): Promise { + const result = await this.pool.query<{ entry: SessionStoreEntry }>( + ` + SELECT entry + FROM ${this.tableName} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + ORDER BY seq + `, + [key.projectKey, key.sessionId, key.subpath ?? ""], + ); + return result.rows.length === 0 + ? null + : result.rows.map(({ entry }) => entry); + } + + async listSessions( + projectKey: string, + ): Promise> { + const result = await this.pool.query<{ + session_id: string; + modified_at: Date; + }>( + ` + SELECT session_id, MAX(modified_at) AS modified_at + FROM ${this.tableName} + WHERE project_key = $1 AND subpath = '' + GROUP BY session_id + ORDER BY modified_at DESC + `, + [projectKey], + ); + return result.rows.map(({ session_id, modified_at }) => ({ + sessionId: session_id, + mtime: modified_at.getTime(), + })); + } + + async listSubkeys( + key: Omit, + ): Promise { + const result = await this.pool.query<{ subpath: string }>( + ` + SELECT DISTINCT subpath + FROM ${this.tableName} + WHERE project_key = $1 + AND session_id = $2 + AND subpath <> '' + ORDER BY subpath + `, + [key.projectKey, key.sessionId], + ); + return result.rows.map(({ subpath }) => subpath); + } + + async delete(key: SessionKey): Promise { + if (key.subpath === undefined) { + await this.pool.query( + ` + DELETE FROM ${this.tableName} + WHERE project_key = $1 AND session_id = $2 + `, + [key.projectKey, key.sessionId], + ); + return; + } + await this.pool.query( + ` + DELETE FROM ${this.tableName} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + `, + [key.projectKey, key.sessionId, key.subpath], + ); + } +} + +async function runQuery(options: { + workspace: string; + prompt: string; + store: SessionStore; + resume?: string; +}): Promise { + const stream = query({ + prompt: options.prompt, + options: { + auth: accessTokenFromEnv(), + cwd: options.workspace, + tools: [], + maxTurns: 1, + model: "auto", + sessionStore: options.store, + ...(options.resume ? { resume: options.resume } : {}), + }, + }); + + let result: SDKResultSuccess | undefined; + try { + for await (const message of stream) { + if (message.type === "system" && message.subtype === "mirror_error") { + throw new Error( + `SessionStore could not persist ${JSON.stringify(message.key)}: ${message.error}`, + ); + } + if (message.type === "result") { + if (message.subtype !== "success") { + throw new Error(message.errors?.join("\n") || message.subtype); + } + result = message; + } + } + } finally { + await stream.close(); + } + + if (!result) throw new Error("The query ended without a success result."); + return result; +} + +export async function run(workspace: string): Promise { + const pool = new pg.Pool({ + connectionString: + process.env["QODER_SAMPLE_POSTGRES_URL"] ?? + "postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store", + }); + const store = new PostgresSessionStore(pool); + const marker = `session-storage-${randomUUID()}`; + let sessionId: string | undefined; + + try { + await store.ensureSchema(); + console.log("[host-a] Starting a session with PostgreSQL storage."); + const first = await runQuery({ + workspace, + store, + prompt: `Remember this exact deployment marker: ${marker}. Reply only that it is stored.`, + }); + sessionId = first.session_id; + await deleteSession(sessionId, { dir: workspace }); + console.log( + `[host-a] Stored session ${sessionId}; local transcript deleted.`, + ); + + console.log("[host-b] Starting without host A's local transcript."); + const resumed = await runQuery({ + workspace, + store, + resume: sessionId, + prompt: + "What exact deployment marker did I ask you to remember? Reply only with the marker.", + }); + console.log(`[host-b] Resumed session ${resumed.session_id}.`); + console.log(`[host-b] Agent response: ${resumed.result}`); + + if (!resumed.result.includes(marker)) { + throw new Error("The resumed response did not contain the stored marker."); + } + console.log("[app] External session handoff verified."); + } finally { + try { + if (sessionId) { + const localSession = await getSessionInfo(sessionId, { + dir: workspace, + }); + if (localSession) { + await deleteSession(sessionId, { dir: workspace }); + } + await deleteSession(sessionId, { + dir: workspace, + sessionStore: store, + }); + console.log("[app] Deleted sample transcript."); + } + } finally { + await pool.end(); + } + } +} + +async function main(): Promise { + await run(resolve(process.argv[2] ?? process.cwd())); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/typescript/external-session-storage/redis-sample.ts b/typescript/external-session-storage/redis-sample.ts new file mode 100644 index 0000000..70ea5a7 --- /dev/null +++ b/typescript/external-session-storage/redis-sample.ts @@ -0,0 +1,265 @@ +import { Buffer } from "node:buffer"; +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + accessTokenFromEnv, + deleteSession, + getSessionInfo, + query, + type SDKResultSuccess, + type SessionKey, + type SessionStore, + type SessionStoreEntry, +} from "@qoder-ai/qoder-agent-sdk"; +import { Redis } from "ioredis"; + +const MAIN_TRANSCRIPT = "main"; + +function encoded(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +async function execute( + transaction: ReturnType, +): Promise { + const results = await transaction.exec(); + if (results === null) { + throw new Error("The Redis transaction was discarded."); + } + for (const [error] of results) { + if (error) throw error; + } +} + +/** + * Redis reference adapter. + * + * Lists hold transcript entries in append order. A sorted set indexes main + * sessions by modification time, and a set indexes child transcript paths. + */ +export class RedisSessionStore implements SessionStore { + private readonly prefix: string; + + constructor( + private readonly client: Redis, + prefix = "qoder:samples:session-storage", + ) { + this.prefix = prefix.replace(/:+$/, ""); + } + + async append( + key: SessionKey, + entries: SessionStoreEntry[], + ): Promise { + if (entries.length === 0) return; + + const transaction = this.client.multi(); + transaction.rpush( + this.entriesKey(key), + ...entries.map((entry) => JSON.stringify(entry)), + ); + if (key.subpath === undefined) { + transaction.zadd( + this.sessionsKey(key.projectKey), + Date.now(), + key.sessionId, + ); + } else { + transaction.sadd(this.subkeysKey(key), key.subpath); + } + await execute(transaction); + } + + async load(key: SessionKey): Promise { + const values = await this.client.lrange(this.entriesKey(key), 0, -1); + if (values.length === 0) return null; + return values.map((value) => JSON.parse(value) as SessionStoreEntry); + } + + async listSessions( + projectKey: string, + ): Promise> { + const values = await this.client.zrevrange( + this.sessionsKey(projectKey), + 0, + -1, + "WITHSCORES", + ); + const sessions: Array<{ sessionId: string; mtime: number }> = []; + for (let index = 0; index < values.length; index += 2) { + sessions.push({ + sessionId: values[index]!, + mtime: Number(values[index + 1]), + }); + } + return sessions; + } + + async listSubkeys( + key: Omit, + ): Promise { + return (await this.client.smembers(this.subkeysKey(key))).sort(); + } + + async delete(key: SessionKey): Promise { + if (key.subpath !== undefined) { + const transaction = this.client + .multi() + .del(this.entriesKey(key)) + .srem(this.subkeysKey(key), key.subpath); + await execute(transaction); + return; + } + + const subpaths = await this.client.smembers(this.subkeysKey(key)); + const childKeys = subpaths.map((subpath) => + this.entriesKey({ ...key, subpath }), + ); + const transaction = this.client + .multi() + .del(this.entriesKey(key), this.subkeysKey(key), ...childKeys) + .zrem(this.sessionsKey(key.projectKey), key.sessionId); + await execute(transaction); + } + + private entriesKey(key: SessionKey): string { + const transcript = key.subpath !== undefined + ? `child:${encoded(key.subpath)}` + : MAIN_TRANSCRIPT; + return [ + this.prefix, + "entries", + encoded(key.projectKey), + encoded(key.sessionId), + transcript, + ].join(":"); + } + + private sessionsKey(projectKey: string): string { + return [this.prefix, "sessions", encoded(projectKey)].join(":"); + } + + private subkeysKey(key: Omit): string { + return [ + this.prefix, + "subkeys", + encoded(key.projectKey), + encoded(key.sessionId), + ].join(":"); + } +} + +async function runQuery(options: { + workspace: string; + prompt: string; + store: SessionStore; + resume?: string; +}): Promise { + const stream = query({ + prompt: options.prompt, + options: { + auth: accessTokenFromEnv(), + cwd: options.workspace, + tools: [], + maxTurns: 1, + model: "auto", + sessionStore: options.store, + ...(options.resume ? { resume: options.resume } : {}), + }, + }); + + let result: SDKResultSuccess | undefined; + try { + for await (const message of stream) { + if (message.type === "system" && message.subtype === "mirror_error") { + throw new Error( + `SessionStore could not persist ${JSON.stringify(message.key)}: ${message.error}`, + ); + } + if (message.type === "result") { + if (message.subtype !== "success") { + throw new Error(message.errors?.join("\n") || message.subtype); + } + result = message; + } + } + } finally { + await stream.close(); + } + + if (!result) throw new Error("The query ended without a success result."); + return result; +} + +export async function run(workspace: string): Promise { + const client = new Redis( + process.env["QODER_SAMPLE_REDIS_URL"] ?? "redis://127.0.0.1:6379", + ); + const store = new RedisSessionStore(client); + const marker = `session-storage-${randomUUID()}`; + let sessionId: string | undefined; + + try { + console.log("[host-a] Starting a session with Redis storage."); + const first = await runQuery({ + workspace, + store, + prompt: `Remember this exact deployment marker: ${marker}. Reply only that it is stored.`, + }); + sessionId = first.session_id; + await deleteSession(sessionId, { dir: workspace }); + console.log( + `[host-a] Stored session ${sessionId}; local transcript deleted.`, + ); + + console.log("[host-b] Starting without host A's local transcript."); + const resumed = await runQuery({ + workspace, + store, + resume: sessionId, + prompt: + "What exact deployment marker did I ask you to remember? Reply only with the marker.", + }); + console.log(`[host-b] Resumed session ${resumed.session_id}.`); + console.log(`[host-b] Agent response: ${resumed.result}`); + + if (!resumed.result.includes(marker)) { + throw new Error("The resumed response did not contain the stored marker."); + } + console.log("[app] External session handoff verified."); + } finally { + try { + if (sessionId) { + const localSession = await getSessionInfo(sessionId, { + dir: workspace, + }); + if (localSession) { + await deleteSession(sessionId, { dir: workspace }); + } + await deleteSession(sessionId, { + dir: workspace, + sessionStore: store, + }); + console.log("[app] Deleted sample transcript."); + } + } finally { + await client.quit(); + } + } +} + +async function main(): Promise { + await run(resolve(process.argv[2] ?? process.cwd())); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/typescript/hooks/package.json b/typescript/hooks/package.json index 105c401..27170c1 100644 --- a/typescript/hooks/package.json +++ b/typescript/hooks/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/model-selection/package.json b/typescript/model-selection/package.json index 05a8422..577a607 100644 --- a/typescript/model-selection/package.json +++ b/typescript/model-selection/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/multi-turn-conversation/package.json b/typescript/multi-turn-conversation/package.json index 90f6a0f..74fe0c8 100644 --- a/typescript/multi-turn-conversation/package.json +++ b/typescript/multi-turn-conversation/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 0ff4898..02b2c16 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -15,13 +15,14 @@ "model-selection", "hooks", "custom-tools", - "subagents" + "subagents", + "external-session-storage" ] }, "ask-user-question": { "name": "@qoder-samples/typescript-ask-user-question", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -32,7 +33,7 @@ "code-review": { "name": "@qoder-samples/typescript-code-review", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -43,7 +44,7 @@ "custom-tools": { "name": "@qoder-samples/typescript-custom-tools", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15", + "@qoder-ai/qoder-agent-sdk": "^1.0.16", "zod": "^4.0.0" }, "devDependencies": { @@ -52,10 +53,24 @@ "typescript": "^5.9.0" } }, + "external-session-storage": { + "name": "@qoder-samples/typescript-external-session-storage", + "dependencies": { + "@qoder-ai/qoder-agent-sdk": "^1.0.16", + "ioredis": "^5.7.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/pg": "^8.15.5", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } + }, "hooks": { "name": "@qoder-samples/typescript-hooks", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -66,7 +81,7 @@ "model-selection": { "name": "@qoder-samples/typescript-model-selection", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -77,7 +92,7 @@ "multi-turn-conversation": { "name": "@qoder-samples/typescript-multi-turn-conversation", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -528,24 +543,30 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -580,9 +601,9 @@ } }, "node_modules/@qoder-ai/qoder-agent-sdk": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.15.tgz", - "integrity": "sha512-6WyA0wEcM3ThwVEmDJsJcuc7jmCIO02SrbYIOSz6MvDQuWY/on+3usLX7WIpeIKg7pfQQMqUeSrQLXo1DkBQ+w==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.16.tgz", + "integrity": "sha512-hcKmA6jjt1TJizoZ1ezhyYfO5sJ3LgkqHjz0T5TtmhfYVBv8rgiq1VdijEF4PYT8v9347/tXcRzHqKfFmMn7Xw==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -607,6 +628,10 @@ "resolved": "custom-tools", "link": true }, + "node_modules/@qoder-samples/typescript-external-session-storage": { + "resolved": "external-session-storage", + "link": true + }, "node_modules/@qoder-samples/typescript-hooks": { "resolved": "hooks", "link": true @@ -645,6 +670,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -766,6 +803,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -854,6 +900,15 @@ } } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1044,9 +1099,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -1221,9 +1276,9 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1271,10 +1326,32 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "engines": { "node": ">= 12" @@ -1302,9 +1379,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1332,12 +1409,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -1462,6 +1543,95 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -1471,6 +1641,45 @@ "node": ">=16.20.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1528,6 +1737,27 @@ "node": ">= 0.10" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1703,6 +1933,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1831,6 +2076,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -1852,7 +2106,7 @@ "quickstart": { "name": "@qoder-samples/typescript-quickstart", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -1863,7 +2117,7 @@ "streaming-chat": { "name": "@qoder-samples/typescript-streaming-chat", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -1874,7 +2128,7 @@ "subagents": { "name": "@qoder-samples/typescript-subagents", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -1885,7 +2139,7 @@ "tool-permissions": { "name": "@qoder-samples/typescript-tool-permissions", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/package.json b/typescript/package.json index cd66fc9..6f53655 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -11,6 +11,7 @@ "model-selection", "hooks", "custom-tools", - "subagents" + "subagents", + "external-session-storage" ] } diff --git a/typescript/quickstart/package.json b/typescript/quickstart/package.json index 451c61f..7a1d0d7 100644 --- a/typescript/quickstart/package.json +++ b/typescript/quickstart/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/streaming-chat/package.json b/typescript/streaming-chat/package.json index a3e96ec..4b97243 100644 --- a/typescript/streaming-chat/package.json +++ b/typescript/streaming-chat/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/subagents/package.json b/typescript/subagents/package.json index 78311cb..82e16f2 100644 --- a/typescript/subagents/package.json +++ b/typescript/subagents/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/tool-permissions/package.json b/typescript/tool-permissions/package.json index 8909699..56bd2ce 100644 --- a/typescript/tool-permissions/package.json +++ b/typescript/tool-permissions/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0",