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.
- {flowproof-0.2.4 → flowproof-0.3.0}/Cargo.lock +8 -7
- {flowproof-0.2.4 → flowproof-0.3.0}/Cargo.toml +1 -1
- {flowproof-0.2.4 → flowproof-0.3.0}/PKG-INFO +1 -1
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/Cargo.toml +3 -0
- flowproof-0.3.0/crates/flowproof-adapters/src/agent_proxy.rs +550 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/lib.rs +6 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/sap_com.rs +71 -14
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/vision.rs +445 -101
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-adapters/src/web.rs +126 -0
- flowproof-0.3.0/crates/flowproof-agent/src/agent_steps.rs +330 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/lib.rs +1 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/recorder.rs +333 -26
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/rules.rs +667 -19
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/spec.rs +294 -6
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/src/lib.rs +49 -8
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/examples_resolve.rs +4 -4
- flowproof-0.3.0/crates/flowproof-cli/tests/notepad_e2e.rs +249 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +13 -6
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/suite_env_from.rs +96 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/support/sap_simulator.py +21 -17
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/vision_pipeline.rs +99 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/web_e2e.rs +173 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/app.rs +525 -4
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/lib.rs +54 -2
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/mock.rs +81 -1
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/window.rs +74 -2
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-python/src/lib.rs +2 -2
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/src/lib.rs +341 -39
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/tests/replay_calc.rs +561 -1
- flowproof-0.3.0/crates/flowproof-trace/src/cassette.rs +504 -0
- flowproof-0.3.0/crates/flowproof-trace/src/cassette_diff.rs +523 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/src/format.rs +32 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/src/lib.rs +3 -0
- flowproof-0.3.0/crates/flowproof-trace/src/toolcalls.rs +499 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/__init__.py +1 -1
- {flowproof-0.2.4 → flowproof-0.3.0}/pyproject.toml +1 -1
- flowproof-0.2.4/crates/flowproof-cli/tests/notepad_e2e.rs +0 -96
- {flowproof-0.2.4 → flowproof-0.3.0}/README.md +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/author.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/clarify.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/heal.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-agent/src/llm.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/src/main.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/backend.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/gdi.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/oob.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/recording.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/redact.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-driver/src/visual.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-python/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-replay/src/report.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/src/secret.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/cli.py +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/flow.py +0 -0
- {flowproof-0.2.4 → flowproof-0.3.0}/flowproof/mcp_server.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
6
|
+
version = "0.3.0"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
repository = "https://github.com/automators-com/flowproof"
|
|
@@ -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
|
|