Skip to content

Commit 214bdb2

Browse files
authored
Add run_for agent tool (#54)
1 parent dc41444 commit 214bdb2

2 files changed

Lines changed: 205 additions & 2 deletions

File tree

‎core/src/agent/tools/stimulate.rs‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ const MIN_CURRENT_PA: f32 = -100.0;
99
const MAX_CURRENT_PA: f32 = 100.0;
1010
const MIN_DURATION_MS: f32 = 0.0;
1111
const MAX_DURATION_MS: f32 = 1000.0;
12+
const MAX_RUN_DURATION_MS: f32 = 5000.0;
1213

1314
pub struct InjectCurrentTool;
1415
pub struct ForceSpikeTool;
16+
pub struct RunForTool;
1517

1618
#[async_trait]
1719
impl AgentTool for InjectCurrentTool {
@@ -96,8 +98,64 @@ impl AgentTool for ForceSpikeTool {
9698
}
9799
}
98100

101+
#[async_trait]
102+
impl AgentTool for RunForTool {
103+
fn descriptor(&self) -> ToolDescriptor {
104+
ToolDescriptor {
105+
name: "run_for",
106+
description:
107+
"Advance the simulator for a bounded duration and return a spike-count summary.",
108+
permission: Permission::Stimulate,
109+
input_schema: serde_json::json!({
110+
"$schema": "http://json-schema.org/draft-07/schema#",
111+
"type": "object",
112+
"properties": {
113+
"duration_ms": {
114+
"type": "number",
115+
"minimum": 0.0,
116+
"maximum": MAX_RUN_DURATION_MS
117+
},
118+
"dt_ms": {
119+
"type": "number",
120+
"exclusiveMinimum": 0.0,
121+
"maximum": 100.0,
122+
"default": 1.0
123+
}
124+
},
125+
"required": ["duration_ms"],
126+
"additionalProperties": false,
127+
}),
128+
}
129+
}
130+
131+
async fn invoke(
132+
&self,
133+
args: serde_json::Value,
134+
ctx: ToolContext,
135+
) -> Result<serde_json::Value, ToolError> {
136+
let requested_duration = parse_f32_arg(&args, "duration_ms")?;
137+
let requested_dt = args
138+
.get("dt_ms")
139+
.map(|_| parse_f32_arg(&args, "dt_ms"))
140+
.transpose()?
141+
.unwrap_or(1.0);
142+
let duration_ms = requested_duration.clamp(0.0, MAX_RUN_DURATION_MS);
143+
let dt_ms = requested_dt.clamp(0.001, 100.0);
144+
let summary = ctx
145+
.sim()?
146+
.run_for(duration_ms, dt_ms)
147+
.await
148+
.map_err(ToolError::Substrate)?;
149+
serde_json::to_value(summary).map_err(|e| ToolError::Substrate(e.to_string()))
150+
}
151+
}
152+
99153
pub fn stimulate_tools() -> Vec<Box<dyn AgentTool>> {
100-
vec![Box::new(InjectCurrentTool), Box::new(ForceSpikeTool)]
154+
vec![
155+
Box::new(InjectCurrentTool),
156+
Box::new(ForceSpikeTool),
157+
Box::new(RunForTool),
158+
]
101159
}
102160

103161
fn parse_uuid_arg(args: &serde_json::Value, property: &'static str) -> Result<Uuid, ToolError> {
@@ -149,7 +207,7 @@ mod tests {
149207
let descriptors = registry.descriptors();
150208
assert_eq!(
151209
descriptors.iter().map(|d| d.name).collect::<Vec<_>>(),
152-
vec!["force_spike", "inject_current"]
210+
vec!["force_spike", "inject_current", "run_for"]
153211
);
154212
assert!(descriptors
155213
.iter()
@@ -224,4 +282,44 @@ mod tests {
224282
.unwrap_err();
225283
assert!(matches!(err, ToolError::PermissionDenied { .. }));
226284
}
285+
286+
#[tokio::test]
287+
async fn run_for_returns_structured_spike_summary() {
288+
let (sim, _join) = spawn_engine(10_000);
289+
let node_id = Uuid::new_v4();
290+
sim.add_node(node_id).await.unwrap();
291+
sim.force_spike(node_id).await.unwrap();
292+
293+
let out = registry()
294+
.invoke(
295+
"run_for",
296+
serde_json::json!({ "duration_ms": 500.0, "dt_ms": 1.0 }),
297+
Permission::Stimulate,
298+
ToolContext::new(sim),
299+
)
300+
.await
301+
.unwrap();
302+
303+
assert_eq!(out["duration_ms"], 500.0);
304+
assert_eq!(out["dt_ms"], 1.0);
305+
assert_eq!(out["steps"], 500);
306+
assert!(out["total_spikes"].as_u64().is_some());
307+
assert_eq!(out["cancelled"], false);
308+
assert!(out["per_neuron"].as_array().is_some());
309+
}
310+
311+
#[tokio::test]
312+
async fn run_for_is_bounded() {
313+
let (sim, _join) = spawn_engine(10_000);
314+
let out = registry()
315+
.invoke(
316+
"run_for",
317+
serde_json::json!({ "duration_ms": 6000.0, "dt_ms": 10.0 }),
318+
Permission::Stimulate,
319+
ToolContext::new(sim),
320+
)
321+
.await
322+
.unwrap_err();
323+
assert!(matches!(out, ToolError::BadInput { .. }));
324+
}
227325
}

‎core/src/engine/mod.rs‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub use hebb::engine::sim::SimEngine;
2020
pub use spike_persist::spawn_spike_persister;
2121
pub use weight_persist::spawn_weight_persister;
2222

23+
use std::collections::BTreeMap;
2324
use std::path::PathBuf;
2425
use std::time::Duration;
2526
use tokio::sync::{broadcast, mpsc, oneshot};
@@ -73,6 +74,25 @@ pub struct EngineSeedReport {
7374
pub added_edges: usize,
7475
}
7576

77+
#[derive(Debug, Clone, serde::Serialize)]
78+
pub struct EngineRunSummary {
79+
pub requested_duration_ms: f32,
80+
pub duration_ms: f32,
81+
pub dt_ms: f32,
82+
pub steps: usize,
83+
pub t_start_ms: f64,
84+
pub t_end_ms: f64,
85+
pub total_spikes: usize,
86+
pub per_neuron: Vec<EngineRunNeuronCount>,
87+
pub cancelled: bool,
88+
}
89+
90+
#[derive(Debug, Clone, serde::Serialize)]
91+
pub struct EngineRunNeuronCount {
92+
pub node_id: Uuid,
93+
pub spikes: usize,
94+
}
95+
7696
pub enum EngineCommand {
7797
AddNode(Uuid),
7898
AddEdge {
@@ -100,6 +120,11 @@ pub enum EngineCommand {
100120
node_id: Uuid,
101121
reply: oneshot::Sender<Result<(), String>>,
102122
},
123+
RunFor {
124+
duration_ms: f32,
125+
dt_ms: f32,
126+
reply: oneshot::Sender<Result<EngineRunSummary, String>>,
127+
},
103128
Snapshot(oneshot::Sender<EngineSnapshot>),
104129
/// Live topology snapshot with synapse endpoints. This is the
105130
/// read-only graph view the agent harness needs without going
@@ -338,6 +363,19 @@ impl SimHandle {
338363
rx.await.map_err(|_| "engine dropped reply".to_string())?
339364
}
340365

366+
pub async fn run_for(&self, duration_ms: f32, dt_ms: f32) -> Result<EngineRunSummary, String> {
367+
let (tx, rx) = oneshot::channel();
368+
self.cmd_tx
369+
.send(EngineCommand::RunFor {
370+
duration_ms,
371+
dt_ms,
372+
reply: tx,
373+
})
374+
.await
375+
.map_err(|_| "engine offline".to_string())?;
376+
rx.await.map_err(|_| "engine dropped reply".to_string())?
377+
}
378+
341379
pub async fn snapshot(&self) -> Result<EngineSnapshot, &'static str> {
342380
let (tx, rx) = oneshot::channel();
343381
self.cmd_tx
@@ -737,6 +775,19 @@ pub fn spawn_engine(tick_hz: u32) -> (SimHandle, tokio::task::JoinHandle<()>) {
737775
};
738776
let _ = reply.send(result);
739777
}
778+
EngineCommand::RunFor {
779+
duration_ms,
780+
dt_ms,
781+
reply,
782+
} => {
783+
let result = run_engine_for(
784+
&mut engine,
785+
&spike_tx,
786+
duration_ms,
787+
dt_ms,
788+
);
789+
let _ = reply.send(result);
790+
}
740791
EngineCommand::Snapshot(reply) => {
741792
let snap = EngineSnapshot {
742793
t_ms: engine.t_ms,
@@ -1830,6 +1881,60 @@ fn open_folder_into_engine(
18301881
Ok((engine, cortex_type, kind, cortex, summary))
18311882
}
18321883

1884+
fn run_engine_for(
1885+
engine: &mut SimEngine,
1886+
spike_tx: &broadcast::Sender<SpikeFrame>,
1887+
duration_ms: f32,
1888+
dt_ms: f32,
1889+
) -> Result<EngineRunSummary, String> {
1890+
if !duration_ms.is_finite() || !dt_ms.is_finite() {
1891+
return Err("duration_ms and dt_ms must be finite".into());
1892+
}
1893+
if duration_ms < 0.0 {
1894+
return Err("duration_ms must be >= 0".into());
1895+
}
1896+
if dt_ms <= 0.0 {
1897+
return Err("dt_ms must be > 0".into());
1898+
}
1899+
1900+
let t_start_ms = engine.t_ms;
1901+
let steps = (duration_ms / dt_ms).ceil() as usize;
1902+
let mut counts: BTreeMap<Uuid, usize> = BTreeMap::new();
1903+
let mut total_spikes = 0;
1904+
1905+
for step in 0..steps {
1906+
let elapsed = step as f32 * dt_ms;
1907+
let remaining = (duration_ms - elapsed).max(0.0);
1908+
let step_dt = remaining.min(dt_ms);
1909+
if step_dt <= 0.0 {
1910+
break;
1911+
}
1912+
let frame = engine.tick(step_dt);
1913+
if !frame.events.is_empty() {
1914+
total_spikes += frame.events.len();
1915+
for event in &frame.events {
1916+
*counts.entry(event.node_id).or_insert(0) += 1;
1917+
}
1918+
let _ = spike_tx.send(frame);
1919+
}
1920+
}
1921+
1922+
Ok(EngineRunSummary {
1923+
requested_duration_ms: duration_ms,
1924+
duration_ms,
1925+
dt_ms,
1926+
steps,
1927+
t_start_ms,
1928+
t_end_ms: engine.t_ms,
1929+
total_spikes,
1930+
per_neuron: counts
1931+
.into_iter()
1932+
.map(|(node_id, spikes)| EngineRunNeuronCount { node_id, spikes })
1933+
.collect(),
1934+
cancelled: false,
1935+
})
1936+
}
1937+
18331938
/// Periodically diff the live weight set against the last published one
18341939
/// and broadcast deltas. Runs as a sibling task to the engine — reads
18351940
/// state through `SimHandle::weight_snapshot` (one mpsc round-trip) so

0 commit comments

Comments
 (0)