Repository navigation
feat(storage): add PostgreSQL storage backend - #26
delete-cloud wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR introduces a PostgreSQL-backed persistence option and expands the runtime/CLI surfaces to support richer interactive operation (streaming output, shell mode, incremental context building), alongside related plumbing (hook specs/validation, tape/windowing, tracing) and docs.
Changes:
- Added PostgreSQL storage primitives (session store, tape store, advisory session lock) built around
asyncpg. - Refactored REPL UX: prompt-toolkit based input handling (multiline, paste folding, shell mode), unified terminal output helpers, status footer updates.
- Upgraded
agentkitruntime/tape/tool plumbing: incremental context build path viaTapeView, typed anchors, tool output validation, hook return-type validation, and tracing.
Reviewed changes
Copilot reviewed 59 out of 190 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| coding-agent/src/coding_agent/cli/terminal_output.py | Adds prompt-toolkit output wiring helpers for consistent REPL printing. |
| coding-agent/src/coding_agent/cli/repl.py | Reworks REPL loop (streaming renderer/consumer, shell mode, status footer, prompt output). |
| coding-agent/src/coding_agent/cli/input_handler.py | Adds prompt-toolkit sessions, keybindings, paste folding, toolbar, and shell/chat mode switching. |
| coding-agent/src/coding_agent/cli/commands.py | Refactors slash commands output to prompt-toolkit HTML and adds new commands. |
| coding-agent/src/coding_agent/cli/bash_executor.py | Adds an async shell executor for REPL shell mode. |
| coding-agent/src/coding_agent/cli/init.py | Exports additional CLI helpers. |
| coding-agent/src/coding_agent/approval/store.py | Cleans up pending approval removal semantics and formatting. |
| coding-agent/src/coding_agent/agents/subagent.py | Removes legacy SubAgent stub types/module. |
| coding-agent/src/coding_agent/agent.toml | Adds [kb] configuration defaults. |
| coding-agent/src/coding_agent/adapter.py | Extends adapter to emit thinking/tool-result/usage events, approval bridge, and metrics. |
| coding-agent/src/coding_agent/main.py | Large wiring changes: plugin factories, tool filtering, KB CLI, child pipeline builder. |
| coding-agent/src/agentkit/tracing.py | Adds optional structlog-based tracing configuration. |
| coding-agent/src/agentkit/tools/schema.py | Adds output_model to tool schema for typed outputs. |
| coding-agent/src/agentkit/tools/registry.py | Adds tool retention/filtering and output validation; filters kwargs to tool signature. |
| coding-agent/src/agentkit/tools/decorator.py | Extends @tool to support output_model and tighter parameter schema. |
| coding-agent/src/agentkit/tape/view.py | Introduces TapeView abstraction for windowed tape consumption. |
| coding-agent/src/agentkit/tape/tape.py | Adds windowing/persistence bookkeeping and append-only JSONL save semantics. |
| coding-agent/src/agentkit/tape/store.py | Changes fork store commit to persist only deltas. |
| coding-agent/src/agentkit/tape/models.py | Adds typed Anchor model and more robust (legacy-compatible) de/serialization. |
| coding-agent/src/agentkit/tape/anchor.py | Re-exports anchor types for convenience/import hygiene. |
| coding-agent/src/agentkit/tape/init.py | Exports Anchor/TapeView from tape package. |
| coding-agent/src/agentkit/storage/pg.py | Adds PostgreSQL session/tape storage and advisory-lock helper. |
| coding-agent/src/agentkit/storage/init.py | Minor export formatting. |
| coding-agent/src/agentkit/runtime/pipeline.py | Adds incremental context build, new event types, structured tool result scoping, and tracing hooks. |
| coding-agent/src/agentkit/runtime/hookspecs.py | Expands hook specs and adds declared return-type validation metadata. |
| coding-agent/src/agentkit/runtime/hook_runtime.py | Validates hook return types against HookSpecs. |
| coding-agent/src/agentkit/runtime/init.py | Adds Lifecycle protocol and reorganizes exports. |
| coding-agent/src/agentkit/providers/models.py | Adds thinking/tool-result/usage stream events. |
| coding-agent/src/agentkit/providers/init.py | Exports new event types. |
| coding-agent/src/agentkit/plugin/registry.py | Warns on unknown hooks when HookSpecs are provided. |
| coding-agent/src/agentkit/errors.py | Adds HookTypeError. |
| coding-agent/src/agentkit/directive/types.py | Adds metadata to AskUser directive. |
| coding-agent/src/agentkit/directive/executor.py | Adds optional tracing around directive execution. |
| coding-agent/src/agentkit/context/builder.py | Adds TapeView support, incremental patching helpers, and anchor filtering rules. |
| coding-agent/src/agentkit/init.py | Exports new runtime/provider symbols. |
| coding-agent/pyproject.toml | Adds optional dependency group redis. |
| coding-agent/docs/** | Adds multiple specs/plans and large TUI checklist documentation. |
| coding-agent/README.md | Updates repo description and usage docs. |
| coding-agent/.gitignore | Ignores .sisyphus/, data/, and other local caches. |
| coding-agent/KIMI-TUI-ARCHITECTURE.md | Adds TUI architecture analysis doc. |
| coding-agent/CLAUDE-CODE-TUI-ARCHITECTURE.md | Adds TUI architecture analysis doc. |
Comments suppressed due to low confidence (1)
coding-agent/src/agentkit/tape/store.py:1
- This changes
ForkTapeStore.commit()semantics from saving the full tape snapshot to saving only a delta slice, without changing theTapeStore.save()API. If existingTapeStoreimplementations interpretsave()as overwrite/replace (common for file/kv stores), this will silently drop historical entries. Either (mandatory) update theTapeStoreprotocol and all implementations to makesave()append/delta-aware (e.g.,append(tape_id, entries)), or keep committing the full snapshot here.
"""ForkTapeStore — transactional tape operations."""
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import importlib | ||
| import json | ||
| from collections.abc import Awaitable, Callable | ||
| from typing import Final, Protocol, cast |
There was a problem hiding this comment.
The PR title/description focuses on adding a PostgreSQL storage backend, but this PR also includes sweeping changes to REPL input/output, hook/runtime APIs, tape/anchor models, incremental context building, KB CLI/config, and extensive docs. This makes review and rollback risk substantially higher. Consider splitting into smaller PRs (e.g., storage backend; runtime/hook changes; CLI/TUI changes; docs) or updating the PR description to accurately enumerate the broader scope.
| self._active.pop(fork.tape_id, None) | ||
| await self._backing.save(fork.tape_id, fork.to_list()) | ||
| delta = fork.to_list()[self._base_lengths[fork.tape_id]:] | ||
| await self._backing.save(fork.tape_id, delta) |
There was a problem hiding this comment.
_base_lengths is never cleaned up on commit or rollback. Over time, repeated forks will leak memory (and, in a long-running service, can grow unbounded). Also, indexing self._base_lengths[fork.tape_id] will raise KeyError if internal state gets out of sync. Recommended: pop the base length in both commit() and rollback() (and treat missing entries as a ValueError with a clear message).
| @self.bindings.add(Keys.Any, filter=Condition(lambda: not self._shell_mode)) | ||
| def _(event): | ||
| buf = event.app.current_buffer | ||
| self._move_cursor_out_of_placeholder(buf) | ||
| data = event.data | ||
| if data: | ||
| buf.insert_text(data) | ||
|
|
There was a problem hiding this comment.
Binding Keys.Any overrides prompt-toolkit's default key handling for many keys, including navigation/history keys (e.g., Up/Down, Home/End) and editing keys that don't have explicit bindings elsewhere. Since this handler doesn't replicate default behaviors, it can break standard line editing and history interaction. Fix: remove this Keys.Any handler and rely on prompt-toolkit’s default insertion, or restrict it to printable characters only (e.g., a filter based on event.data being a single printable char and no modifiers), while leaving non-text keys to prompt-toolkit.
| @self.bindings.add(Keys.Any, filter=Condition(lambda: not self._shell_mode)) | |
| def _(event): | |
| buf = event.app.current_buffer | |
| self._move_cursor_out_of_placeholder(buf) | |
| data = event.data | |
| if data: | |
| buf.insert_text(data) |
|
|
||
| for _sequence in ("\x1b[27;2;13~", "\x1b[13;2u"): | ||
| ANSI_SEQUENCES[_sequence] = _SHIFT_ENTER_SEQUENCE | ||
|
|
There was a problem hiding this comment.
This mutates prompt_toolkit.input.ansi_escape_sequences.ANSI_SEQUENCES at import time, which is a global side effect affecting any prompt-toolkit usage in the same process (including tests or other CLIs). Prefer registering these sequences in a more localized way (e.g., during InputHandler.__init__() with an idempotent guard, or via prompt-toolkit configuration/hooks if available) so importing the module doesn’t change global terminal parsing.
| for _sequence in ("\x1b[27;2;13~", "\x1b[13;2u"): | |
| ANSI_SEQUENCES[_sequence] = _SHIFT_ENTER_SEQUENCE | |
| _SHIFT_ENTER_ESCAPE_SEQUENCES = ("\x1b[27;2;13~", "\x1b[13;2u") | |
| _SHIFT_ENTER_SEQUENCES_REGISTERED = False | |
| def _register_shift_enter_sequences() -> None: | |
| global _SHIFT_ENTER_SEQUENCES_REGISTERED | |
| if _SHIFT_ENTER_SEQUENCES_REGISTERED: | |
| return | |
| for _sequence in _SHIFT_ENTER_ESCAPE_SEQUENCES: | |
| ANSI_SEQUENCES[_sequence] = _SHIFT_ENTER_SEQUENCE | |
| _SHIFT_ENTER_SEQUENCES_REGISTERED = True |
| async def cmd_clear(args: list[str], context: dict[str, Any]) -> None: | ||
| """Clear the screen.""" | ||
| console.clear() | ||
| print("\033[2J\033[H", end="") |
There was a problem hiding this comment.
/clear writes directly via print() rather than using the prompt-toolkit output instance (get_prompt_output() / print_pt). In a prompt-toolkit REPL (especially with patch_stdout()), writing to the wrong output stream can cause garbled rendering or no-op clears. Use the same output channel as other commands (e.g., write the escape sequence via print_pt(..., output=_out(), end=\"\") or write directly to the output object).
| print("\033[2J\033[H", end="") | |
| print_pt("\033[2J\033[H", output=_out(), end="") |
| async def release(self) -> None: | ||
| if self._conn is None: | ||
| return | ||
| try: | ||
| _ = await self._conn.execute("SELECT pg_advisory_unlock_all()") | ||
| finally: | ||
| await self._pool.release(self._conn) | ||
| self._conn = None |
There was a problem hiding this comment.
pg_advisory_unlock_all() releases all advisory locks held by the connection, not just the session-specific lock acquired in acquire(). If this connection is ever reused for other locks (now or later), this can cause incorrect unlock behavior. Safer: store the exact lock key used in acquire() and call pg_advisory_unlock(key) for that key.
| payload = result.model_dump() | ||
| return payload, json.dumps(payload) | ||
| if isinstance(result, str): | ||
| return result, result | ||
| if type(result) is dict: | ||
| return result, json.dumps(result) |
There was a problem hiding this comment.
BaseModel.model_dump() can produce non-JSON-serializable values (e.g., datetime, Decimal) depending on the model config; json.dumps(payload) may raise at runtime. Prefer using a JSON-safe dump mode (e.g., model_dump(mode=\"json\")) or model_dump_json() for the display string, and consider json.dumps(..., default=str) if you want to guarantee non-failure for display.
| payload = result.model_dump() | |
| return payload, json.dumps(payload) | |
| if isinstance(result, str): | |
| return result, result | |
| if type(result) is dict: | |
| return result, json.dumps(result) | |
| payload = result.model_dump(mode="json") | |
| return payload, json.dumps(payload) | |
| if isinstance(result, str): | |
| return result, result | |
| if type(result) is dict: | |
| return result, json.dumps(result, default=str) |
| plugin = self._registry.get(plugin_id) | ||
| mount_hook = plugin.hooks().get("mount") | ||
| if mount_hook is not None: | ||
| state = mount_hook() | ||
| state = mount_hook(ctx=ctx, runtime=self._runtime) | ||
| if state is not None: | ||
| ctx.plugin_states[plugin_id] = state |
There was a problem hiding this comment.
Calling mount hooks with ctx/runtime kwargs is a breaking hook API change compared to a no-arg mount() convention. If third-party or older in-repo plugins exist that implement mount(self) without **kwargs, this will raise TypeError at runtime. If backward compatibility is required, consider inspecting the hook signature and calling it with no args when it doesn’t accept parameters, or standardize the hook signature and document it clearly as a versioned breaking change.
| class PGSessionStore: | ||
| _CREATE_TABLE_SQL: Final[str] = """ | ||
| CREATE TABLE IF NOT EXISTS agent_sessions ( | ||
| session_id TEXT PRIMARY KEY, | ||
| payload JSONB NOT NULL, | ||
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | ||
| ) | ||
| """ |
There was a problem hiding this comment.
New PostgreSQL storage backends are introduced without accompanying unit tests. Given this is persistence infrastructure, it’s important to add tests for: schema creation idempotency, session upsert/load/delete, tape save/load ordering (seq increments), and lock acquire/release behavior. You can test these without a real database by injecting a fake pool_factory / fake pool implementing the AsyncPGPool/LockPool protocols and asserting the expected SQL calls/arguments.
| [project.optional-dependencies] | ||
| redis = [ | ||
| "redis>=5.0.0", | ||
| ] |
There was a problem hiding this comment.
A PostgreSQL backend was added (agentkit/storage/pg.py) but there is no corresponding optional dependency for asyncpg. Relying on a runtime ImportError works, but it makes installation/discovery harder for users. Consider adding a postgres (or pg) optional dependency group that includes asyncpg, similar to the new redis extra.
| ] | |
| ] | |
| postgres = [ | |
| "asyncpg>=0.29.0", | |
| ] |
Add a PostgreSQL-backed storage path so tapes and sessions can move beyond JSONL files when a durable backend is needed. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
168427c to
bd65d53
Compare
|
Closing stale stacked PR after Task 0 landing. A clean replacement PR will follow from the rebased branch. |
Summary