locode_protocol/lib.rs
1//! locode-protocol — shared, provider-neutral types with no I/O.
2//!
3//! Two concerns live here:
4//! - the **conversation model** (4-role, Anthropic-shaped content blocks — [ADR-0013]),
5//! which the loop accumulates and hands to a `Provider`; and
6//! - the **report envelope** ([ADR-0009]), the single JSON artifact `locode-exec` prints.
7//!
8//! Types are Rust-native and serialize with `serde` for our own persistence/reporting;
9//! conversion to a specific provider wire (Anthropic, OpenAI) lives in each `Provider`
10//! impl, not here.
11//!
12//! [ADR-0013]: https://github.com/Luolc/locode-core/blob/main/docs/decisions/ADR-0013-conversation-protocol.md
13//! [ADR-0009]: https://github.com/Luolc/locode-core/blob/main/docs/decisions/ADR-0009-headless-io-contract.md
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18// ============================ Conversation model ============================
19
20/// A full conversation: one uniform stream of role-tagged messages (ADR-0013).
21///
22/// There is no separate `system` field — a [`Role::System`] message *is* the base
23/// prompt; the Anthropic wire hoists leading System messages into its top-level
24/// `system` param.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Conversation {
27 /// The ordered turns of the conversation.
28 pub messages: Vec<Message>,
29}
30
31/// One message: a role plus an ordered list of content blocks.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct Message {
34 /// Who this message is from.
35 pub role: Role,
36 /// The message's content, as typed blocks.
37 pub content: Vec<ContentBlock>,
38}
39
40/// The author of a message (ADR-0013).
41///
42/// `System` is the static base identity; `Developer` is client-injected instructions
43/// and dynamic context. On the wire, `System` maps to Anthropic's top-level `system`
44/// (or an OpenAI `system` message), while `Developer` maps to an Anthropic
45/// mid-conversation `system` message (or an OpenAI `developer` message).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Role {
49 /// Immutable base identity and safety/policy — the "constitution".
50 System,
51 /// App-author instructions and dynamically injected operational context.
52 Developer,
53 /// The human's turns; also carries [`ContentBlock::ToolResult`] blocks.
54 User,
55 /// The model's turns: text, thinking, and tool-use blocks.
56 Assistant,
57}
58
59/// A typed piece of message content, modeled on Anthropic's content blocks.
60///
61/// `#[non_exhaustive]` so new block kinds can be added without a breaking change;
62/// only `Text`, `ToolUse`, and `ToolResult` are exercised in v0.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum ContentBlock {
67 /// Plain text.
68 Text {
69 /// The text content.
70 text: String,
71 },
72 /// An image (multimodal).
73 Image {
74 /// Where the image bytes come from.
75 source: ImageSource,
76 },
77 /// Assistant reasoning, unified across wires (ADR-0013 amendment
78 /// 2026-07-19; replaces the earlier `Thinking`/`RedactedThinking` pair).
79 ///
80 /// [`ReasoningFormat`] selects the replay contract; each wire's build
81 /// replays only its own format(s) and **drops foreign formats** (a session
82 /// never crosses wires, so nothing is lost).
83 Reasoning {
84 /// Which encoding/replay contract this data follows.
85 format: ReasoningFormat,
86 /// Human-readable reasoning: the full text (`anthropic`), empty
87 /// (`anthropic_redacted`), the summary (`openai_responses`), or the
88 /// captured text (`text_only`).
89 text: String,
90 /// Anthropic's validator over `text` (`anthropic` format only).
91 signature: Option<String>,
92 /// The wire's opaque replay payload, replayed verbatim and never
93 /// interpreted: the whole Responses reasoning item
94 /// (`openai_responses`) or Anthropic's redacted-thinking data
95 /// (`anthropic_redacted`).
96 payload: Option<Value>,
97 },
98 /// A tool call emitted by the assistant.
99 ToolUse {
100 /// Provider-assigned id, paired with a later [`ContentBlock::ToolResult`].
101 id: String,
102 /// The client-facing tool name (the harness pack's name).
103 name: String,
104 /// The tool arguments as a JSON value.
105 input: Value,
106 },
107 /// The result of a tool call, carried in a [`Role::User`] message.
108 ToolResult {
109 /// The id of the [`ContentBlock::ToolUse`] this answers.
110 tool_use_id: String,
111 /// The result content (text and/or images).
112 content: Vec<ResultChunk>,
113 /// Whether the tool call failed (a soft error the model can recover from).
114 is_error: bool,
115 },
116}
117
118/// A single chunk of a tool result (a restricted set of block kinds).
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120#[serde(tag = "type", rename_all = "snake_case")]
121pub enum ResultChunk {
122 /// Text output.
123 Text {
124 /// The text content.
125 text: String,
126 },
127 /// Image output (e.g. a screenshot).
128 Image {
129 /// Where the image bytes come from.
130 source: ImageSource,
131 },
132}
133
134/// The source of an image block.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "type", rename_all = "snake_case")]
137pub enum ImageSource {
138 /// Inline base64-encoded bytes.
139 Base64 {
140 /// The MIME type, e.g. `image/png`.
141 media_type: String,
142 /// The base64-encoded image data.
143 data: String,
144 },
145 /// A URL the provider fetches.
146 Url {
147 /// The image URL.
148 url: String,
149 },
150}
151
152/// The encoding/replay contract of a [`ContentBlock::Reasoning`] block.
153///
154/// Named after the wire's own vocabulary (Responses reasoning items tag
155/// themselves `format: "openai-responses-v1"`). Serialized values deliberately
156/// echo the `api_schema` strings so a trace reader maps block → wire at a
157/// glance.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160#[non_exhaustive]
161pub enum ReasoningFormat {
162 /// Anthropic extended thinking: full `text` + `signature` validator.
163 Anthropic,
164 /// Anthropic `redacted_thinking`: encrypted `payload`, empty `text`.
165 AnthropicRedacted,
166 /// OpenAI Responses reasoning item: summary in `text`, the WHOLE item in
167 /// `payload` (id + summary + `encrypted_content` + `format` + future fields).
168 OpenAiResponses,
169 /// Capture-only reasoning with no replay contract (e.g. Chat Completions
170 /// gateway extensions). Never replayed by any wire.
171 TextOnly,
172}
173
174// ============================== Report envelope ==============================
175
176/// The single JSON document `locode-exec` prints to stdout (ADR-0009).
177///
178/// `schema_version` is frozen at `1`; changing the envelope shape is a deliberate,
179/// versioned change (see the golden test).
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct Report {
182 /// Envelope schema version. Always `1` for this contract.
183 pub schema_version: u32,
184 /// The terminal state of the run.
185 pub status: Status,
186 /// The harness pack the run used (e.g. `grok`).
187 pub harness: String,
188 /// The wire schema the run used (e.g. `anthropic`) — the provider's `api_schema()`.
189 /// Names the request/response protocol shape, not a gateway/endpoint.
190 pub api_schema: String,
191 /// The assistant's final text message, if the run completed with one.
192 pub final_message: Option<String>,
193 /// A schema-constrained task answer, if one was requested (`--json-schema`).
194 pub structured_output: Option<Value>,
195 /// How many sample→dispatch→append turns the loop ran.
196 pub turns: u32,
197 /// A record of every tool call made during the run.
198 pub tool_calls: Vec<ToolCallRecord>,
199 /// Token accounting for the run.
200 pub usage: Usage,
201 /// The session identifier.
202 pub session_id: String,
203 /// The final model stop reason (`"end_turn"`, `"max_tokens"`, …), when a
204 /// completion was received (ADR-0009 amendment 2026-07-19): lets an eval
205 /// pipeline distinguish "model finished" from "model got truncated"
206 /// without re-reading the trace.
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub stop_reason: Option<String>,
209 /// A fatal error message, if the run ended in `status == error`/`model_error`.
210 pub error: Option<String>,
211}
212
213/// The terminal state of a run. Serializes to the exact strings in ADR-0009.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum Status {
217 /// The model finished with a text answer and no further tool calls.
218 Completed,
219 /// The max-turns ceiling was hit.
220 MaxTurns,
221 /// A provider/network error after bounded retry.
222 ModelError,
223 /// A fatal (`Tool`/host) error aborted the run.
224 Error,
225}
226
227/// A report-side record of one tool call (distinct from the in-conversation
228/// [`ContentBlock::ToolUse`]): the structured `output` view, not the model-facing text.
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct ToolCallRecord {
231 /// The tool-call id (matches the conversation's `tool_use` id).
232 pub id: String,
233 /// The client-facing tool name the model called.
234 pub name: String,
235 /// The canonical `ToolKind` tag for cross-pack A/B alignment (e.g. `shell`).
236 pub kind: String,
237 /// The arguments the model supplied.
238 pub args: Value,
239 /// Whether the call succeeded.
240 pub ok: bool,
241 /// The structured output of the call (the report view, not `prompt_text`).
242 pub output: Value,
243}
244
245/// Token accounting parsed from the provider's terminal usage event.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
247pub struct Usage {
248 /// Input (prompt) tokens.
249 pub input_tokens: u64,
250 /// Output (completion) tokens.
251 pub output_tokens: u64,
252 /// Tokens served from the prompt cache. **`None` = this wire/provider does
253 /// not report the counter; `Some(0)` = reported as zero** (a real signal:
254 /// no cache hit). ADR-0009 amendment 2026-07-19 — zero-as-N/A rejected.
255 pub cache_read_tokens: Option<u64>,
256 /// Tokens written to the prompt cache (`None` on wires that never report
257 /// writes, e.g. the OpenAI family).
258 pub cache_creation_tokens: Option<u64>,
259 /// Reasoning/thinking tokens (`None` on wires that fold them into
260 /// `output_tokens`, e.g. Anthropic).
261 pub reasoning_tokens: Option<u64>,
262}
263
264/// `Some+Some` sums; `None` is the identity — a run total is `None` only if no
265/// turn ever reported the counter.
266fn add_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
267 match (a, b) {
268 (Some(x), Some(y)) => Some(x + y),
269 (Some(x), None) | (None, Some(x)) => Some(x),
270 (None, None) => None,
271 }
272}
273
274impl std::ops::AddAssign for Usage {
275 /// Accumulate another turn's usage field-wise (the engine sums across turns).
276 fn add_assign(&mut self, rhs: Self) {
277 self.input_tokens += rhs.input_tokens;
278 self.output_tokens += rhs.output_tokens;
279 self.cache_read_tokens = add_opt(self.cache_read_tokens, rhs.cache_read_tokens);
280 self.cache_creation_tokens = add_opt(self.cache_creation_tokens, rhs.cache_creation_tokens);
281 self.reasoning_tokens = add_opt(self.reasoning_tokens, rhs.reasoning_tokens);
282 }
283}
284
285// ================================= Tool spec =================================
286
287/// A provider-neutral tool spec: name + description + args JSON Schema.
288///
289/// This is the wire-agnostic representation a harness pack produces (from a
290/// `Registry`) and a `Provider` wire maps onto its own tool format (e.g. Anthropic's
291/// `{name, description, input_schema}` vs OpenAI's `{type:"function", function:{…}}`).
292/// It lives in `locode-protocol` because both `locode-tools` (which builds it) and
293/// `locode-provider` (which consumes it via `ConversationRequest`) need it, and the
294/// dependency graph forbids `provider → tools`.
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296pub struct ToolSpec {
297 /// The model-facing wire name (the harness pack's name for the tool).
298 pub name: String,
299 /// The tool description offered to the model.
300 pub description: String,
301 /// How the tool's input is specified to the model (ADR-0003 amendment
302 /// 2026-07-19; replaces the bare `parameters: Value`).
303 pub input: ToolInputFormat,
304}
305
306/// How a tool's input is specified: a JSON-schema function tool, or a freeform
307/// tool whose raw-text input is constrained by a server-side grammar (OpenAI
308/// Responses `custom` tools — codex's `apply_patch`). Exactly one of the two —
309/// an enum, not optional fields, so invalid states are unrepresentable.
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311#[serde(tag = "type", rename_all = "snake_case")]
312pub enum ToolInputFormat {
313 /// A JSON-schema function tool (every tool today).
314 JsonSchema {
315 /// The derived JSON Schema for the tool's arguments.
316 parameters: Value,
317 },
318 /// A freeform tool: raw text constrained by a grammar. On wires without
319 /// custom-tool support it degrades to a `{"input": string}` function tool;
320 /// the raw text reaches the tool identically either way.
321 Freeform {
322 /// The grammar language.
323 syntax: GrammarSyntax,
324 /// The grammar source, verbatim.
325 definition: String,
326 },
327}
328
329/// The grammar language of a [`ToolInputFormat::Freeform`] tool.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum GrammarSyntax {
333 /// A Lark grammar (codex `apply_patch.lark`).
334 Lark,
335 /// A regular expression.
336 Regex,
337}
338
339// ========================= Streaming events (stream-json) =========================
340
341/// One event in the `stream-json` trajectory (one JSON object per line).
342///
343/// The stream is a **self-sufficient, replayable source of the whole run**: `Init`
344/// carries the base prompt + tool specs + model, and each [`Event::Message`] carries a
345/// full turn — so [`reconstruct_conversation`] rebuilds the entire history with nothing
346/// else. (Claude Code's stream omits `system`/`tools`, forcing a proxy capture to
347/// recover them; `Init` closes that gap.) `#[non_exhaustive]` so events can be added
348/// (e.g. per-turn markers) without a breaking change.
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(tag = "type", rename_all = "snake_case")]
351#[non_exhaustive]
352pub enum Event {
353 /// Emitted once at the start — everything needed to reconstruct context.
354 Init {
355 /// The session identifier.
356 session_id: String,
357 /// The harness pack in use (e.g. `grok`).
358 harness: String,
359 /// The wire schema in use (e.g. `anthropic`) — the provider's `api_schema()`.
360 api_schema: String,
361 /// The model id.
362 model: String,
363 /// The working directory.
364 cwd: String,
365 /// The max-turns ceiling; absent = unlimited (the default — ADR-0005
366 /// amendment 2026-07-18).
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 max_turns: Option<u32>,
369 /// The base `System` + `Developer` messages (prompt + capabilities).
370 preamble: Vec<Message>,
371 /// The tool specs offered to the model (name + JSON schema), as JSON values.
372 tools: Vec<Value>,
373 },
374 /// A full message appended to the history (the trace): role + content blocks.
375 Message {
376 /// The appended message.
377 message: Message,
378 },
379 /// The terminal event: the final report (identical to `--output-format json`).
380 Result {
381 /// The run's report envelope.
382 report: Report,
383 },
384 /// A non-terminal error note (e.g. a retry); terminal errors ride in [`Event::Result`].
385 Error {
386 /// A human-readable message.
387 message: String,
388 },
389}
390
391/// Reconstruct the full [`Conversation`] from a `stream-json` event trajectory.
392///
393/// `Init.preamble` seeds the `System`/`Developer` prompt and each [`Event::Message`]
394/// appends a turn; `Result`/`Error` events are run metadata, not part of the history.
395/// This is the inverse of what `locode-exec` emits — the stream is a complete source.
396#[must_use]
397pub fn reconstruct_conversation(events: &[Event]) -> Conversation {
398 let mut messages = Vec::new();
399 for event in events {
400 match event {
401 Event::Init { preamble, .. } => messages.extend(preamble.iter().cloned()),
402 Event::Message { message } => messages.push(message.clone()),
403 Event::Result { .. } | Event::Error { .. } => {}
404 }
405 }
406 Conversation { messages }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use serde_json::json;
413
414 #[test]
415 fn conversation_round_trips_all_roles_and_tool_pairing() {
416 let call_id = "call_42";
417 let conversation = Conversation {
418 messages: vec![
419 Message {
420 role: Role::System,
421 content: vec![ContentBlock::Text {
422 text: "You are locode.".into(),
423 }],
424 },
425 Message {
426 role: Role::Developer,
427 content: vec![ContentBlock::Text {
428 text: "Available tools: run_terminal_command.".into(),
429 }],
430 },
431 Message {
432 role: Role::User,
433 content: vec![ContentBlock::Text {
434 text: "run echo hi".into(),
435 }],
436 },
437 Message {
438 role: Role::Assistant,
439 content: vec![
440 ContentBlock::Text {
441 text: "Sure.".into(),
442 },
443 ContentBlock::ToolUse {
444 id: call_id.into(),
445 name: "run_terminal_command".into(),
446 input: json!({ "command": "echo hi" }),
447 },
448 ],
449 },
450 Message {
451 role: Role::User,
452 content: vec![ContentBlock::ToolResult {
453 tool_use_id: call_id.into(),
454 content: vec![ResultChunk::Text {
455 text: "hi\n".into(),
456 }],
457 is_error: false,
458 }],
459 },
460 ],
461 };
462
463 let wire = serde_json::to_string(&conversation).expect("serialize");
464 let back: Conversation = serde_json::from_str(&wire).expect("deserialize");
465 assert_eq!(
466 conversation, back,
467 "conversation did not round-trip losslessly"
468 );
469
470 // The tool_use id and tool_result tool_use_id are the pairing link (ADR-0004).
471 let ContentBlock::ToolUse { id, .. } = &conversation.messages[3].content[1] else {
472 panic!("expected a tool_use block");
473 };
474 let ContentBlock::ToolResult { tool_use_id, .. } = &conversation.messages[4].content[0]
475 else {
476 panic!("expected a tool_result block");
477 };
478 assert_eq!(id, tool_use_id);
479 }
480
481 #[test]
482 fn content_block_uses_anthropic_style_type_tags() {
483 let block = ContentBlock::Text { text: "hi".into() };
484 assert_eq!(
485 serde_json::to_value(&block).unwrap(),
486 json!({ "type": "text", "text": "hi" })
487 );
488 }
489
490 #[test]
491 fn status_serializes_to_adr_0009_strings() {
492 let cases = [
493 (Status::Completed, "completed"),
494 (Status::MaxTurns, "max_turns"),
495 (Status::ModelError, "model_error"),
496 (Status::Error, "error"),
497 ];
498 for (status, want) in cases {
499 assert_eq!(serde_json::to_value(status).unwrap(), json!(want));
500 }
501 }
502
503 fn minimal_report() -> Report {
504 Report {
505 schema_version: 1,
506 status: Status::Completed,
507 harness: "grok".into(),
508 api_schema: "anthropic".into(),
509 final_message: Some("done".into()),
510 structured_output: None,
511 turns: 1,
512 tool_calls: vec![],
513 usage: Usage::default(),
514 session_id: "sess-1".into(),
515 stop_reason: None,
516 error: None,
517 }
518 }
519
520 /// The JSONL event stream is a self-sufficient source: `init.preamble` + every
521 /// `message` event reconstruct the entire conversation (system/developer included).
522 #[test]
523 fn events_reconstruct_full_conversation() {
524 let system = Message {
525 role: Role::System,
526 content: vec![ContentBlock::Text {
527 text: "base".into(),
528 }],
529 };
530 let developer = Message {
531 role: Role::Developer,
532 content: vec![ContentBlock::Text {
533 text: "capabilities".into(),
534 }],
535 };
536 let user = Message {
537 role: Role::User,
538 content: vec![ContentBlock::Text {
539 text: "run echo hi".into(),
540 }],
541 };
542 let assistant = Message {
543 role: Role::Assistant,
544 content: vec![ContentBlock::ToolUse {
545 id: "c1".into(),
546 name: "run_terminal_command".into(),
547 input: json!({ "command": "echo hi" }),
548 }],
549 };
550 let tool_result = Message {
551 role: Role::User,
552 content: vec![ContentBlock::ToolResult {
553 tool_use_id: "c1".into(),
554 content: vec![ResultChunk::Text {
555 text: "hi\n".into(),
556 }],
557 is_error: false,
558 }],
559 };
560
561 let events = vec![
562 Event::Init {
563 session_id: "sess-1".into(),
564 harness: "grok".into(),
565 api_schema: "anthropic".into(),
566 model: "claude-opus-4-8".into(),
567 cwd: "/repo".into(),
568 max_turns: Some(30),
569 preamble: vec![system.clone(), developer.clone()],
570 tools: vec![json!({ "name": "run_terminal_command" })],
571 },
572 Event::Message {
573 message: user.clone(),
574 },
575 Event::Message {
576 message: assistant.clone(),
577 },
578 Event::Message {
579 message: tool_result.clone(),
580 },
581 Event::Result {
582 report: minimal_report(),
583 },
584 ];
585
586 // JSONL round-trip: one JSON object per line, parsed back losslessly.
587 let jsonl = events
588 .iter()
589 .map(|e| serde_json::to_string(e).unwrap())
590 .collect::<Vec<_>>()
591 .join("\n");
592 let parsed: Vec<Event> = jsonl
593 .lines()
594 .map(|l| serde_json::from_str(l).unwrap())
595 .collect();
596 assert_eq!(parsed, events, "events did not round-trip through JSONL");
597
598 // Reconstruction yields the FULL history, system/developer included.
599 let rebuilt = reconstruct_conversation(&parsed);
600 assert_eq!(
601 rebuilt,
602 Conversation {
603 messages: vec![system, developer, user, assistant, tool_result]
604 }
605 );
606 }
607
608 #[test]
609 fn event_uses_snake_case_type_tags() {
610 let event = Event::Message {
611 message: Message {
612 role: Role::User,
613 content: vec![],
614 },
615 };
616 assert_eq!(
617 serde_json::to_value(&event).unwrap()["type"],
618 json!("message")
619 );
620 }
621}