flowproof 0.2.4__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {flowproof-0.2.4 → flowproof-0.3.0}/Cargo.lock +8 -7
  2. {flowproof-0.2.4 → flowproof-0.3.0}/Cargo.toml +1 -1
  3. {flowproof-0.2.4 → flowproof-0.3.0}/PKG-INFO +1 -1
  4. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/Cargo.toml +3 -0
  5. flowproof-0.3.0/crates/flowproof-adapters/src/agent_proxy.rs +550 -0
  6. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/lib.rs +6 -0
  7. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/sap_com.rs +71 -14
  8. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/vision.rs +445 -101
  9. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/web.rs +126 -0
  10. flowproof-0.3.0/crates/flowproof-agent/src/agent_steps.rs +330 -0
  11. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/lib.rs +1 -0
  12. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/recorder.rs +333 -26
  13. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/rules.rs +667 -19
  14. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/spec.rs +294 -6
  15. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/src/lib.rs +49 -8
  16. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/examples_resolve.rs +4 -4
  17. flowproof-0.3.0/crates/flowproof-cli/tests/notepad_e2e.rs +249 -0
  18. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +13 -6
  19. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/suite_env_from.rs +96 -0
  20. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/support/sap_simulator.py +21 -17
  21. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/vision_pipeline.rs +99 -0
  22. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/web_e2e.rs +173 -0
  23. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/app.rs +525 -4
  24. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/lib.rs +54 -2
  25. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/mock.rs +81 -1
  26. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/window.rs +74 -2
  27. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-python/src/lib.rs +2 -2
  28. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/src/lib.rs +341 -39
  29. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/tests/replay_calc.rs +561 -1
  30. flowproof-0.3.0/crates/flowproof-trace/src/cassette.rs +504 -0
  31. flowproof-0.3.0/crates/flowproof-trace/src/cassette_diff.rs +523 -0
  32. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/src/format.rs +32 -0
  33. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/src/lib.rs +3 -0
  34. flowproof-0.3.0/crates/flowproof-trace/src/toolcalls.rs +499 -0
  35. {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/__init__.py +1 -1
  36. {flowproof-0.2.4 → flowproof-0.3.0}/pyproject.toml +1 -1
  37. flowproof-0.2.4/crates/flowproof-cli/tests/notepad_e2e.rs +0 -96
  38. {flowproof-0.2.4 → flowproof-0.3.0}/README.md +0 -0
  39. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/Cargo.toml +0 -0
  40. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/author.rs +0 -0
  41. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/clarify.rs +0 -0
  42. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/heal.rs +0 -0
  43. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/llm.rs +0 -0
  44. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/Cargo.toml +0 -0
  45. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/src/main.rs +0 -0
  46. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  47. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  48. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  49. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  50. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  51. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  52. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  53. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  54. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  55. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/Cargo.toml +0 -0
  56. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/backend.rs +0 -0
  57. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/gdi.rs +0 -0
  58. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/oob.rs +0 -0
  59. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/recording.rs +0 -0
  60. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/redact.rs +0 -0
  61. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/visual.rs +0 -0
  62. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-python/Cargo.toml +0 -0
  63. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/Cargo.toml +0 -0
  64. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/src/report.rs +0 -0
  65. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/Cargo.toml +0 -0
  66. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  67. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/src/secret.rs +0 -0
  68. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  69. {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  70. {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/cli.py +0 -0
  71. {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/flow.py +0 -0
  72. {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/mcp_server.py +0 -0
  73. {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/py.typed +0 -0
@@ -722,10 +722,11 @@ dependencies = [
722
722
 
723
723
  [[package]]
724
724
  name = "flowproof-adapters"
725
- version = "0.2.4"
725
+ version = "0.3.0"
726
726
  dependencies = [
727
727
  "anyhow",
728
728
  "flowproof-driver",
729
+ "flowproof-trace",
729
730
  "headless_chrome",
730
731
  "image",
731
732
  "ocrs",
@@ -738,7 +739,7 @@ dependencies = [
738
739
 
739
740
  [[package]]
740
741
  name = "flowproof-agent"
741
- version = "0.2.4"
742
+ version = "0.3.0"
742
743
  dependencies = [
743
744
  "chrono",
744
745
  "flowproof-driver",
@@ -753,7 +754,7 @@ dependencies = [
753
754
 
754
755
  [[package]]
755
756
  name = "flowproof-cli"
756
- version = "0.2.4"
757
+ version = "0.3.0"
757
758
  dependencies = [
758
759
  "ab_glyph",
759
760
  "clap",
@@ -771,7 +772,7 @@ dependencies = [
771
772
 
772
773
  [[package]]
773
774
  name = "flowproof-driver"
774
- version = "0.2.4"
775
+ version = "0.3.0"
775
776
  dependencies = [
776
777
  "image",
777
778
  "postgres",
@@ -785,7 +786,7 @@ dependencies = [
785
786
 
786
787
  [[package]]
787
788
  name = "flowproof-python"
788
- version = "0.2.4"
789
+ version = "0.3.0"
789
790
  dependencies = [
790
791
  "flowproof-agent",
791
792
  "flowproof-cli",
@@ -797,7 +798,7 @@ dependencies = [
797
798
 
798
799
  [[package]]
799
800
  name = "flowproof-replay"
800
- version = "0.2.4"
801
+ version = "0.3.0"
801
802
  dependencies = [
802
803
  "chrono",
803
804
  "flowproof-agent",
@@ -811,7 +812,7 @@ dependencies = [
811
812
 
812
813
  [[package]]
813
814
  name = "flowproof-trace"
814
- version = "0.2.4"
815
+ version = "0.3.0"
815
816
  dependencies = [
816
817
  "jsonschema",
817
818
  "serde",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/flowproof-driver", "crates/flowproof-trace", "crates/flowproof-replay", "crates/flowproof-agent", "crates/flowproof-adapters", "crates/flowproof-cli", "crates/flowproof-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.4"
6
+ version = "0.3.0"
7
7
  edition = "2021"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/automators-com/flowproof"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowproof
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -14,10 +14,13 @@ web = ["dep:headless_chrome"]
14
14
  # Pixels-only (Citrix/RDP) driving: OCR perception via ocrs/rten, model
15
15
  # download via ureq.
16
16
  vision = ["dep:ocrs", "dep:rten", "dep:ureq"]
17
+ # Agent-boundary testing: the model-API proxy that serves a cassette.
18
+ agent = []
17
19
 
18
20
  [dependencies]
19
21
  anyhow = { workspace = true }
20
22
  flowproof-driver = { workspace = true }
23
+ flowproof-trace = { workspace = true }
21
24
  headless_chrome = { version = "1", optional = true }
22
25
  serde_json = "1"
23
26
  image = { version = "0.25.10", default-features = false, features = ["png"] }
@@ -0,0 +1,550 @@
1
+ //! The model-boundary proxy: an OpenAI-compatible chat-completions
2
+ //! endpoint that answers from a recorded cassette.
3
+ //!
4
+ //! This is what makes an agent test deterministic and free. The system
5
+ //! under test is launched with its API base URL pointed here, so it keeps
6
+ //! making the same HTTP calls it always makes, to what it believes is the
7
+ //! model. Nothing about the agent changes; the nondeterminism is removed
8
+ //! from underneath it.
9
+ //!
10
+ //! Hand-rolled HTTP/1.1 on a plain [`TcpListener`], deliberately. Serving
11
+ //! a recording needs no upstream call, so it needs no TLS, no HTTP client
12
+ //! and no async runtime, and this workspace has none of those - adding an
13
+ //! async stack to answer localhost POSTs from one process would be a large
14
+ //! dependency for a small job. Record mode, which DOES have to reach a
15
+ //! real API over TLS, is a separate slice and can take that decision with
16
+ //! its own evidence.
17
+
18
+ use std::io::{BufRead, BufReader, Read, Write};
19
+ use std::net::{SocketAddr, TcpListener, TcpStream};
20
+ use std::sync::atomic::{AtomicBool, Ordering};
21
+ use std::sync::{Arc, Mutex};
22
+
23
+ use flowproof_trace::cassette::{Cassette, Divergence, Message, ToolCall, TurnRequest};
24
+
25
+ /// The largest request body the proxy will read. Prompts are large and
26
+ /// grow every turn; this is a guard against a malformed `content-length`,
27
+ /// not a limit anyone should reach.
28
+ const MAX_BODY: usize = 32 * 1024 * 1024;
29
+
30
+ /// What the proxy observed while serving. Read after the run to assert
31
+ /// against the trajectory.
32
+ #[derive(Debug, Default)]
33
+ pub struct ProxyLog {
34
+ /// Requests served, in order.
35
+ pub served: usize,
36
+ /// The first divergence, which is also the last: serving stops being
37
+ /// meaningful once the trajectory has left its recording.
38
+ pub divergence: Option<Divergence>,
39
+ }
40
+
41
+ /// A running proxy. Dropping it stops the listener.
42
+ pub struct AgentProxy {
43
+ addr: SocketAddr,
44
+ log: Arc<Mutex<ProxyLog>>,
45
+ stop: Arc<AtomicBool>,
46
+ thread: Option<std::thread::JoinHandle<()>>,
47
+ }
48
+
49
+ impl AgentProxy {
50
+ /// Start serving `cassette` on an ephemeral localhost port.
51
+ ///
52
+ /// Bound to 127.0.0.1 on purpose: this endpoint answers whatever asks
53
+ /// it, with no authentication, so it must not be reachable off the
54
+ /// machine running the test.
55
+ pub fn start(cassette: Cassette) -> std::io::Result<Self> {
56
+ let listener = TcpListener::bind("127.0.0.1:0")?;
57
+ let addr = listener.local_addr()?;
58
+ // A short read timeout lets the accept loop notice `stop` even
59
+ // when a client connects and then says nothing.
60
+ listener.set_nonblocking(true)?;
61
+
62
+ let log = Arc::new(Mutex::new(ProxyLog::default()));
63
+ let stop = Arc::new(AtomicBool::new(false));
64
+ let thread = {
65
+ let (log, stop) = (Arc::clone(&log), Arc::clone(&stop));
66
+ std::thread::spawn(move || {
67
+ let mut turn = 0usize;
68
+ while !stop.load(Ordering::Relaxed) {
69
+ match listener.accept() {
70
+ Ok((stream, _)) => {
71
+ stream.set_nonblocking(false).ok();
72
+ serve_one(stream, &cassette, &mut turn, &log);
73
+ }
74
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
75
+ std::thread::sleep(std::time::Duration::from_millis(5));
76
+ }
77
+ Err(_) => break,
78
+ }
79
+ }
80
+ })
81
+ };
82
+ Ok(Self {
83
+ addr,
84
+ log,
85
+ stop,
86
+ thread: Some(thread),
87
+ })
88
+ }
89
+
90
+ /// The base URL to hand the system under test, in the shape
91
+ /// `OPENAI_BASE_URL` expects.
92
+ pub fn base_url(&self) -> String {
93
+ format!("http://{}/v1", self.addr)
94
+ }
95
+
96
+ pub fn log(&self) -> std::sync::MutexGuard<'_, ProxyLog> {
97
+ self.log.lock().unwrap_or_else(|e| e.into_inner())
98
+ }
99
+ }
100
+
101
+ impl Drop for AgentProxy {
102
+ fn drop(&mut self) {
103
+ self.stop.store(true, Ordering::Relaxed);
104
+ if let Some(thread) = self.thread.take() {
105
+ let _ = thread.join();
106
+ }
107
+ }
108
+ }
109
+
110
+ /// Read one request, answer it, close. `Connection: close` every time:
111
+ /// keep-alive would buy nothing here and multiplexing state machines are
112
+ /// where hand-rolled HTTP goes wrong.
113
+ fn serve_one(stream: TcpStream, cassette: &Cassette, turn: &mut usize, log: &Mutex<ProxyLog>) {
114
+ let mut reader = BufReader::new(match stream.try_clone() {
115
+ Ok(s) => s,
116
+ Err(_) => return,
117
+ });
118
+ let mut writer = stream;
119
+
120
+ let Some((path, body)) = read_request(&mut reader) else {
121
+ let _ = writer.write_all(&response(400, r#"{"error":"malformed request"}"#));
122
+ return;
123
+ };
124
+ if !path.contains("/chat/completions") {
125
+ let _ = writer.write_all(&response(
126
+ 404,
127
+ r#"{"error":"only /v1/chat/completions is served"}"#,
128
+ ));
129
+ return;
130
+ }
131
+
132
+ let incoming = match parse_request(&body) {
133
+ Ok(request) => request,
134
+ Err(why) => {
135
+ let _ = writer.write_all(&response(
136
+ 400,
137
+ &error_body(&format!("could not read the request: {why}")),
138
+ ));
139
+ return;
140
+ }
141
+ };
142
+
143
+ let index = *turn;
144
+ *turn += 1;
145
+ match cassette.turn(index, &incoming) {
146
+ Ok(recorded) => {
147
+ log.lock().unwrap_or_else(|e| e.into_inner()).served += 1;
148
+ let _ = writer.write_all(&response(200, &completion_body(&recorded.message)));
149
+ }
150
+ Err(divergence) => {
151
+ // The agent is owed an answer or it will hang; the run is owed
152
+ // the truth. A 409 with the divergence in the body does both,
153
+ // and the recorded reason is what the test reports - an agent
154
+ // that swallows the error must not turn a divergence into a
155
+ // pass.
156
+ let mut log = log.lock().unwrap_or_else(|e| e.into_inner());
157
+ if log.divergence.is_none() {
158
+ log.divergence = Some(divergence.clone());
159
+ }
160
+ let _ = writer.write_all(&response(409, &error_body(&divergence.to_string())));
161
+ }
162
+ }
163
+ }
164
+
165
+ /// Read the request line, headers, and exactly `content-length` bytes.
166
+ ///
167
+ /// Reading a fixed-size buffer once would be shorter and wrong: a
168
+ /// trajectory's later prompts run to tens of kilobytes and arrive across
169
+ /// several TCP segments, so the body has to be read to its declared
170
+ /// length rather than to whatever happened to have landed.
171
+ fn read_request(reader: &mut BufReader<TcpStream>) -> Option<(String, Vec<u8>)> {
172
+ let mut request_line = String::new();
173
+ reader.read_line(&mut request_line).ok()?;
174
+ let path = request_line.split_whitespace().nth(1)?.to_string();
175
+
176
+ let mut length = 0usize;
177
+ loop {
178
+ let mut header = String::new();
179
+ if reader.read_line(&mut header).ok()? == 0 {
180
+ return None;
181
+ }
182
+ let header = header.trim_end();
183
+ if header.is_empty() {
184
+ break;
185
+ }
186
+ if let Some((name, value)) = header.split_once(':') {
187
+ if name.eq_ignore_ascii_case("content-length") {
188
+ length = value.trim().parse().ok()?;
189
+ }
190
+ }
191
+ }
192
+ if length > MAX_BODY {
193
+ return None;
194
+ }
195
+ let mut body = vec![0u8; length];
196
+ reader.read_exact(&mut body).ok()?;
197
+ Some((path, body))
198
+ }
199
+
200
+ /// Pull the comparable request out of an OpenAI-compatible payload.
201
+ ///
202
+ /// Only the fields the cassette matches on are taken. Sampling knobs
203
+ /// (temperature, top_p, seed) are deliberately ignored: they do not change
204
+ /// which conversation this is, and matching on them would make a test fail
205
+ /// because someone tuned a dial.
206
+ fn parse_request(body: &[u8]) -> Result<TurnRequest, String> {
207
+ let json: serde_json::Value =
208
+ serde_json::from_slice(body).map_err(|e| format!("not JSON ({e})"))?;
209
+ let model = json
210
+ .get("model")
211
+ .and_then(|m| m.as_str())
212
+ .ok_or("no model")?
213
+ .to_string();
214
+ let messages = json
215
+ .get("messages")
216
+ .and_then(|m| m.as_array())
217
+ .ok_or("no messages")?
218
+ .iter()
219
+ .map(parse_message)
220
+ .collect();
221
+ let tools = json
222
+ .get("tools")
223
+ .and_then(|t| t.as_array())
224
+ .map(|tools| {
225
+ tools
226
+ .iter()
227
+ .filter_map(|t| {
228
+ t.get("function")
229
+ .and_then(|f| f.get("name"))
230
+ .and_then(|n| n.as_str())
231
+ .map(str::to_string)
232
+ })
233
+ .collect()
234
+ })
235
+ .unwrap_or_default();
236
+ Ok(TurnRequest {
237
+ model,
238
+ messages,
239
+ tools,
240
+ })
241
+ }
242
+
243
+ fn parse_message(value: &serde_json::Value) -> Message {
244
+ Message {
245
+ role: value
246
+ .get("role")
247
+ .and_then(|r| r.as_str())
248
+ .unwrap_or_default()
249
+ .to_string(),
250
+ // Content is absent on a pure tool-call message and null on some
251
+ // clients; both mean "nothing said".
252
+ content: value
253
+ .get("content")
254
+ .and_then(|c| c.as_str())
255
+ .map(str::to_string),
256
+ tool_calls: value
257
+ .get("tool_calls")
258
+ .and_then(|c| c.as_array())
259
+ .map(|calls| calls.iter().filter_map(parse_tool_call).collect())
260
+ .unwrap_or_default(),
261
+ tool_call_id: value
262
+ .get("tool_call_id")
263
+ .and_then(|c| c.as_str())
264
+ .map(str::to_string),
265
+ }
266
+ }
267
+
268
+ fn parse_tool_call(value: &serde_json::Value) -> Option<ToolCall> {
269
+ let function = value.get("function")?;
270
+ Some(ToolCall {
271
+ id: value
272
+ .get("id")
273
+ .and_then(|i| i.as_str())
274
+ .unwrap_or_default()
275
+ .to_string(),
276
+ name: function.get("name")?.as_str()?.to_string(),
277
+ arguments: function
278
+ .get("arguments")
279
+ .and_then(|a| a.as_str())
280
+ .unwrap_or("{}")
281
+ .to_string(),
282
+ })
283
+ }
284
+
285
+ /// Render a recorded assistant message as a chat-completions response.
286
+ fn completion_body(message: &Message) -> String {
287
+ let tool_calls: Vec<serde_json::Value> = message
288
+ .tool_calls
289
+ .iter()
290
+ .map(|call| {
291
+ serde_json::json!({
292
+ "id": call.id,
293
+ "type": "function",
294
+ "function": { "name": call.name, "arguments": call.arguments },
295
+ })
296
+ })
297
+ .collect();
298
+ let mut rendered = serde_json::json!({
299
+ "role": message.role,
300
+ "content": message.content,
301
+ });
302
+ if !tool_calls.is_empty() {
303
+ rendered["tool_calls"] = serde_json::Value::Array(tool_calls);
304
+ }
305
+ serde_json::json!({
306
+ "id": "flowproof-replay",
307
+ "object": "chat.completion",
308
+ "model": "flowproof-replay",
309
+ "choices": [{
310
+ "index": 0,
311
+ "message": rendered,
312
+ // A recorded turn that asked for tools finished for that
313
+ // reason; anything else finished by stopping.
314
+ "finish_reason": if message.tool_calls.is_empty() { "stop" } else { "tool_calls" },
315
+ }],
316
+ })
317
+ .to_string()
318
+ }
319
+
320
+ fn error_body(message: &str) -> String {
321
+ serde_json::json!({ "error": { "type": "flowproof_divergence", "message": message } })
322
+ .to_string()
323
+ }
324
+
325
+ fn response(status: u16, body: &str) -> Vec<u8> {
326
+ let reason = match status {
327
+ 200 => "OK",
328
+ 400 => "Bad Request",
329
+ 404 => "Not Found",
330
+ _ => "Conflict",
331
+ };
332
+ format!(
333
+ "HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\n\
334
+ content-length: {}\r\nconnection: close\r\n\r\n{body}",
335
+ body.len()
336
+ )
337
+ .into_bytes()
338
+ }
339
+
340
+ #[cfg(test)]
341
+ mod tests {
342
+ use super::*;
343
+ use flowproof_trace::cassette::{Turn, TurnResponse};
344
+
345
+ fn cassette() -> Cassette {
346
+ Cassette {
347
+ turns: vec![
348
+ Turn {
349
+ request: TurnRequest {
350
+ model: "gpt-4o".into(),
351
+ messages: vec![Message::new("user", "Book a flight to Nairobi")],
352
+ tools: vec!["search_flights".into()],
353
+ },
354
+ response: TurnResponse {
355
+ message: Message {
356
+ role: "assistant".into(),
357
+ content: None,
358
+ tool_calls: vec![ToolCall {
359
+ id: "call_1".into(),
360
+ name: "search_flights".into(),
361
+ arguments: r#"{"destination":"NBO"}"#.into(),
362
+ }],
363
+ tool_call_id: None,
364
+ },
365
+ },
366
+ },
367
+ Turn {
368
+ request: TurnRequest {
369
+ model: "gpt-4o".into(),
370
+ messages: vec![
371
+ Message::new("user", "Book a flight to Nairobi"),
372
+ Message::new("tool", r#"{"id":"KQ311"}"#),
373
+ ],
374
+ tools: vec!["search_flights".into()],
375
+ },
376
+ response: TurnResponse {
377
+ message: Message::new("assistant", "Booked KQ311."),
378
+ },
379
+ },
380
+ ],
381
+ }
382
+ }
383
+
384
+ /// A minimal client: POST a JSON body, return (status, body).
385
+ fn post(base: &str, payload: serde_json::Value) -> (u16, serde_json::Value) {
386
+ let addr = base
387
+ .trim_start_matches("http://")
388
+ .trim_end_matches("/v1")
389
+ .to_string();
390
+ let body = payload.to_string();
391
+ let mut stream = TcpStream::connect(&addr).expect("connect");
392
+ let request = format!(
393
+ "POST /v1/chat/completions HTTP/1.1\r\nhost: {addr}\r\n\
394
+ content-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
395
+ body.len()
396
+ );
397
+ stream.write_all(request.as_bytes()).expect("write");
398
+ let mut raw = String::new();
399
+ stream.read_to_string(&mut raw).expect("read");
400
+ let status = raw
401
+ .split_whitespace()
402
+ .nth(1)
403
+ .and_then(|s| s.parse().ok())
404
+ .expect("status");
405
+ let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default();
406
+ (status, serde_json::from_str(body).unwrap_or_default())
407
+ }
408
+
409
+ fn chat(messages: serde_json::Value) -> serde_json::Value {
410
+ serde_json::json!({
411
+ "model": "gpt-4o",
412
+ "messages": messages,
413
+ "temperature": 0.7,
414
+ "tools": [{"type": "function", "function": {"name": "search_flights"}}],
415
+ })
416
+ }
417
+
418
+ /// The whole point: an agent making its usual HTTP calls gets the
419
+ /// recorded trajectory back, turn by turn, with no model involved.
420
+ #[test]
421
+ fn a_recorded_trajectory_is_served_over_http() {
422
+ let proxy = AgentProxy::start(cassette()).expect("starts");
423
+
424
+ let (status, body) = post(
425
+ &proxy.base_url(),
426
+ chat(serde_json::json!([{"role": "user", "content": "Book a flight to Nairobi"}])),
427
+ );
428
+ assert_eq!(status, 200);
429
+ let call = &body["choices"][0]["message"]["tool_calls"][0];
430
+ assert_eq!(call["function"]["name"], "search_flights");
431
+ assert_eq!(call["function"]["arguments"], r#"{"destination":"NBO"}"#);
432
+ assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
433
+
434
+ let (status, body) = post(
435
+ &proxy.base_url(),
436
+ chat(serde_json::json!([
437
+ {"role": "user", "content": "Book a flight to Nairobi"},
438
+ {"role": "tool", "content": r#"{"id":"KQ311"}"#},
439
+ ])),
440
+ );
441
+ assert_eq!(status, 200);
442
+ assert_eq!(body["choices"][0]["message"]["content"], "Booked KQ311.");
443
+ assert_eq!(body["choices"][0]["finish_reason"], "stop");
444
+
445
+ assert_eq!(proxy.log().served, 2);
446
+ assert!(proxy.log().divergence.is_none());
447
+ }
448
+
449
+ /// A drifted prompt must not quietly succeed. The agent is owed an
450
+ /// answer so it does not hang, and the run is owed the reason.
451
+ #[test]
452
+ fn a_divergence_is_reported_to_both_the_agent_and_the_run() {
453
+ let proxy = AgentProxy::start(cassette()).expect("starts");
454
+ let (status, body) = post(
455
+ &proxy.base_url(),
456
+ chat(serde_json::json!([{"role": "user", "content": "Book a flight to Mombasa"}])),
457
+ );
458
+ assert_eq!(status, 409);
459
+ let message = body["error"]["message"].as_str().unwrap_or_default();
460
+ assert!(message.contains("content changed"), "{message}");
461
+ assert!(message.starts_with("turn 1:"), "{message}");
462
+
463
+ let log = proxy.log();
464
+ assert_eq!(log.served, 0, "a divergent turn is not a served turn");
465
+ assert!(log.divergence.is_some());
466
+ }
467
+
468
+ /// Sampling knobs do not change which conversation this is. A test
469
+ /// must not break because somebody tuned temperature.
470
+ #[test]
471
+ fn sampling_parameters_are_ignored() {
472
+ let proxy = AgentProxy::start(cassette()).expect("starts");
473
+ let mut payload =
474
+ chat(serde_json::json!([{"role": "user", "content": "Book a flight to Nairobi"}]));
475
+ payload["temperature"] = serde_json::json!(0.0);
476
+ payload["top_p"] = serde_json::json!(0.1);
477
+ payload["seed"] = serde_json::json!(42);
478
+ assert_eq!(post(&proxy.base_url(), payload).0, 200);
479
+ }
480
+
481
+ /// A body split across TCP segments must still be read whole. This is
482
+ /// the failure mode of "read once into a buffer", and real prompts are
483
+ /// big enough to hit it.
484
+ #[test]
485
+ fn a_body_arriving_in_pieces_is_read_to_its_declared_length() {
486
+ let proxy = AgentProxy::start(cassette()).expect("starts");
487
+ // Pad with an ignored field so the body comfortably exceeds a
488
+ // single small segment.
489
+ let mut payload =
490
+ chat(serde_json::json!([{"role": "user", "content": "Book a flight to Nairobi"}]));
491
+ payload["user"] = serde_json::json!("x".repeat(200_000));
492
+ let body = payload.to_string();
493
+
494
+ let addr = proxy
495
+ .base_url()
496
+ .trim_start_matches("http://")
497
+ .trim_end_matches("/v1")
498
+ .to_string();
499
+ let mut stream = TcpStream::connect(&addr).expect("connect");
500
+ let head = format!(
501
+ "POST /v1/chat/completions HTTP/1.1\r\nhost: {addr}\r\n\
502
+ content-type: application/json\r\ncontent-length: {}\r\n\r\n",
503
+ body.len()
504
+ );
505
+ stream.write_all(head.as_bytes()).expect("head");
506
+ for chunk in body.as_bytes().chunks(8192) {
507
+ stream.write_all(chunk).expect("chunk");
508
+ std::thread::sleep(std::time::Duration::from_millis(1));
509
+ }
510
+ let mut raw = String::new();
511
+ stream.read_to_string(&mut raw).expect("read");
512
+ assert!(
513
+ raw.starts_with("HTTP/1.1 200"),
514
+ "{}",
515
+ &raw[..60.min(raw.len())]
516
+ );
517
+ }
518
+
519
+ #[test]
520
+ fn other_endpoints_are_refused_rather_than_guessed_at() {
521
+ let proxy = AgentProxy::start(cassette()).expect("starts");
522
+ let addr = proxy
523
+ .base_url()
524
+ .trim_start_matches("http://")
525
+ .trim_end_matches("/v1")
526
+ .to_string();
527
+ let mut stream = TcpStream::connect(&addr).expect("connect");
528
+ stream
529
+ .write_all(
530
+ format!("GET /v1/models HTTP/1.1\r\nhost: {addr}\r\ncontent-length: 0\r\n\r\n")
531
+ .as_bytes(),
532
+ )
533
+ .expect("write");
534
+ let mut raw = String::new();
535
+ stream.read_to_string(&mut raw).expect("read");
536
+ assert!(raw.starts_with("HTTP/1.1 404"), "{raw}");
537
+ }
538
+
539
+ /// It answers whatever asks it, with no authentication, so it must not
540
+ /// be reachable off this machine.
541
+ #[test]
542
+ fn the_proxy_listens_only_on_loopback() {
543
+ let proxy = AgentProxy::start(cassette()).expect("starts");
544
+ assert!(
545
+ proxy.base_url().starts_with("http://127.0.0.1:"),
546
+ "{}",
547
+ proxy.base_url()
548
+ );
549
+ }
550
+ }
@@ -2,6 +2,9 @@
2
2
  //! prefer it over pixels: SAP GUI Scripting COM (`sap-com` feature), browser
3
3
  //! via the DevTools protocol (`web` feature). Java Access Bridge comes later.
4
4
 
5
+ #[cfg(feature = "agent")]
6
+ pub mod agent_proxy;
7
+
5
8
  #[cfg(feature = "sap-com")]
6
9
  pub mod sap_com;
7
10
 
@@ -11,6 +14,9 @@ pub mod vision;
11
14
  #[cfg(feature = "web")]
12
15
  pub mod web;
13
16
 
17
+ #[cfg(feature = "agent")]
18
+ pub use agent_proxy::AgentProxy;
19
+
14
20
  #[cfg(feature = "sap-com")]
15
21
  pub use sap_com::SapAppDriver;
16
22