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..86264f6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,12 +11,15 @@ 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 + - name: Verify conversation logs + run: python3 tests/persist_conversation.py 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..ba0d466 100644 --- a/README.md +++ b/README.md @@ -2,4 +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. + +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 bf5495d..e3b095b 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` @@ -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) +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,13 +165,16 @@ The MCP providers. - `headers`: `HeaderMap` Additional model HTTP headers forwarded to the protocol client. +- `persist`: `List` +Conversation log providers, empty by default. + #### `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, List persist) ``` Parameters: @@ -205,6 +218,9 @@ The context provider. - `mcp`: `List` The MCP providers. +- `persist`: `List` +Conversation log providers, empty by default. + #### `skill` @@ -291,7 +307,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,12 +354,155 @@ The run-complete callback. - `onEvent`: `Function?` The event callback. -- `session`: `AgentSession` +- `session`: `AgentSession?` The session to use. Returns: `Response` +## `agent.internal` + +Source: `internal/Connection.norm` + +### `Connection` + +```norm +class Connection +``` + +#### `Connection` + +```norm +Connection(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +#### `close` + +```norm +Void close() +``` + +Returns: `Void` + +## `agent.internal` + +Source: `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: `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` Source: `mcp.norm` @@ -468,6 +627,229 @@ The tool call. Returns: `String` +## `agent.org.sqlite.core` + +Source: `org/sqlite/core/Codes.norm` + +### `Codes` + +```norm +interface Codes +``` + +## `agent.org.sqlite.core` + +Source: `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: `org/sqlite/core/CoreStatement.norm` + +### `CoreStatement` + +```norm +class CoreStatement implements Codes +``` + +#### `CoreStatement` + +```norm +CoreStatement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +## `agent.org.sqlite.jdbc3` + +Source: `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: `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: `org/sqlite/jdbc4/JDBC4Statement.norm` + +### `JDBC4Statement` + +```norm +class JDBC4Statement extends JDBC3Statement implements Codes +``` + +#### `JDBC4Statement` + +```norm +JDBC4Statement(__JarBindingToken token) +``` + +Parameters: + +- `token`: `__JarBindingToken` + +## `agent` + +Source: `persist.norm` + +### `PersistProvider` + +Receives ordered events for a conversation. The session identifier is generated by the library. + +```norm +interface PersistProvider +``` + +#### `append` + +Appends one event. + +```norm +Void append(String sessionId, JsonValue event) +``` + +Parameters: + +- `sessionId`: `String` +The conversation identifier. + +- `event`: `JsonValue` +The event object. + + +Returns: `Void` + +### `JsonPersist` + +Appends UTF-8 JSON Lines to a generated file per session. + +```norm +class JsonPersist implements PersistProvider +``` + +#### `JsonPersist` + +Creates a JSON Lines provider. + +```norm +JsonPersist(String directory) +``` + +Parameters: + +- `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` + +### `SqlitePersist` + +Records ordered events in a generated SQLite database per conversation. + +```norm +class SqlitePersist implements PersistProvider +``` + +#### `SqlitePersist` + +Creates a SQLite provider. + +```norm +SqlitePersist(String directory) +``` + +Parameters: + +- `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` diff --git a/agent.norm b/agent.norm index fa1a433..9dd9f01 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,82 @@ 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 private List> tools @@ -100,8 +188,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: "Conversation log providers, empty by default.") List persist = [] ) { + 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 @@ -127,8 +218,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: "Conversation log providers, empty by default.") List persist = [] ) { + 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 @@ -185,29 +279,53 @@ 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) + 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)) - 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()), - 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 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)) + toolItems.add(JsonValue.Object(fields: tool)) + } + 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)) + } + var response = client.exchange(request: request, 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 +336,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 +355,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() } } } @@ -252,6 +370,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 40893d4..33184ad 100644 --- a/module.norm +++ b/module.norm @@ -1,9 +1,12 @@ Module module() { - return module(name: "agent", version: 6, - dependencies: [exportedDependency(repository: "github", name: "openai", version: 7)], - exports: ["agent", "skill", "session", "mcp"]) + return module(name: "agent", version: 7, + 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..808d6dc --- /dev/null +++ b/persist.norm @@ -0,0 +1,63 @@ +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/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/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() 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") +}