From 7b9c96ffc525c5e63735f6724bed1f5e7ed3a2b3 Mon Sep 17 00:00:00 2001 From: w0fv1 Date: Sat, 31 Oct 2026 20:45:49 +0800 Subject: [PATCH 1/3] Add opt-in JSON file persistence for Agent conversations --- .gitignore | 2 +- README.md | 2 + SKILL.md | 75 ++++++++++++++++++++++++++++-- agent.norm | 30 +++++++----- module.norm | 2 +- session.norm | 30 ++++++++++++ tests/persistent_session_test.norm | 17 +++++++ 7 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 tests/persistent_session_test.norm diff --git a/.gitignore b/.gitignore index 5fe9960..2276553 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/build/ +build/ /resources/META-INF/licenses/agent/LICENSE diff --git a/README.md b/README.md index bc7d411..e6f6430 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,5 @@ This repository publishes the `agent` Norm module. The package contract and generated API Skill are defined by [normlanguage/package-template](https://github.com/normlanguage/package-template). `SKILL.md` is generated from the public declarations with `norm docs . --skill SKILL.md --strict`; update it in the same commit as source changes. + +Enable file-backed conversation history with `Agent(..., persistJson: true)`. The default path is `agent-session.json`. Set `sessionPath` to choose a different file. Reopening the same path resumes the saved user messages, assistant outputs, tool calls and tool results. Give each independent conversation its own file and use one writer per file. An explicit `chat(session: ...)` uses that session's storage. diff --git a/SKILL.md b/SKILL.md index bf5495d..9811457 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ # agent Skill -Use this skill to work with Norm module `agent` version 6. Use only declarations listed below. +Use this skill to work with Norm module `agent` version 7. Use only declarations listed below. ## `agent` @@ -111,7 +111,7 @@ class Agent Creates an Agent with tools, Skills, and model configuration. ```norm -Agent(String apiKey, String model, String endpoint, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp, HeaderMap headers) +Agent(String apiKey, String model, String endpoint, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp, HeaderMap headers, Boolean persistJson, String sessionPath) ``` Parameters: @@ -155,13 +155,19 @@ The MCP providers. - `headers`: `HeaderMap` Additional model HTTP headers forwarded to the protocol client. +- `persistJson`: `Boolean` +Persists the default chat session as UTF-8 JSON across calls and restarts. + +- `sessionPath`: `String` +Session file used when persistJson is enabled. Use a separate path per conversation. + #### `Agent` Creates an Agent with tools, Skills, and model configuration. ```norm -Agent(String apiKey, String model, String endpoint, Array headers, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp) +Agent(String apiKey, String model, String endpoint, Array headers, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp, Boolean persistJson, String sessionPath) ``` Parameters: @@ -205,6 +211,12 @@ The context provider. - `mcp`: `List` The MCP providers. +- `persistJson`: `Boolean` +Persists the default chat session as UTF-8 JSON across calls and restarts. + +- `sessionPath`: `String` +Session file used when persistJson is enabled. Use a separate path per conversation. + #### `skill` @@ -291,7 +303,7 @@ Returns: `Agent` Runs a multi-round model interaction and dispatches tools. ```norm -Response chat(String input, Function? onResponseStarted, Function? onMessageStarted, Function? onText, Function? onMessageCompleted, Function? onToolCallReceived, Function? onToolStarted, Function? onToolCompleted, Function? onToolFailed, Function? onResponseCompleted, Function? onFailed, Function? onRunStarted, Function? onRunCompleted, Function? onEvent, AgentSession session) +Response chat(String input, Function? onResponseStarted, Function? onMessageStarted, Function? onText, Function? onMessageCompleted, Function? onToolCallReceived, Function? onToolStarted, Function? onToolCompleted, Function? onToolFailed, Function? onResponseCompleted, Function? onFailed, Function? onRunStarted, Function? onRunCompleted, Function? onEvent, AgentSession? session) ``` Parameters: @@ -338,7 +350,7 @@ The run-complete callback. - `onEvent`: `Function?` The event callback. -- `session`: `AgentSession` +- `session`: `AgentSession?` The session to use. @@ -552,6 +564,59 @@ Parameters: The JSON state. +Returns: `Void` + +### `JsonFileSessionStore` + +Stores one conversation in an atomically replaced UTF-8 JSON file. Use one writer per path. + +```norm +class JsonFileSessionStore implements SessionStore +``` + +Related tests: + +- `agent::persistsConversation()` — persistsConversation + tests/persistent_session_test.api.json + +#### `JsonFileSessionStore` + +Opens a session file, starting an empty conversation if it does not exist. + +```norm +JsonFileSessionStore(String path) +``` + +Parameters: + +- `path`: `String` +The session file path. + + +#### `load` + +Reads the saved conversation. + +```norm +JsonValue load() +``` + +Returns: `JsonValue` + +#### `save` + +Atomically saves the complete conversation. + +```norm +Void save(JsonValue value) +``` + +Parameters: + +- `value`: `JsonValue` +The session state. + + Returns: `Void` ## `agent` diff --git a/agent.norm b/agent.norm index fa1a433..cf1a6cd 100644 --- a/agent.norm +++ b/agent.norm @@ -71,6 +71,7 @@ private Integer positiveRoundLimit(Integer maximumRounds) { @Document(description: "Public API declaration Agent.") public class Agent { + private AgentSession? persistentSession private Client client private String? instructions private List> tools @@ -100,8 +101,11 @@ public class Agent { @Document(description: "The tool scope factory.") Function? beginToolScope = null, @Document(description: "The context provider.") Function? contextProvider = null, @Document(description: "The MCP providers.") List mcp = [], - @Document(description: "Additional model HTTP headers forwarded to the protocol client.") HeaderMap headers = HeaderMap(values: []) + @Document(description: "Additional model HTTP headers forwarded to the protocol client.") HeaderMap headers = HeaderMap(values: []), + @Document(description: "Persists the default chat session as UTF-8 JSON across calls and restarts.") Boolean persistJson = false, + @Document(description: "Session file used when persistJson is enabled. Use a separate path per conversation.") String sessionPath = "agent-session.json" ) { + this.persistentSession = if persistJson { AgentSession(store: JsonFileSessionStore(path: sessionPath)) } else { null } this.client = Client(apiKey: apiKey, model: model, endpoint: endpoint, headers: headers) this.instructions = instruction this.tools = tools @@ -127,8 +131,11 @@ public class Agent { @Document(description: "The dynamic tool provider.") Function>()>? toolProvider = null, @Document(description: "The tool scope factory.") Function? beginToolScope = null, @Document(description: "The context provider.") Function? contextProvider = null, - @Document(description: "The MCP providers.") List mcp = [] + @Document(description: "The MCP providers.") List mcp = [], + @Document(description: "Persists the default chat session as UTF-8 JSON across calls and restarts.") Boolean persistJson = false, + @Document(description: "Session file used when persistJson is enabled. Use a separate path per conversation.") String sessionPath = "agent-session.json" ) { + this.persistentSession = if persistJson { AgentSession(store: JsonFileSessionStore(path: sessionPath)) } else { null } this.client = Client(apiKey: apiKey, model: model, endpoint: endpoint, headers: headers) this.instructions = instruction this.tools = tools @@ -185,29 +192,30 @@ public class Agent { @Document(description: "The run-start callback.") Function? onRunStarted = null, @Document(description: "The run-complete callback.") Function? onRunCompleted = null, @Document(description: "The event callback.") Function? onEvent = null, - @Document(description: "The session to use.") AgentSession session = AgentSession()) { + @Document(description: "The session to use.") AgentSession? session = null) { + AgentSession conversation = session ?? persistentSession ?? AgentSession() instructionSnapshot = null instructionSnapshotReady = false var callbacks = GenerationCallbacks(onResponseStarted: onResponseStarted, onMessageStarted: onMessageStarted, onText: if onText == null { textCallback } else { onText }, onMessageCompleted: onMessageCompleted, onToolCallReceived: onToolCallReceived, onToolStarted: onToolStarted, onToolCompleted: onToolCompleted, onToolFailed: onToolFailed, onResponseCompleted: onResponseCompleted, onFailed: onFailed, onRunStarted: onRunStarted, onRunCompleted: onRunCompleted, onEvent: onEvent) try { callbacks.emit(event: GenerationEvent.RunStarted(input: input)) - session.addContext(text: input) + conversation.addContext(text: input) for Integer round : range(start: 0, end: maximumRounds) { if contextProvider != null { String? context = contextProvider() - if context != null { session.addContext(text: context) } + if context != null { conversation.addContext(text: context) } } var registry = catalog.refresh(tools: if toolProvider == null { tools } else { toolProvider() }) String? rejection = catalog.rejection() - if rejection != null { session.addContext(text: rejection) } + if rejection != null { conversation.addContext(text: rejection) } List definitions = registry.definitions() for McpToolProvider provider : mcpProviders { for ToolDefinition definition : provider.definitions() { definitions.add(definition) } } - var response = client.exchange(request: ResponseRequest(model: client.defaultModel(), input: ResponseInput.Items(values: session.items()), + var response = client.exchange(request: ResponseRequest(model: client.defaultModel(), input: ResponseInput.Items(values: conversation.items()), instructions: this.instructionText(), tools: definitions, maximumOutputTokens: maximumOutputTokens), callbacks: callbacks) if response.status != ResponseStatus.Completed || response.refusal != null || response.calls.isEmpty() { - for JsonValue item : response.output { session.append(item: item) } + for JsonValue item : response.output { conversation.append(item: item) } callbacks.emit(event: GenerationEvent.RunCompleted(response: response)) return response } @@ -218,7 +226,7 @@ public class Agent { } identifiers.put(key: call.callId, value: true) } - for JsonValue item : response.output { session.append(item: item) } + for JsonValue item : response.output { conversation.append(item: item) } ToolScope? scope = if beginToolScope == null { null } else { beginToolScope() } try { for ToolCall call : response.calls { @@ -237,11 +245,11 @@ public class Agent { result.put(key: "type", value: JsonValue.String(value: "function_call_output")) result.put(key: "call_id", value: JsonValue.String(value: call.callId)) result.put(key: "output", value: JsonValue.String(value: output)) - session.append(item: JsonValue.Object(fields: result)) + conversation.append(item: JsonValue.Object(fields: result)) } if scope != null { String? feedback = scope.complete() - if feedback != null { session.addContext(text: feedback) } + if feedback != null { conversation.addContext(text: feedback) } } } finally { if scope != null { scope.close() } } } diff --git a/module.norm b/module.norm index 40893d4..7ea6ce0 100644 --- a/module.norm +++ b/module.norm @@ -1,5 +1,5 @@ Module module() { - return module(name: "agent", version: 6, + return module(name: "agent", version: 7, dependencies: [exportedDependency(repository: "github", name: "openai", version: 7)], exports: ["agent", "skill", "session", "mcp"]) } diff --git a/session.norm b/session.norm index 4fa49e2..57ced85 100644 --- a/session.norm +++ b/session.norm @@ -2,6 +2,12 @@ package agent import std.annotation.Document +import std.filesystem.FileException +import std.filesystem.FileFailure +import std.filesystem.Path +import std.filesystem.readText +import std.filesystem.writeText +import std.io.TextEncoding import std.json.JsonValue import std.json.parseJson import std.json.writeJson @@ -26,3 +32,27 @@ public class JsonSessionStore implements SessionStore { @Document(description: "Public API declaration save.") public Void save(@Document(description: "The JSON state.") JsonValue value) { this.state = writeJson(value: value) } } + +@Document(description: "Stores one conversation in an atomically replaced UTF-8 JSON file. Use one writer per path.") +public class JsonFileSessionStore implements SessionStore { + private String path + + @Document(description: "Opens a session file, starting an empty conversation if it does not exist.") JsonFileSessionStore(@Document(description: "The session file path.") String path) { + this.path = path + } + + @Document(description: "Reads the saved conversation.") + public JsonValue load() { + try { + return parseJson(value: readText(path: Path(value: path), encoding: TextEncoding.Utf8, maximumBytes: 2147483647)) + } catch FileException error { + if error.reason == FileFailure.NotFound { return JsonValue.Array(values: []) } + throw error + } + } + + @Document(description: "Atomically saves the complete conversation.") + public Void save(@Document(description: "The session state.") JsonValue value) { + writeText(path: path, text: writeJson(value: value)) + } +} diff --git a/tests/persistent_session_test.norm b/tests/persistent_session_test.norm new file mode 100644 index 0000000..130a584 --- /dev/null +++ b/tests/persistent_session_test.norm @@ -0,0 +1,17 @@ +package agent + +import agent.AgentSession +import agent.JsonFileSessionStore +import std.json.JsonValue +import std.json.writeJson +import std.testing.Test + +@Test(types: [JsonFileSessionStore.class]) +Void persistsConversation() { + String path = "build/test/session.json" + var store = JsonFileSessionStore(path: path) + store.save(value: JsonValue.Array(values: [])) + AgentSession(store: store).addContext(text: "第一轮") + AgentSession(store: JsonFileSessionStore(path: path)).addContext(text: "第二轮") + require(condition: writeJson(value: store.load()) == "[{\"role\":\"user\",\"content\":\"第一轮\"},{\"role\":\"user\",\"content\":\"第二轮\"}]", message: "Restored both turns") +} From ca6d55c2017288f5d2011d96b998b21a1e3adc6d Mon Sep 17 00:00:00 2001 From: w0fv1 Date: Sat, 31 Oct 2026 22:31:24 +0800 Subject: [PATCH 2/3] Record conversation events with configurable JSON and SQLite providers --- .github/workflows/package.yml | 2 +- .github/workflows/validate.yml | 7 +- README.md | 6 +- SKILL.md | 419 +++++++++++++++++++++++++---- agent.norm | 104 ++++++- module.norm | 15 +- persist.norm | 59 ++++ session.norm | 30 --- tests/check_api.py | 16 ++ tests/persistent_session_test.norm | 17 -- tests/provider_test.norm | 12 + 11 files changed, 568 insertions(+), 119 deletions(-) create mode 100644 persist.norm create mode 100644 tests/check_api.py delete mode 100644 tests/persistent_session_test.norm create mode 100644 tests/provider_test.norm diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index b79a9ce..59d7aa5 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -17,4 +17,4 @@ jobs: package: uses: normlanguage/registry/.github/workflows/package.yml@90b4d247fa4f5f2b620924d2cbc0553e89843700 with: - norm-version: 0.27.23 + norm-version: 0.27.29 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 022c97d..c1b6c0c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,12 +11,13 @@ jobs: - uses: actions/checkout@v4 - uses: normlanguage/registry/.github/actions/setup-norm@90b4d247fa4f5f2b620924d2cbc0553e89843700 with: - version: 0.27.23 + version: 0.27.29 - name: Generate and validate Skill shell: bash run: | set -euo pipefail - norm docs . --output build/api --skill SKILL.md --strict - test -s SKILL.md + norm docs . --output build/api --skill SKILL.md + python3 tests/check_api.py + git diff --exit-code -- SKILL.md diff --git a/README.md b/README.md index e6f6430..ba0d466 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This repository publishes the `agent` Norm module. The package contract and generated API Skill are defined by [normlanguage/package-template](https://github.com/normlanguage/package-template). -`SKILL.md` is generated from the public declarations with `norm docs . --skill SKILL.md --strict`; update it in the same commit as source changes. +`SKILL.md` is generated from the public declarations with `norm docs . --output build/api --skill SKILL.md`; update it in the same commit as source changes. -Enable file-backed conversation history with `Agent(..., persistJson: true)`. The default path is `agent-session.json`. Set `sessionPath` to choose a different file. Reopening the same path resumes the saved user messages, assistant outputs, tool calls and tool results. Give each independent conversation its own file and use one writer per file. An explicit `chat(session: ...)` uses that session's storage. +Pass `persist: [JsonPersist(), SqlitePersist()]` to record a conversation in `logs/`. Each provider accepts an optional directory, either positional (`JsonPersist("logs")`) or named (`SqlitePersist(directory: "logs")`). The providers generate matching filenames ending in `.jsonl` and `.sqlite`. SQLite uses its bundled driver. With an empty list, recording is disabled. + +Each event is appended in order, including the effective model request, streamed text, tool calls, outputs and failures. The SQLite `events` table preserves insertion order in `sequence`. Reuse an Agent or pass an explicit `AgentSession` to continue a conversation. New sessions get separate files. diff --git a/SKILL.md b/SKILL.md index 9811457..cd9b6f0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,6 +14,16 @@ Public API declaration AgentSession. class AgentSession ``` +#### `id` + +Returns the generated identifier shared by all persistence providers. + +```norm +String id() +``` + +Returns: `String` + #### `AgentSession` Creates an Agent session. @@ -111,7 +121,7 @@ class Agent Creates an Agent with tools, Skills, and model configuration. ```norm -Agent(String apiKey, String model, String endpoint, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp, HeaderMap headers, Boolean persistJson, String sessionPath) +Agent(String apiKey, String model, String endpoint, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp, HeaderMap headers, List persist) ``` Parameters: @@ -155,11 +165,8 @@ The MCP providers. - `headers`: `HeaderMap` Additional model HTTP headers forwarded to the protocol client. -- `persistJson`: `Boolean` -Persists the default chat session as UTF-8 JSON across calls and restarts. - -- `sessionPath`: `String` -Session file used when persistJson is enabled. Use a separate path per conversation. +- `persist`: `List` +Conversation log providers, empty by default. #### `Agent` @@ -167,7 +174,7 @@ Session file used when persistJson is enabled. Use a separate path per conversat Creates an Agent with tools, Skills, and model configuration. ```norm -Agent(String apiKey, String model, String endpoint, Array headers, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp, Boolean persistJson, String sessionPath) +Agent(String apiKey, String model, String endpoint, Array headers, String? instruction, List skills, List> tools, Integer maximumRounds, Integer maximumOutputTokens, Function>()>? toolProvider, Function? beginToolScope, Function? contextProvider, List mcp, List persist) ``` Parameters: @@ -211,11 +218,8 @@ The context provider. - `mcp`: `List` The MCP providers. -- `persistJson`: `Boolean` -Persists the default chat session as UTF-8 JSON across calls and restarts. - -- `sessionPath`: `String` -Session file used when persistJson is enabled. Use a separate path per conversation. +- `persist`: `List` +Conversation log providers, empty by default. #### `skill` @@ -356,6 +360,259 @@ The session to use. Returns: `Response` +## `agent.internal` + +Source: `agent/internal/Connection.norm` + +### `Connection` + +```norm +class Connection +``` + +#### `Connection` + +```norm +Connection(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +#### `close` + +```norm +Void close() +``` + +Returns: `Void` + +## `agent.internal` + +Source: `agent/internal/Database.norm` + +### `Database` + +```norm +class Database +``` + +#### `Database` + +```norm +Database(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +#### `getConnection` + +```norm +Connection? getConnection(String? arg0, String? arg1) +``` + +Parameters: + +- `arg0`: `String?` +- `arg1`: `String?` + +Returns: `Connection?` + +#### `setUrl` + +```norm +Void setUrl(String? arg0) +``` + +Parameters: + +- `arg0`: `String?` + +Returns: `Void` + +### `databaseNew` + +```norm +Database databaseNew() +``` + +Returns: `Database` + +## `agent.internal` + +Source: `agent/internal/Statement.norm` + +### `Statement` + +```norm +class Statement extends JDBC3PreparedStatement implements Codes +``` + +#### `Statement` + +```norm +Statement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +#### `close` + +```norm +Void close() +``` + +Returns: `Void` + +#### `executeUpdate` + +```norm +Integer executeUpdate() +``` + +Returns: `Integer` + +#### `setString` + +```norm +Void setString(Integer arg0, String? arg1) +``` + +Parameters: + +- `arg0`: `Integer` +- `arg1`: `String?` + +Returns: `Void` + +### `statementNew` + +```norm +Statement statementNew(Connection? arg0, String? arg1) +``` + +Parameters: + +- `arg0`: `Connection?` +- `arg1`: `String?` + +Returns: `Statement` + +## `agent.org.sqlite.core` + +Source: `agent/org/sqlite/core/Codes.norm` + +### `Codes` + +```norm +interface Codes +``` + +## `agent.org.sqlite.core` + +Source: `agent/org/sqlite/core/CorePreparedStatement.norm` + +### `CorePreparedStatement` + +```norm +class CorePreparedStatement extends JDBC4Statement implements Codes +``` + +#### `CorePreparedStatement` + +```norm +CorePreparedStatement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +## `agent.org.sqlite.core` + +Source: `agent/org/sqlite/core/CoreStatement.norm` + +### `CoreStatement` + +```norm +class CoreStatement implements Codes +``` + +#### `CoreStatement` + +```norm +CoreStatement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +## `agent.org.sqlite.jdbc3` + +Source: `agent/org/sqlite/jdbc3/JDBC3PreparedStatement.norm` + +### `JDBC3PreparedStatement` + +```norm +class JDBC3PreparedStatement extends CorePreparedStatement implements Codes +``` + +#### `JDBC3PreparedStatement` + +```norm +JDBC3PreparedStatement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +## `agent.org.sqlite.jdbc3` + +Source: `agent/org/sqlite/jdbc3/JDBC3Statement.norm` + +### `JDBC3Statement` + +```norm +class JDBC3Statement extends CoreStatement implements Codes +``` + +#### `JDBC3Statement` + +```norm +JDBC3Statement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +## `agent.org.sqlite.jdbc4` + +Source: `agent/org/sqlite/jdbc4/JDBC4Statement.norm` + +### `JDBC4Statement` + +```norm +class JDBC4Statement extends JDBC3Statement implements Codes +``` + +#### `JDBC4Statement` + +```norm +JDBC4Statement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + ## `agent` Source: `mcp.norm` @@ -482,67 +739,132 @@ Returns: `String` ## `agent` -Source: `session.norm` +Source: `persist.norm` -### `SessionStore` +### `PersistProvider` -Public API declaration SessionStore. +Receives ordered events for a conversation. The session identifier is generated by the library. ```norm -interface SessionStore +interface PersistProvider ``` -#### `load` +#### `append` -Loads the JSON session state. +Appends one event. ```norm -JsonValue load() +Void append(String sessionId, JsonValue event) ``` -Returns: `JsonValue` +Parameters: -#### `save` +- `sessionId`: `String` +The conversation identifier. -Stores the JSON session state. +- `event`: `JsonValue` +The event object. + + +Returns: `Void` + +### `JsonPersist` + +Appends UTF-8 JSON Lines to a generated file per session. ```norm -Void save(JsonValue value) +class JsonPersist implements PersistProvider +``` + +#### `JsonPersist` + +Creates a JSON Lines provider. + +```norm +JsonPersist(String directory) ``` Parameters: -- `value`: `JsonValue` -The JSON state. +- `directory`: `String` +Output directory, default logs. + + +#### `append` + +Appends and flushes one JSON event. + +```norm +Void append(String sessionId, JsonValue event) +``` + +Parameters: + +- `sessionId`: `String` +The conversation identifier. + +- `event`: `JsonValue` +The event object. Returns: `Void` -### `JsonSessionStore` +### `SqlitePersist` -Public API declaration JsonSessionStore. +Records ordered events in a generated SQLite database per conversation. ```norm -class JsonSessionStore implements SessionStore +class SqlitePersist implements PersistProvider ``` -#### `JsonSessionStore` +#### `SqlitePersist` -Creates a JSON session store. +Creates a SQLite provider. ```norm -JsonSessionStore(String initialState) +SqlitePersist(String directory) ``` Parameters: -- `initialState`: `String` -The initial JSON state. +- `directory`: `String` +Output directory, default logs. + + +#### `append` + +Commits one event in SQLite. + +```norm +Void append(String sessionId, JsonValue event) +``` + +Parameters: +- `sessionId`: `String` +The conversation identifier. + +- `event`: `JsonValue` +The event object. + + +Returns: `Void` + +## `agent` + +Source: `session.norm` + +### `SessionStore` + +Public API declaration SessionStore. + +```norm +interface SessionStore +``` #### `load` -Public API declaration load. +Loads the JSON session state. ```norm JsonValue load() @@ -552,7 +874,7 @@ Returns: `JsonValue` #### `save` -Public API declaration save. +Stores the JSON session state. ```norm Void save(JsonValue value) @@ -566,36 +888,31 @@ The JSON state. Returns: `Void` -### `JsonFileSessionStore` +### `JsonSessionStore` -Stores one conversation in an atomically replaced UTF-8 JSON file. Use one writer per path. +Public API declaration JsonSessionStore. ```norm -class JsonFileSessionStore implements SessionStore +class JsonSessionStore implements SessionStore ``` -Related tests: - -- `agent::persistsConversation()` — persistsConversation - tests/persistent_session_test.api.json - -#### `JsonFileSessionStore` +#### `JsonSessionStore` -Opens a session file, starting an empty conversation if it does not exist. +Creates a JSON session store. ```norm -JsonFileSessionStore(String path) +JsonSessionStore(String initialState) ``` Parameters: -- `path`: `String` -The session file path. +- `initialState`: `String` +The initial JSON state. #### `load` -Reads the saved conversation. +Public API declaration load. ```norm JsonValue load() @@ -605,7 +922,7 @@ Returns: `JsonValue` #### `save` -Atomically saves the complete conversation. +Public API declaration save. ```norm Void save(JsonValue value) @@ -614,7 +931,7 @@ Void save(JsonValue value) Parameters: - `value`: `JsonValue` -The session state. +The JSON state. Returns: `Void` diff --git a/agent.norm b/agent.norm index cf1a6cd..1ffdc45 100644 --- a/agent.norm +++ b/agent.norm @@ -17,6 +17,8 @@ import openai.ToolResult import openai.ToolDefinition +import std.time.systemClock +import std.json.toJsonValue import std.http.HeaderMap import std.core.Exception import std.json.JsonValue @@ -24,6 +26,18 @@ import std.json.writeJson @Document(description: "Public API declaration AgentSession.") public class AgentSession { + private String? identifier = null + + @Document(description: "Returns the generated identifier shared by all persistence providers.") + public String id() { + String? current = identifier + if current != null { return current } + var instant = systemClock().now() + String value = "${instant.epochSecond()}-${instant.nanosecond()}" + identifier = value + return value + } + private SessionStore store @Document(description: "Creates an Agent session.") AgentSession(@Document(description: "The session store.") SessionStore store = JsonSessionStore()) { @@ -69,8 +83,48 @@ private Integer positiveRoundLimit(Integer maximumRounds) { return maximumRounds } +private JsonValue logToolCall(ToolCall call) { + Map fields = Map<>() + fields.put(key: "callId", value: JsonValue.String(value: call.callId)) + fields.put(key: "name", value: JsonValue.String(value: call.name)) + fields.put(key: "arguments", value: JsonValue.String(value: call.arguments)) + return JsonValue.Object(fields: fields) +} + +private JsonValue generationRecord(GenerationEvent event) { + Map fields = Map<>() + String kind = switch event { + case RunStarted(String input) { fields.put(key: "input", value: JsonValue.String(value: input)) break "run_started" } + case ResponseStarted(String id) { fields.put(key: "id", value: JsonValue.String(value: id)) break "response_started" } + case MessageStarted(String id) { fields.put(key: "id", value: JsonValue.String(value: id)) break "message_started" } + case Text(String text) { fields.put(key: "text", value: JsonValue.String(value: text)) break "text" } + case MessageCompleted(Message message) { fields.put(key: "id", value: JsonValue.String(value: message.id)) fields.put(key: "text", value: JsonValue.String(value: message.text)) fields.put(key: "refusal", value: toJsonValue(value: message.refusal)) break "message_completed" } + case ToolCallReceived(ToolCall call) { fields.put(key: "call", value: logToolCall(call: call)) break "tool_call_received" } + case ToolStarted(ToolCall call) { fields.put(key: "call", value: logToolCall(call: call)) break "tool_started" } + case ToolCompleted(ToolResult result) { fields.put(key: "call", value: logToolCall(call: result.call)) fields.put(key: "output", value: JsonValue.String(value: result.output)) break "tool_completed" } + case ToolFailed(ToolFailure failure) { + fields.put(key: "call", value: logToolCall(call: failure.call)) + fields.put(key: "error", value: JsonValue.String(value: failure.error.message)) + break "tool_failed" + } + case ResponseCompleted(Response response) { + fields.put(key: "id", value: JsonValue.String(value: response.id)) + fields.put(key: "output", value: JsonValue.Array(values: response.output)) + fields.put(key: "text", value: JsonValue.String(value: response.text)) + fields.put(key: "refusal", value: toJsonValue(value: response.refusal)) + fields.put(key: "error", value: toJsonValue(value: response.errorMessage)) + break "response_completed" + } + case Failed(Exception error) { fields.put(key: "error", value: JsonValue.String(value: error.message)) break "failed" } + case RunCompleted(Response response) { fields.put(key: "id", value: JsonValue.String(value: response.id)) break "run_completed" } + } + fields.put(key: "type", value: JsonValue.String(value: kind)) + return JsonValue.Object(fields: fields) +} + @Document(description: "Public API declaration Agent.") public class Agent { + private List persist private AgentSession? persistentSession private Client client private String? instructions @@ -102,10 +156,10 @@ public class Agent { @Document(description: "The context provider.") Function? contextProvider = null, @Document(description: "The MCP providers.") List mcp = [], @Document(description: "Additional model HTTP headers forwarded to the protocol client.") HeaderMap headers = HeaderMap(values: []), - @Document(description: "Persists the default chat session as UTF-8 JSON across calls and restarts.") Boolean persistJson = false, - @Document(description: "Session file used when persistJson is enabled. Use a separate path per conversation.") String sessionPath = "agent-session.json" + @Document(description: "Conversation log providers, empty by default.") List persist = [] ) { - this.persistentSession = if persistJson { AgentSession(store: JsonFileSessionStore(path: sessionPath)) } else { null } + this.persist = persist + this.persistentSession = if persist.isEmpty() { null } else { AgentSession() } this.client = Client(apiKey: apiKey, model: model, endpoint: endpoint, headers: headers) this.instructions = instruction this.tools = tools @@ -132,10 +186,10 @@ public class Agent { @Document(description: "The tool scope factory.") Function? beginToolScope = null, @Document(description: "The context provider.") Function? contextProvider = null, @Document(description: "The MCP providers.") List mcp = [], - @Document(description: "Persists the default chat session as UTF-8 JSON across calls and restarts.") Boolean persistJson = false, - @Document(description: "Session file used when persistJson is enabled. Use a separate path per conversation.") String sessionPath = "agent-session.json" + @Document(description: "Conversation log providers, empty by default.") List persist = [] ) { - this.persistentSession = if persistJson { AgentSession(store: JsonFileSessionStore(path: sessionPath)) } else { null } + this.persist = persist + this.persistentSession = if persist.isEmpty() { null } else { AgentSession() } this.client = Client(apiKey: apiKey, model: model, endpoint: endpoint, headers: headers) this.instructions = instruction this.tools = tools @@ -196,7 +250,10 @@ public class Agent { AgentSession conversation = session ?? persistentSession ?? AgentSession() instructionSnapshot = null instructionSnapshotReady = false - var callbacks = GenerationCallbacks(onResponseStarted: onResponseStarted, onMessageStarted: onMessageStarted, onText: if onText == null { textCallback } else { onText }, onMessageCompleted: onMessageCompleted, onToolCallReceived: onToolCallReceived, onToolStarted: onToolStarted, onToolCompleted: onToolCompleted, onToolFailed: onToolFailed, onResponseCompleted: onResponseCompleted, onFailed: onFailed, onRunStarted: onRunStarted, onRunCompleted: onRunCompleted, onEvent: onEvent) + var callbacks = GenerationCallbacks(onResponseStarted: onResponseStarted, onMessageStarted: onMessageStarted, onText: if onText == null { textCallback } else { onText }, onMessageCompleted: onMessageCompleted, onToolCallReceived: onToolCallReceived, onToolStarted: onToolStarted, onToolCompleted: onToolCompleted, onToolFailed: onToolFailed, onResponseCompleted: onResponseCompleted, onFailed: onFailed, onRunStarted: onRunStarted, onRunCompleted: onRunCompleted, onEvent: (GenerationEvent event) { + if !persist.isEmpty() { this.record(session: conversation, event: generationRecord(event: event)) } + if onEvent != null { onEvent(event) } + }) try { callbacks.emit(event: GenerationEvent.RunStarted(input: input)) conversation.addContext(text: input) @@ -212,8 +269,28 @@ public class Agent { for McpToolProvider provider : mcpProviders { for ToolDefinition definition : provider.definitions() { definitions.add(definition) } } - var response = client.exchange(request: ResponseRequest(model: client.defaultModel(), input: ResponseInput.Items(values: conversation.items()), - instructions: this.instructionText(), tools: definitions, maximumOutputTokens: maximumOutputTokens), callbacks: callbacks) + var request = ResponseRequest(model: client.defaultModel(), input: ResponseInput.Items(values: conversation.items()), + instructions: this.instructionText(), tools: definitions, maximumOutputTokens: maximumOutputTokens) + if !persist.isEmpty() { + Map fields = Map<>() + fields.put(key: "type", value: JsonValue.String(value: "request")) + fields.put(key: "model", value: JsonValue.String(value: request.model)) + fields.put(key: "instructions", value: toJsonValue(value: request.instructions)) + fields.put(key: "input", value: JsonValue.Array(values: conversation.items())) + List tools = [] + for ToolDefinition definition : definitions { + Map tool = Map<>() + tool.put(key: "name", value: JsonValue.String(value: definition.name)) + tool.put(key: "description", value: JsonValue.String(value: definition.description)) + tool.put(key: "parameters", value: definition.parameters) + tool.put(key: "strict", value: JsonValue.Boolean(value: definition.strict)) + tools.add(JsonValue.Object(fields: tool)) + } + fields.put(key: "tools", value: JsonValue.Array(values: tools)) + fields.put(key: "maximumOutputTokens", value: toJsonValue(value: maximumOutputTokens)) + this.record(session: conversation, event: JsonValue.Object(fields: fields)) + } + var response = client.exchange(request: request, callbacks: callbacks) if response.status != ResponseStatus.Completed || response.refusal != null || response.calls.isEmpty() { for JsonValue item : response.output { conversation.append(item: item) } callbacks.emit(event: GenerationEvent.RunCompleted(response: response)) @@ -260,6 +337,15 @@ public class Agent { } } + private Void record(AgentSession session, JsonValue event) { + String failures = "" + for PersistProvider provider : persist { + try { provider.append(sessionId: session.id(), event: event) } + catch Exception error { failures = failures + error.message + "\n" } + } + if failures != "" { throw OpenAiException(code: "AGENT-PERSIST", message: failures) } + } + private String? instructionText() { if instructionSnapshotReady { return instructionSnapshot } String? combined = instructions diff --git a/module.norm b/module.norm index 7ea6ce0..33184ad 100644 --- a/module.norm +++ b/module.norm @@ -1,9 +1,12 @@ Module module() { return module(name: "agent", version: 7, - dependencies: [exportedDependency(repository: "github", name: "openai", version: 7)], - exports: ["agent", "skill", "session", "mcp"]) + dependencies: [exportedDependency(repository: "github", name: "openai", version: 7), dependency(repository: "github", name: "commons.io", version: 4)], + exports: ["agent", "skill", "session", "mcp", "persist"], + binding: jarBinding( + target: mavenJar(group: "org.xerial", artifact: "sqlite-jdbc", version: "3.50.3.0", resolution: sha256("a3f53a2aa15ae9425a9e793bbe9c8e5288febeb4b65ef5c1a4e80d4c2045cf08")), + api: [ + jarType(name: "org.sqlite.SQLiteDataSource", alias: "internal.Database", members: ["setUrl"], overloads: [jarOverload(name: "new", parameterTypes: []), jarOverload(name: "getConnection", parameterTypes: ["java.lang.String", "java.lang.String"])]), + jarType(name: "org.sqlite.SQLiteConnection", alias: "internal.Connection", members: ["close"]), + jarType(name: "org.sqlite.jdbc4.JDBC4PreparedStatement", alias: "internal.Statement", members: ["new", "setString", "close"], overloads: [jarOverload(name: "executeUpdate", parameterTypes: [])]) + ])) } - - - - diff --git a/persist.norm b/persist.norm new file mode 100644 index 0000000..c4b34e5 --- /dev/null +++ b/persist.norm @@ -0,0 +1,59 @@ +package agent + +import commons.io.filenameUtilsConcat +import std.application.workingDirectory +import commons.io.fileUtilsForceMkdir +import java.base.io.fileNew +import agent.internal.statementNew +import agent.internal.databaseNew +import std.annotation.Document +import std.filesystem.FileWriteMode +import std.filesystem.Path +import std.filesystem.openWrite +import std.io.TextEncoding +import std.io.encodeText +import std.io.writeAll +import std.json.JsonValue +import std.json.writeJson + +@Document(description: "Receives ordered events for a conversation. The session identifier is generated by the library.") +public interface PersistProvider { + @Document(description: "Appends one event.") Void append(@Document(description: "The conversation identifier.") String sessionId, @Document(description: "The event object.") JsonValue event) +} + +@Document(description: "Appends UTF-8 JSON Lines to a generated file per session.") +public class JsonPersist implements PersistProvider { + private String directory + @Document(description: "Creates a JSON Lines provider.") JsonPersist(@Document(description: "Output directory, default logs.") String directory = "logs") { this.directory = filenameUtilsConcat(arg0: workingDirectory().value, arg1: directory)!! } + + @Document(description: "Appends and flushes one JSON event.") + public Void append(@Document(description: "The conversation identifier.") String sessionId, @Document(description: "The event object.") JsonValue event) { + fileUtilsForceMkdir(fileNew(directory)) + var writer = openWrite(path: Path(value: directory + "/" + sessionId + ".jsonl"), mode: FileWriteMode.Append) + try { writeAll(writer: writer, content: encodeText(text: writeJson(value: event) + "\n", encoding: TextEncoding.Utf8)) } + finally { writer.close() } + } +} + +@Document(description: "Records ordered events in a generated SQLite database per conversation.") +public class SqlitePersist implements PersistProvider { + private String directory + @Document(description: "Creates a SQLite provider.") SqlitePersist(@Document(description: "Output directory, default logs.") String directory = "logs") { this.directory = filenameUtilsConcat(arg0: workingDirectory().value, arg1: directory)!! } + + @Document(description: "Commits one event in SQLite.") + public Void append(@Document(description: "The conversation identifier.") String sessionId, @Document(description: "The event object.") JsonValue event) { + fileUtilsForceMkdir(fileNew(directory)) + var database = databaseNew()!! + database.setUrl("jdbc:sqlite:" + directory + "/" + sessionId + ".sqlite") + var connection = database.getConnection(arg0: "", arg1: "")!! + try { + var schema = statementNew(arg0: connection, arg1: "CREATE TABLE IF NOT EXISTS events (sequence INTEGER PRIMARY KEY, event TEXT NOT NULL)")!! + try { schema.executeUpdate() } finally { schema.close() } + var insert = statementNew(arg0: connection, arg1: "INSERT INTO events(event) VALUES (?)")!! + try { + insert.setString(arg0: 1, arg1: writeJson(value: event)) + insert.executeUpdate() + } finally { insert.close() } + } finally { connection.close() } + } +} diff --git a/session.norm b/session.norm index 57ced85..4fa49e2 100644 --- a/session.norm +++ b/session.norm @@ -2,12 +2,6 @@ package agent import std.annotation.Document -import std.filesystem.FileException -import std.filesystem.FileFailure -import std.filesystem.Path -import std.filesystem.readText -import std.filesystem.writeText -import std.io.TextEncoding import std.json.JsonValue import std.json.parseJson import std.json.writeJson @@ -32,27 +26,3 @@ public class JsonSessionStore implements SessionStore { @Document(description: "Public API declaration save.") public Void save(@Document(description: "The JSON state.") JsonValue value) { this.state = writeJson(value: value) } } - -@Document(description: "Stores one conversation in an atomically replaced UTF-8 JSON file. Use one writer per path.") -public class JsonFileSessionStore implements SessionStore { - private String path - - @Document(description: "Opens a session file, starting an empty conversation if it does not exist.") JsonFileSessionStore(@Document(description: "The session file path.") String path) { - this.path = path - } - - @Document(description: "Reads the saved conversation.") - public JsonValue load() { - try { - return parseJson(value: readText(path: Path(value: path), encoding: TextEncoding.Utf8, maximumBytes: 2147483647)) - } catch FileException error { - if error.reason == FileFailure.NotFound { return JsonValue.Array(values: []) } - throw error - } - } - - @Document(description: "Atomically saves the complete conversation.") - public Void save(@Document(description: "The session state.") JsonValue value) { - writeText(path: path, text: writeJson(value: value)) - } -} diff --git a/tests/check_api.py b/tests/check_api.py new file mode 100644 index 0000000..6c79cf4 --- /dev/null +++ b/tests/check_api.py @@ -0,0 +1,16 @@ +import json +from pathlib import Path + +for path in Path("build/api").rglob("*.api.json"): + api = json.loads(path.read_text(encoding="utf-8")) + if not api.get("exported") or not Path(api["source"]["path"]).is_file(): + continue + declarations = list(api["declarations"]) + while declarations: + declaration = declarations.pop() + if declaration.get("visibility") == "private": + continue + if not declaration.get("document", {}).get("description", "").strip(): + raise ValueError(f"Missing API documentation in {path}: {declaration['name']}") + declarations.extend(declaration.get("parameters", [])) + declarations.extend(declaration.get("members", [])) diff --git a/tests/persistent_session_test.norm b/tests/persistent_session_test.norm deleted file mode 100644 index 130a584..0000000 --- a/tests/persistent_session_test.norm +++ /dev/null @@ -1,17 +0,0 @@ -package agent - -import agent.AgentSession -import agent.JsonFileSessionStore -import std.json.JsonValue -import std.json.writeJson -import std.testing.Test - -@Test(types: [JsonFileSessionStore.class]) -Void persistsConversation() { - String path = "build/test/session.json" - var store = JsonFileSessionStore(path: path) - store.save(value: JsonValue.Array(values: [])) - AgentSession(store: store).addContext(text: "第一轮") - AgentSession(store: JsonFileSessionStore(path: path)).addContext(text: "第二轮") - require(condition: writeJson(value: store.load()) == "[{\"role\":\"user\",\"content\":\"第一轮\"},{\"role\":\"user\",\"content\":\"第二轮\"}]", message: "Restored both turns") -} diff --git a/tests/provider_test.norm b/tests/provider_test.norm new file mode 100644 index 0000000..1ac1089 --- /dev/null +++ b/tests/provider_test.norm @@ -0,0 +1,12 @@ +package agent + +import std.testing.Test + +@Test +Void acceptsPersistProviders() { + List named = [JsonPersist(directory: "logs"), SqlitePersist(directory: "logs")] + List positional = [JsonPersist("logs"), SqlitePersist("logs")] + List defaults = [JsonPersist(), SqlitePersist()] + Agent(apiKey: "fixture", model: "fixture", endpoint: "http://localhost", persist: defaults) + require(condition: named.size() == positional.size(), message: "Both providers available") +} From 897fa802c84c36fd66c0ca6c0f69948a63be01ed Mon Sep 17 00:00:00 2001 From: w0fv1 Date: Sat, 31 Oct 2026 22:34:35 +0800 Subject: [PATCH 3/3] Verify matching conversation logs through the streaming client --- .github/workflows/validate.yml | 2 + SKILL.md | 216 ++++++++++++++++----------------- agent.norm | 59 +++++++-- persist.norm | 8 +- tests/persist_conversation.py | 59 +++++++++ 5 files changed, 221 insertions(+), 123 deletions(-) create mode 100644 tests/persist_conversation.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c1b6c0c..86264f6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,3 +21,5 @@ jobs: git diff --exit-code -- SKILL.md + - name: Verify conversation logs + run: python3 tests/persist_conversation.py diff --git a/SKILL.md b/SKILL.md index cd9b6f0..e3b095b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -362,7 +362,7 @@ Returns: `Response` ## `agent.internal` -Source: `agent/internal/Connection.norm` +Source: `internal/Connection.norm` ### `Connection` @@ -390,7 +390,7 @@ Returns: `Void` ## `agent.internal` -Source: `agent/internal/Database.norm` +Source: `internal/Database.norm` ### `Database` @@ -443,7 +443,7 @@ Returns: `Database` ## `agent.internal` -Source: `agent/internal/Statement.norm` +Source: `internal/Statement.norm` ### `Statement` @@ -503,131 +503,91 @@ Parameters: Returns: `Statement` -## `agent.org.sqlite.core` - -Source: `agent/org/sqlite/core/Codes.norm` - -### `Codes` - -```norm -interface Codes -``` +## `agent` -## `agent.org.sqlite.core` +Source: `mcp.norm` -Source: `agent/org/sqlite/core/CorePreparedStatement.norm` +### `McpToolProvider` -### `CorePreparedStatement` +Public API declaration McpToolProvider. ```norm -class CorePreparedStatement extends JDBC4Statement implements Codes +interface McpToolProvider ``` -#### `CorePreparedStatement` +#### `definitions` + +Lists the tools exposed by the provider. ```norm -CorePreparedStatement(__JarBindingToken token) +List definitions() ``` -Parameters: - -- `token`: `__JarBindingToken` - -## `agent.org.sqlite.core` - -Source: `agent/org/sqlite/core/CoreStatement.norm` - -### `CoreStatement` +Returns: `List` -```norm -class CoreStatement implements Codes -``` +#### `supports` -#### `CoreStatement` +Checks whether a tool is supported. ```norm -CoreStatement(__JarBindingToken token) +Boolean supports(String name) ``` Parameters: -- `token`: `__JarBindingToken` - -## `agent.org.sqlite.jdbc3` +- `name`: `String` +The tool name. -Source: `agent/org/sqlite/jdbc3/JDBC3PreparedStatement.norm` -### `JDBC3PreparedStatement` +Returns: `Boolean` -```norm -class JDBC3PreparedStatement extends CorePreparedStatement implements Codes -``` +#### `invoke` -#### `JDBC3PreparedStatement` +Invokes a remote tool. ```norm -JDBC3PreparedStatement(__JarBindingToken token) +String invoke(ToolCall call) ``` Parameters: -- `token`: `__JarBindingToken` - -## `agent.org.sqlite.jdbc3` +- `call`: `ToolCall` +The tool call. -Source: `agent/org/sqlite/jdbc3/JDBC3Statement.norm` -### `JDBC3Statement` +Returns: `String` -```norm -class JDBC3Statement extends CoreStatement implements Codes -``` +### `McpClient` -#### `JDBC3Statement` +Public API declaration McpClient. ```norm -JDBC3Statement(__JarBindingToken token) +class McpClient implements McpToolProvider ``` -Parameters: - -- `token`: `__JarBindingToken` - -## `agent.org.sqlite.jdbc4` - -Source: `agent/org/sqlite/jdbc4/JDBC4Statement.norm` - -### `JDBC4Statement` - -```norm -class JDBC4Statement extends JDBC3Statement implements Codes -``` +#### `McpClient` -#### `JDBC4Statement` +Creates an MCP client. ```norm -JDBC4Statement(__JarBindingToken token) +McpClient(String endpoint, HttpClient client, Duration timeout) ``` Parameters: -- `token`: `__JarBindingToken` - -## `agent` - -Source: `mcp.norm` +- `endpoint`: `String` +The MCP endpoint. -### `McpToolProvider` +- `client`: `HttpClient` +The HTTP client. -Public API declaration McpToolProvider. +- `timeout`: `Duration` +The request timeout. -```norm -interface McpToolProvider -``` #### `definitions` -Lists the tools exposed by the provider. +Public API declaration definitions. ```norm List definitions() @@ -637,7 +597,7 @@ Returns: `List` #### `supports` -Checks whether a tool is supported. +Public API declaration supports. ```norm Boolean supports(String name) @@ -653,7 +613,7 @@ Returns: `Boolean` #### `invoke` -Invokes a remote tool. +Public API declaration invoke. ```norm String invoke(ToolCall call) @@ -667,75 +627,115 @@ The tool call. Returns: `String` -### `McpClient` +## `agent.org.sqlite.core` -Public API declaration McpClient. +Source: `org/sqlite/core/Codes.norm` + +### `Codes` ```norm -class McpClient implements McpToolProvider +interface Codes ``` -#### `McpClient` +## `agent.org.sqlite.core` -Creates an MCP client. +Source: `org/sqlite/core/CorePreparedStatement.norm` + +### `CorePreparedStatement` ```norm -McpClient(String endpoint, HttpClient client, Duration timeout) +class CorePreparedStatement extends JDBC4Statement implements Codes +``` + +#### `CorePreparedStatement` + +```norm +CorePreparedStatement(__JarBindingToken token) ``` Parameters: -- `endpoint`: `String` -The MCP endpoint. +- `token`: `__JarBindingToken` -- `client`: `HttpClient` -The HTTP client. +## `agent.org.sqlite.core` -- `timeout`: `Duration` -The request timeout. +Source: `org/sqlite/core/CoreStatement.norm` +### `CoreStatement` -#### `definitions` +```norm +class CoreStatement implements Codes +``` -Public API declaration definitions. +#### `CoreStatement` ```norm -List definitions() +CoreStatement(__JarBindingToken token) ``` -Returns: `List` +Parameters: -#### `supports` +- `token`: `__JarBindingToken` -Public API declaration supports. +## `agent.org.sqlite.jdbc3` + +Source: `org/sqlite/jdbc3/JDBC3PreparedStatement.norm` + +### `JDBC3PreparedStatement` ```norm -Boolean supports(String name) +class JDBC3PreparedStatement extends CorePreparedStatement implements Codes +``` + +#### `JDBC3PreparedStatement` + +```norm +JDBC3PreparedStatement(__JarBindingToken token) ``` Parameters: -- `name`: `String` -The tool name. +- `token`: `__JarBindingToken` +## `agent.org.sqlite.jdbc3` -Returns: `Boolean` +Source: `org/sqlite/jdbc3/JDBC3Statement.norm` -#### `invoke` +### `JDBC3Statement` -Public API declaration invoke. +```norm +class JDBC3Statement extends CoreStatement implements Codes +``` + +#### `JDBC3Statement` ```norm -String invoke(ToolCall call) +JDBC3Statement(__JarBindingToken token) ``` Parameters: -- `call`: `ToolCall` -The tool call. +- `token`: `__JarBindingToken` +## `agent.org.sqlite.jdbc4` -Returns: `String` +Source: `org/sqlite/jdbc4/JDBC4Statement.norm` + +### `JDBC4Statement` + +```norm +class JDBC4Statement extends JDBC3Statement implements Codes +``` + +#### `JDBC4Statement` + +```norm +JDBC4Statement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` ## `agent` diff --git a/agent.norm b/agent.norm index 1ffdc45..9dd9f01 100644 --- a/agent.norm +++ b/agent.norm @@ -94,14 +94,41 @@ private JsonValue logToolCall(ToolCall call) { private JsonValue generationRecord(GenerationEvent event) { Map fields = Map<>() String kind = switch event { - case RunStarted(String input) { fields.put(key: "input", value: JsonValue.String(value: input)) break "run_started" } - case ResponseStarted(String id) { fields.put(key: "id", value: JsonValue.String(value: id)) break "response_started" } - case MessageStarted(String id) { fields.put(key: "id", value: JsonValue.String(value: id)) break "message_started" } - case Text(String text) { fields.put(key: "text", value: JsonValue.String(value: text)) break "text" } - case MessageCompleted(Message message) { fields.put(key: "id", value: JsonValue.String(value: message.id)) fields.put(key: "text", value: JsonValue.String(value: message.text)) fields.put(key: "refusal", value: toJsonValue(value: message.refusal)) break "message_completed" } - case ToolCallReceived(ToolCall call) { fields.put(key: "call", value: logToolCall(call: call)) break "tool_call_received" } - case ToolStarted(ToolCall call) { fields.put(key: "call", value: logToolCall(call: call)) break "tool_started" } - case ToolCompleted(ToolResult result) { fields.put(key: "call", value: logToolCall(call: result.call)) fields.put(key: "output", value: JsonValue.String(value: result.output)) break "tool_completed" } + case RunStarted(String input) { + fields.put(key: "input", value: JsonValue.String(value: input)) + break "run_started" + } + case ResponseStarted(String id) { + fields.put(key: "id", value: JsonValue.String(value: id)) + break "response_started" + } + case MessageStarted(String id) { + fields.put(key: "id", value: JsonValue.String(value: id)) + break "message_started" + } + case Text(String text) { + fields.put(key: "text", value: JsonValue.String(value: text)) + break "text" + } + case MessageCompleted(Message message) { + fields.put(key: "id", value: JsonValue.String(value: message.id)) + fields.put(key: "text", value: JsonValue.String(value: message.text)) + fields.put(key: "refusal", value: toJsonValue(value: message.refusal)) + break "message_completed" + } + case ToolCallReceived(ToolCall call) { + fields.put(key: "call", value: logToolCall(call: call)) + break "tool_call_received" + } + case ToolStarted(ToolCall call) { + fields.put(key: "call", value: logToolCall(call: call)) + break "tool_started" + } + case ToolCompleted(ToolResult result) { + fields.put(key: "call", value: logToolCall(call: result.call)) + fields.put(key: "output", value: JsonValue.String(value: result.output)) + break "tool_completed" + } case ToolFailed(ToolFailure failure) { fields.put(key: "call", value: logToolCall(call: failure.call)) fields.put(key: "error", value: JsonValue.String(value: failure.error.message)) @@ -115,8 +142,14 @@ private JsonValue generationRecord(GenerationEvent event) { fields.put(key: "error", value: toJsonValue(value: response.errorMessage)) break "response_completed" } - case Failed(Exception error) { fields.put(key: "error", value: JsonValue.String(value: error.message)) break "failed" } - case RunCompleted(Response response) { fields.put(key: "id", value: JsonValue.String(value: response.id)) break "run_completed" } + case Failed(Exception error) { + fields.put(key: "error", value: JsonValue.String(value: error.message)) + break "failed" + } + case RunCompleted(Response response) { + fields.put(key: "id", value: JsonValue.String(value: response.id)) + break "run_completed" + } } fields.put(key: "type", value: JsonValue.String(value: kind)) return JsonValue.Object(fields: fields) @@ -277,16 +310,16 @@ public class Agent { fields.put(key: "model", value: JsonValue.String(value: request.model)) fields.put(key: "instructions", value: toJsonValue(value: request.instructions)) fields.put(key: "input", value: JsonValue.Array(values: conversation.items())) - List tools = [] + List toolItems = [] for ToolDefinition definition : definitions { Map tool = Map<>() tool.put(key: "name", value: JsonValue.String(value: definition.name)) tool.put(key: "description", value: JsonValue.String(value: definition.description)) tool.put(key: "parameters", value: definition.parameters) tool.put(key: "strict", value: JsonValue.Boolean(value: definition.strict)) - tools.add(JsonValue.Object(fields: tool)) + toolItems.add(JsonValue.Object(fields: tool)) } - fields.put(key: "tools", value: JsonValue.Array(values: tools)) + fields.put(key: "tools", value: JsonValue.Array(values: toolItems)) fields.put(key: "maximumOutputTokens", value: toJsonValue(value: maximumOutputTokens)) this.record(session: conversation, event: JsonValue.Object(fields: fields)) } diff --git a/persist.norm b/persist.norm index c4b34e5..808d6dc 100644 --- a/persist.norm +++ b/persist.norm @@ -24,7 +24,9 @@ public interface PersistProvider { @Document(description: "Appends UTF-8 JSON Lines to a generated file per session.") public class JsonPersist implements PersistProvider { private String directory - @Document(description: "Creates a JSON Lines provider.") JsonPersist(@Document(description: "Output directory, default logs.") String directory = "logs") { this.directory = filenameUtilsConcat(arg0: workingDirectory().value, arg1: directory)!! } + @Document(description: "Creates a JSON Lines provider.") JsonPersist(@Document(description: "Output directory, default logs.") String directory = "logs") { + this.directory = filenameUtilsConcat(arg0: workingDirectory().value, arg1: directory)!! + } @Document(description: "Appends and flushes one JSON event.") public Void append(@Document(description: "The conversation identifier.") String sessionId, @Document(description: "The event object.") JsonValue event) { @@ -38,7 +40,9 @@ public class JsonPersist implements PersistProvider { @Document(description: "Records ordered events in a generated SQLite database per conversation.") public class SqlitePersist implements PersistProvider { private String directory - @Document(description: "Creates a SQLite provider.") SqlitePersist(@Document(description: "Output directory, default logs.") String directory = "logs") { this.directory = filenameUtilsConcat(arg0: workingDirectory().value, arg1: directory)!! } + @Document(description: "Creates a SQLite provider.") SqlitePersist(@Document(description: "Output directory, default logs.") String directory = "logs") { + this.directory = filenameUtilsConcat(arg0: workingDirectory().value, arg1: directory)!! + } @Document(description: "Commits one event in SQLite.") public Void append(@Document(description: "The conversation identifier.") String sessionId, @Document(description: "The event object.") JsonValue event) { diff --git a/tests/persist_conversation.py b/tests/persist_conversation.py new file mode 100644 index 0000000..96e204e --- /dev/null +++ b/tests/persist_conversation.py @@ -0,0 +1,59 @@ +import json +import shutil +import sqlite3 +import subprocess +from contextlib import closing +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from tempfile import TemporaryDirectory +from threading import Thread + +requests = [] + + +class Model(BaseHTTPRequestHandler): + def do_POST(self): + requests.append(json.loads(self.rfile.read(int(self.headers["Content-Length"])))) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + chunk = {"id": "chatcmpl-test", "model": "fixture", "choices": [ + {"index": 0, "delta": {"role": "assistant", "content": "回复"}, "finish_reason": "stop"} + ]} + self.wfile.write(f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode()) + + +with HTTPServer(("127.0.0.1", 0), Model) as model, TemporaryDirectory() as temporary: + Thread(target=model.serve_forever, daemon=True).start() + project = Path(temporary) / "agent" + project.mkdir() + for source in Path(__file__).resolve().parents[1].glob("*.norm"): + shutil.copy(source, project) + entry = project / "main.norm" + entry.write_text(f"""package agent +import agent.Agent +import agent.JsonPersist +import agent.SqlitePersist + +Void main() {{ + var agent = Agent(apiKey: "test-secret", model: "fixture", + endpoint: "http://127.0.0.1:{model.server_port}/chat/completions", + persist: [JsonPersist(), SqlitePersist("logs")]) + agent.chat("第一轮") + agent.chat("第二轮") +}} +""", encoding="utf-8") + for _ in range(2): + subprocess.run(["norm", "run", str(entry)], cwd=project, check=True, timeout=120) + files = list((project / "logs").glob("*.jsonl")) + assert len(files) == 2 + assert [m["content"] for m in requests[-1]["messages"]] == ["第一轮", "回复", "第二轮"] + for path in files: + text = path.read_text(encoding="utf-8") + events = [json.loads(line) for line in text.splitlines()] + assert "test-secret" not in text + assert [e["input"] for e in events if e["type"] == "run_started"] == ["第一轮", "第二轮"] + assert [e["text"] for e in events if e["type"] == "text"] == ["回复", "回复"] + with closing(sqlite3.connect(path.with_suffix(".sqlite"))) as db: + assert events == [json.loads(row[0]) for row in db.execute("SELECT event FROM events ORDER BY sequence")] + model.shutdown()