1use dyn_clone::{clone_trait_object, DynClone};
8use rmcp::{
9 model::*, service::RequestContext, ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
10};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::marker::PhantomData;
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::Arc;
18use tidepool_bridge::{FromCore, ToCore};
19use tidepool_runtime::DispatchEffect;
20use tokio::io::{stdin, stdout};
21use tokio::time::{timeout, Duration};
22
23const EVAL_TIMEOUT_SECS: u64 = 120;
24const MAX_CONCURRENT_EVALS: usize = 4;
25
26#[derive(Debug, Clone, Copy)]
35pub struct EffectDecl {
36 pub type_name: &'static str,
38 pub description: &'static str,
40 pub constructors: &'static [&'static str],
42 pub type_defs: &'static [&'static str],
45 pub helpers: &'static [&'static str],
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ParsedConstructor {
53 pub name: String,
54 pub arity: u32,
55}
56
57pub fn parse_constructor(decl: &str) -> Result<ParsedConstructor, String> {
61 let (name_part, type_part) = decl
62 .split_once("::")
63 .ok_or_else(|| format!("constructor decl must contain '::': {:?}", decl))?;
64 let name = name_part.trim().to_string();
65 let arity = type_part.matches("->").count() as u32;
66 Ok(ParsedConstructor { name, arity })
67}
68
69pub trait DescribeEffect {
71 fn effect_decl() -> EffectDecl;
72}
73
74pub trait CollectEffectDecls {
76 fn collect_decls() -> Vec<EffectDecl>;
77}
78
79impl CollectEffectDecls for frunk::HNil {
80 fn collect_decls() -> Vec<EffectDecl> {
81 Vec::new()
82 }
83}
84
85impl<H, T> CollectEffectDecls for frunk::HCons<H, T>
86where
87 H: DescribeEffect,
88 T: CollectEffectDecls,
89{
90 fn collect_decls() -> Vec<EffectDecl> {
91 let mut decls = vec![H::effect_decl()];
92 decls.extend(T::collect_decls());
93 decls
94 }
95}
96
97pub fn console_decl() -> EffectDecl {
103 EffectDecl {
104 type_name: "Console",
105 description: "Print text output.",
106 constructors: &["Print :: Text -> Console ()"],
107 type_defs: &[],
108 helpers: &[],
109 }
110}
111
112pub fn kv_decl() -> EffectDecl {
114 EffectDecl {
115 type_name: "KV",
116 description:
117 "Persistent key-value store. State survives across calls within one server session.",
118 constructors: &[
119 "KvGet :: Text -> KV (Maybe Value)",
120 "KvSet :: Text -> Value -> KV ()",
121 "KvDelete :: Text -> KV ()",
122 "KvKeys :: KV [Text]",
123 ],
124 type_defs: &[],
125 helpers: &[
126 "kvGet :: Text -> M (Maybe Value)\nkvGet = send . KvGet",
127 "kvSet :: Text -> Value -> M ()\nkvSet k v = send (KvSet k v)",
128 "kvDel :: Text -> M ()\nkvDel = send . KvDelete",
129 "kvKeys :: M [Text]\nkvKeys = send KvKeys",
130 ],
131 }
132}
133
134pub fn fs_decl() -> EffectDecl {
136 EffectDecl {
137 type_name: "Fs",
138 description: "Read and write files (sandboxed to server working directory).",
139 constructors: &[
140 "FsRead :: Text -> Fs Text",
141 "FsWrite :: Text -> Text -> Fs ()",
142 "FsListDir :: Text -> Fs [Text]",
143 "FsGlob :: Text -> Fs [Text]",
144 "FsExists :: Text -> Fs Bool",
145 "FsMetadata :: Text -> Fs (Int, Bool, Bool)",
146 ],
147 type_defs: &[],
148 helpers: &[
149 "fsRead :: Text -> M Text\nfsRead = send . FsRead",
150 "fsWrite :: Text -> Text -> M ()\nfsWrite f c = send (FsWrite f c)",
151 "fsListDir :: Text -> M [Text]\nfsListDir = send . FsListDir",
152 "fsGlob :: Text -> M [Text]\nfsGlob = send . FsGlob",
153 "fsExists :: Text -> M Bool\nfsExists = send . FsExists",
154 "fsMetadata :: Text -> M (Int, Bool, Bool)\nfsMetadata = send . FsMetadata",
155 ],
156 }
157}
158
159pub fn sg_decl() -> EffectDecl {
161 EffectDecl {
162 type_name: "SG",
163 description: concat!(
164 "Structural code search and rewrite via ast-grep. ",
165 "Use patterns with $VAR for single-node captures and $$$VAR for multi-node. ",
166 "Paths are relative to server working directory.",
167 ),
168 type_defs: &[
169 "data Lang = Rust | Python | TypeScript | JavaScript | Go | Java | C | Cpp | Haskell | Nix | Html | Css | Json | Yaml | Toml",
170 "data Match = Match { mText :: Text, mFile :: Text, mLine :: Int, mVars :: [(Text, Text)], mReplacement :: Text }",
171 "instance ToJSON Match where\n toJSON (Match t f l vs r) = object ([\"text\" .= t, \"file\" .= f, \"line\" .= l] ++ (if null vs then [] else [\"vars\" .= toJSON (Map.fromList vs)]) ++ (if T.null r then [] else [\"replacement\" .= r]))",
172 "var :: Match -> Text -> Text",
173 "var (Match _ _ _ vs _) k = case [v | (k', v) <- vs, k' == k] of { (x:_) -> x; _ -> \"\" }",
174 ],
175 constructors: &[
176 "SgFind :: Lang -> Text -> [Text] -> SG [Match]",
177 "SgPreview :: Lang -> Text -> Text -> [Text] -> SG [Match]",
178 "SgReplace :: Lang -> Text -> Text -> [Text] -> SG Int",
179 "SgRuleFind :: Lang -> Value -> [Text] -> SG [Match]",
180 "SgRuleReplace :: Lang -> Value -> Text -> [Text] -> SG Int",
181 ],
182 helpers: &[
183 "sgFind :: Lang -> Text -> [Text] -> M [Match]\nsgFind l p fs = send (SgFind l p fs)",
184 "sgPreview :: Lang -> Text -> Text -> [Text] -> M [Match]\nsgPreview l p r fs = send (SgPreview l p r fs)",
185 "sgReplace :: Lang -> Text -> Text -> [Text] -> M Int\nsgReplace l p r fs = send (SgReplace l p r fs)",
186 "sgRuleFind :: Lang -> Value -> [Text] -> M [Match]\nsgRuleFind l r fs = send (SgRuleFind l r fs)",
187 "sgRuleReplace :: Lang -> Value -> Text -> [Text] -> M Int\nsgRuleReplace l r rw fs = send (SgRuleReplace l r rw fs)",
188 "rPat :: Text -> Value\nrPat p = object [\"pattern\" .= p]",
189 "rKind :: Text -> Value\nrKind k = object [\"kind\" .= k]",
190 "rRegex :: Text -> Value\nrRegex r = object [\"regex\" .= r]",
191 "rHas :: Value -> Value\nrHas r = object [\"has\" .= r]",
192 "rInside :: Value -> Value\nrInside r = object [\"inside\" .= r]",
193 "rFollows :: Value -> Value\nrFollows r = object [\"follows\" .= r]",
194 "rPrecedes :: Value -> Value\nrPrecedes r = object [\"precedes\" .= r]",
195 "rAll :: [Value] -> Value\nrAll rs = object [\"all\" .= rs]",
196 "rAny :: [Value] -> Value\nrAny rs = object [\"any\" .= rs]",
197 "rNot :: Value -> Value\nrNot r = object [\"not\" .= r]",
198 "infixr 6 .+.\n(.+.) :: Value -> Value -> Value\n(.+.) (Object a) (Object b) = Object (KM.unionWith const a b)\n(.+.) a _ = a",
200 "infixr 5 .&.\n(.&.) :: Value -> Value -> Value\na .&. b = object [\"all\" .= [a, b]]",
202 "infixr 4 .|.\n(.|.) :: Value -> Value -> Value\na .|. b = object [\"any\" .= [a, b]]",
203 "infixl 7 ?>\n(?>) :: Value -> Value -> Value\nparent ?> child = parent .+. rHas child",
205 "infixl 7 <?\n(<?) :: Value -> Value -> Value\nchild <? ancestor = child .+. rInside ancestor",
206 "rField :: Text -> Value\nrField name = object [\"field\" .= name]",
208 "rStopBy :: Text -> Value\nrStopBy s = object [\"stopBy\" .= s]",
209 ],
210 }
211}
212
213pub fn http_decl() -> EffectDecl {
215 EffectDecl {
216 type_name: "Http",
217 description: "Fetch JSON from HTTP endpoints. Returns response body as Value.",
218 constructors: &[
219 "HttpGet :: Text -> Http Value",
220 "HttpPost :: Text -> Value -> Http Value",
221 "HttpRequest :: Text -> Text -> [(Text,Text)] -> Text -> Http Value",
222 ],
223 type_defs: &[],
224 helpers: &[
225 "httpGet :: Text -> M Value\nhttpGet = send . HttpGet",
226 "httpPost :: Text -> Value -> M Value\nhttpPost url body = send (HttpPost url body)",
227 "httpReq :: Text -> Text -> [(Text,Text)] -> Text -> M Value\nhttpReq method url headers body = send (HttpRequest method url headers body)",
228 ],
229 }
230}
231
232pub fn exec_decl() -> EffectDecl {
234 EffectDecl {
235 type_name: "Exec",
236 description: "Run shell commands and capture output.",
237 constructors: &[
238 "Run :: Text -> Exec (Int, Text, Text)",
239 "RunIn :: Text -> Text -> Exec (Int, Text, Text)",
240 "RunJson :: Text -> Exec Value",
241 ],
242 type_defs: &[],
243 helpers: &[
244 "run :: Text -> M (Int, Text, Text)\nrun = send . Run",
245 "runIn :: Text -> Text -> M (Int, Text, Text)\nrunIn dir cmd = send (RunIn dir cmd)",
246 ],
247 }
248}
249
250pub fn meta_decl() -> EffectDecl {
252 EffectDecl {
253 type_name: "Meta",
254 description:
255 "Self-mirror for the runtime. Query constructors, primops, effects, diagnostics.",
256 constructors: &[
257 "MetaConstructors :: Meta [(Text, Int)]",
258 "MetaLookupCon :: Text -> Meta (Maybe (Int, Int))",
259 "MetaPrimOps :: Meta [Text]",
260 "MetaEffects :: Meta [Text]",
261 "MetaDiagnostics :: Meta [Text]",
262 "MetaVersion :: Meta Text",
263 "MetaHelp :: Meta [Text]",
264 ],
265 type_defs: &[],
266 helpers: &[
267 "metaConstructors :: M [(Text, Int)]\nmetaConstructors = send MetaConstructors",
268 "metaLookupCon :: Text -> M (Maybe (Int, Int))\nmetaLookupCon = send . MetaLookupCon",
269 "metaPrimOps :: M [Text]\nmetaPrimOps = send MetaPrimOps",
270 "metaEffects :: M [Text]\nmetaEffects = send MetaEffects",
271 "metaDiagnostics :: M [Text]\nmetaDiagnostics = send MetaDiagnostics",
272 "metaVersion :: M Text\nmetaVersion = send MetaVersion",
273 "metaHelp :: M [Text]\nmetaHelp = send MetaHelp",
274 ],
275 }
276}
277
278pub fn ask_decl() -> EffectDecl {
280 EffectDecl {
281 type_name: "Ask",
282 description: "Suspend execution and ask the calling LLM a question. The LLM calls the resume tool with an answer, and execution continues.",
283 constructors: &["Ask :: Text -> Ask Value"],
284 type_defs: &[],
285 helpers: &[
286 "ask :: Text -> M Value\nask = send . Ask",
287 ],
288 }
289}
290
291pub fn git_decl() -> EffectDecl {
293 EffectDecl {
294 type_name: "Git",
295 description: "Native git repository access via libgit2.",
296 constructors: &[
297 "GitLog :: Text -> Int -> Git [Value]",
298 "GitShow :: Text -> Git Value",
299 "GitDiff :: Text -> Git [Value]",
300 "GitBlame :: Text -> Int -> Int -> Git [Value]",
301 "GitTree :: Text -> Text -> Git [Value]",
302 "GitBranches :: Git [Value]",
303 ],
304 type_defs: &[],
305 helpers: &[
306 "gitLog :: Text -> Int -> M [Value]\ngitLog ref n = send (GitLog ref n)",
307 "gitShow :: Text -> M Value\ngitShow = send . GitShow",
308 "gitDiff :: Text -> M [Value]\ngitDiff = send . GitDiff",
309 "gitBlame :: Text -> Int -> Int -> M [Value]\ngitBlame file s e = send (GitBlame file s e)",
310 "gitTree :: Text -> Text -> M [Value]\ngitTree hash path = send (GitTree hash path)",
311 "gitBranches :: M [Value]\ngitBranches = send GitBranches",
312 ],
313 }
314}
315
316pub fn llm_decl() -> EffectDecl {
318 EffectDecl {
319 type_name: "Llm",
320 description: "Call a fast LLM (Haiku) for classification, extraction, or judgment.",
321 constructors: &[
322 "LlmChat :: Text -> Llm Text",
323 "LlmStructured :: Text -> Value -> Llm Value",
324 ],
325 type_defs: &[
326 "data Schema = SObj [(Text, Schema)] | SArr Schema | SStr | SNum | SBool | SEnum [Text] | SOpt Schema",
327 ],
328 helpers: &[
329 "llm :: Text -> M Text\nllm = send . LlmChat",
330 "llmJson :: Text -> Schema -> M Value\nllmJson prompt schema = send (LlmStructured prompt (schemaToValue schema))",
331 "isOpt :: Schema -> Bool\nisOpt (SOpt _) = True\nisOpt _ = False",
332 "innerSchema :: Schema -> Schema\ninnerSchema (SOpt s) = s\ninnerSchema s = s",
333 "schemaToValue :: Schema -> Value\nschemaToValue SStr = object [\"type\" .= (\"string\" :: Text)]\nschemaToValue SNum = object [\"type\" .= (\"number\" :: Text)]\nschemaToValue SBool = object [\"type\" .= (\"boolean\" :: Text)]\nschemaToValue (SEnum vs) = object [\"type\" .= (\"string\" :: Text), \"enum\" .= vs]\nschemaToValue (SArr item) = object [\"type\" .= (\"array\" :: Text), \"items\" .= schemaToValue item]\nschemaToValue (SOpt s) = schemaToValue s\nschemaToValue (SObj fields) = object [\"type\" .= (\"object\" :: Text), \"properties\" .= object (map (\\(k,s) -> k .= schemaToValue (innerSchema s)) fields), \"required\" .= map fst (filter (not . isOpt . snd) fields)]",
334 ],
335 }
336}
337
338pub fn standard_decls() -> Vec<EffectDecl> {
340 vec![
341 console_decl(),
342 kv_decl(),
343 fs_decl(),
344 sg_decl(),
345 http_decl(),
346 exec_decl(),
347 meta_decl(),
348 git_decl(),
349 llm_decl(),
350 ask_decl(),
351 ]
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363pub struct EvalRequest {
364 pub code: String,
368 #[serde(default)]
370 pub imports: String,
371 #[serde(default)]
374 pub helpers: String,
375 #[serde(default)]
377 pub input: Option<serde_json::Value>,
378 #[serde(default)]
382 pub max_len: Option<u32>,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
389pub struct ResumeRequest {
390 pub continuation_id: String,
392 pub response: String,
394}
395
396pub fn build_preamble(effects: &[EffectDecl], user_library: bool) -> String {
410 let mut out = String::new();
411 out.push_str("{-# LANGUAGE NoImplicitPrelude, OverloadedStrings, DataKinds, TypeOperators, FlexibleContexts, FlexibleInstances, GADTs, PartialTypeSignatures, ScopedTypeVariables #-}\n");
412 out.push_str("module Expr where\n");
413 out.push_str("import Tidepool.Prelude hiding (error)\n");
414 out.push_str("import qualified Data.Text as T\n");
415 out.push_str("import qualified Data.Map.Strict as Map\n");
416 out.push_str("import qualified Data.Set as Set\n");
417 out.push_str("import qualified Tidepool.Aeson.KeyMap as KM\n");
418 out.push_str("import qualified Data.List as L\n");
419 out.push_str("import qualified Tidepool.Text as TT\n");
420 out.push_str("import qualified Tidepool.Table as Tab\n");
421 out.push_str("import Control.Monad.Freer hiding (run)\n");
422 if user_library {
423 out.push_str("import Library\n");
424 }
425 out.push_str("import qualified Prelude as P\n");
426 out.push_str("default (Int, Text)\n");
427 out.push_str("error :: Text -> a\nerror = P.error . T.unpack\n");
428 out.push('\n');
429
430 for eff in effects {
431 for td in eff.type_defs {
432 out.push_str(td);
433 out.push('\n');
434 }
435 out.push_str(&format!("data {} a where\n", eff.type_name));
436 for ctor in eff.constructors {
437 out.push_str(&format!(" {}\n", ctor));
438 }
439 out.push('\n');
440 }
441
442 if !effects.is_empty() {
444 let names: Vec<&str> = effects.iter().map(|e| e.type_name).collect();
445 out.push_str(&format!("type M = Eff '[{}]\n\n", names.join(", ")));
446 }
447
448 let has_helpers = effects.iter().any(|e| !e.helpers.is_empty());
450 if has_helpers {
451 for eff in effects {
452 for h in eff.helpers {
453 out.push_str(h);
454 out.push('\n');
455 }
456 }
457 out.push('\n');
458 }
459
460 if !effects.is_empty() {
462 let has_ask = effects.iter().any(|e| e.type_name == "Ask");
463 let has_console = effects.iter().any(|e| e.type_name == "Console");
464 let has_kv = effects.iter().any(|e| e.type_name == "KV");
465
466 out.push_str("-- Pagination\n");
467 out.push_str(concat!("showI :: Int -> Text\n", "showI n = show n\n",));
468 if has_console && has_kv {
470 out.push_str(concat!(
471 "say :: Text -> M ()\n",
472 "say t = do\n",
473 " send (Print t)\n",
474 " v <- kvGet \"__sayChars\"\n",
475 " let cur = case v of { Just b -> case b ^? _Number of { Just n -> round n; _ -> 0 }; Nothing -> 0 }\n",
476 " kvSet \"__sayChars\" (toJSON (cur + T.length t))\n",
477 ));
478 } else if has_console {
479 out.push_str(concat!("say :: Text -> M ()\n", "say = send . Print\n",));
480 }
481
482 out.push_str(concat!(
483 "valSize :: Value -> Int\n",
484 "valSize v = case v of\n",
485 " String t -> T.length t + 2\n",
486 " Number _ -> 8\n",
487 " Bool b -> if b then 4 else 5\n",
488 " Null -> 4\n",
489 " Array xs -> arrSz xs 2\n",
490 " Object m -> objSz (KM.toList m) 2\n",
491 ));
492 out.push_str(concat!(
493 "arrSz :: [Value] -> Int -> Int\n",
494 "arrSz [] acc = acc\n",
495 "arrSz [x] acc = acc + valSize x\n",
496 "arrSz (x:xs) acc = arrSz xs (acc + valSize x + 2)\n",
497 ));
498 out.push_str(concat!(
499 "objSz :: [(Key, Value)] -> Int -> Int\n",
500 "objSz [] acc = acc\n",
501 "objSz [(k,v)] acc = acc + T.length (KM.toText k) + 4 + valSize v\n",
502 "objSz ((k,v):rest) acc = objSz rest (acc + T.length (KM.toText k) + 4 + valSize v + 2)\n",
503 ));
504 out.push_str(concat!(
505 "truncArr :: Int -> Int -> [Value] -> ([Value], Int, [(Int, Value)])\n",
506 "truncArr _ nid [] = ([], nid, [])\n",
507 "truncArr bud nid (x:xs)\n",
508 " | bud <= 30 = ([marker], nid + 1, [(nid, Array (x:xs))])\n",
509 " | sz <= bud = let (r, nid', s) = truncArr (bud - sz - 2) nid xs in (x : r, nid', s)\n",
510 " | otherwise = let m = String (\"[~\" <> showI sz <> \" chars -> stub_\" <> showI nid <> \"]\")\n",
511 " (r, nid', s) = truncArr (bud - 50) (nid + 1) xs\n",
512 " in (m : r, nid', (nid, x) : s)\n",
513 " where sz = valSize x\n",
514 " n = 1 + length xs\n",
515 " tsz = sz + arrSz xs 0\n",
516 " marker = String (\"[\" <> showI n <> \" more, ~\" <> showI tsz <> \" chars -> stub_\" <> showI nid <> \"]\")\n",
517 ));
518 out.push_str(concat!(
519 "truncKvs :: Int -> Int -> [(Key, Value)] -> ([(Key, Value)], Int, [(Int, Value)])\n",
520 "truncKvs _ nid [] = ([], nid, [])\n",
521 "truncKvs bud nid ((k,v):rest)\n",
522 " | bud <= 30 = ([(KM.fromText \"...\", String marker)], nid + 1, [(nid, object (map (\\(k',v') -> KM.toText k' .= v') ((k,v):rest)))])\n",
523 " | sz <= bud = let (r, nid', s) = truncKvs (bud - sz - 2) nid rest in ((k,v) : r, nid', s)\n",
524 " | otherwise = let m = String (\"[~\" <> showI (valSize v) <> \" chars -> stub_\" <> showI nid <> \"]\")\n",
525 " (r, nid', s) = truncKvs (bud - 50) (nid + 1) rest\n",
526 " in ((k, m) : r, nid', (nid, v) : s)\n",
527 " where sz = T.length (KM.toText k) + 4 + valSize v\n",
528 " n = 1 + length rest\n",
529 " tsz = sz + objSz rest 0\n",
530 " marker = \"[\" <> showI n <> \" more fields, ~\" <> showI tsz <> \" chars -> stub_\" <> showI nid <> \"]\"\n",
531 ));
532 out.push_str(concat!(
533 "truncGo :: Int -> Int -> Value -> (Value, Int, [(Int, Value)])\n",
534 "truncGo bud nid v\n",
535 " | valSize v <= bud = (v, nid, [])\n",
536 " | otherwise = case v of\n",
537 " Array xs -> let (items, nid', stubs) = truncArr bud nid xs in (Array items, nid', stubs)\n",
538 " Object m -> let (pairs, nid', stubs) = truncKvs bud nid (KM.toList m)\n",
539 " in (object (map (\\(k',v') -> KM.toText k' .= v') pairs), nid', stubs)\n",
540 " String t -> let keep = max' 10 (bud - 30)\n",
541 " in (String (T.take keep t <> \"...[\" <> showI (T.length t) <> \" chars]\"), nid, [])\n",
542 " _ -> (v, nid, [])\n",
543 ));
544 out.push_str(concat!(
545 "truncVal :: Int -> Value -> (Value, [(Int, Value)])\n",
546 "truncVal budget val = let (v, _, stubs) = truncGo budget 0 val in (v, stubs)\n",
547 ));
548 out.push_str(concat!(
549 "lookupStub :: Int -> [(Int, Value)] -> Maybe Value\n",
550 "lookupStub _ [] = Nothing\n",
551 "lookupStub sid ((k,v):rest) = if sid == k then Just v else lookupStub sid rest\n",
552 ));
553
554 if has_ask {
555 out.push_str(concat!(
556 "paginateResult :: Int -> Value -> M Value\n",
557 "paginateResult budget val\n",
558 " | valSize val <= budget = pure val\n",
559 " | otherwise = do\n",
560 " let (truncated, stubs) = truncVal budget val\n",
561 " case stubs of\n",
562 " [] -> pure truncated\n",
563 " _ -> do\n",
564 " let stubInfo = Array (map (\\(sid, sv) -> object [\"id\" .= (\"stub_\" <> showI sid), \"size\" .= toJSON (valSize sv)]) stubs)\n",
565 " resp <- ask (\"[Pagination] truncated: \" <> show truncated <> \" stubs: \" <> show stubInfo)\n",
566 " case resp ^? _String of\n",
567 " Just s -> case parseIntM (T.drop 5 s) of\n",
568 " Just sid -> case lookupStub sid stubs of\n",
569 " Just subtree -> paginateResult budget subtree\n",
570 " Nothing -> pure truncated\n",
571 " Nothing -> pure truncated\n",
572 " _ -> pure truncated\n",
573 ));
574 } else {
575 out.push_str(concat!(
576 "paginateResult :: Int -> Value -> M Value\n",
577 "paginateResult budget val\n",
578 " | valSize val <= budget = pure val\n",
579 " | otherwise = let (truncated, _) = truncVal budget val in pure truncated\n",
580 ));
581 }
582 out.push('\n');
583 }
584
585 if user_library && !effects.is_empty() {
587 out.push_str("-- Effect orchestration (from Library preamble)\n");
588 out.push_str(concat!(
589 "converse :: (s -> Value -> Either a (Text, s)) -> Text -> s -> M a\n",
590 "converse decide firstQ s0 = do\n",
591 " v <- ask firstQ\n",
592 " case decide s0 v of\n",
593 " Left a -> pure a\n",
594 " Right (q, s') -> converse decide q s'\n",
595 ));
596 out.push_str(concat!(
597 "askUntil :: (Value -> Maybe a) -> Text -> M a\n",
598 "askUntil check prompt = do\n",
599 " v <- ask prompt\n",
600 " case check v of\n",
601 " Just a -> pure a\n",
602 " Nothing -> askUntil check (prompt <> \" (invalid, try again)\")\n",
603 ));
604 out.push_str(concat!(
605 "askChoice :: Text -> [(Text, a)] -> M a\n",
606 "askChoice prompt choices = do\n",
607 " let choiceText = T.intercalate \", \" (map fst choices)\n",
608 " v <- ask (prompt <> \" [\" <> choiceText <> \"]\")\n",
609 " let answer = case v ^? _String of { Just s -> s; _ -> \"\" }\n",
610 " case lookup answer choices of\n",
611 " Just a -> pure a\n",
612 " Nothing -> askChoice prompt choices\n",
613 ));
614 out.push_str(concat!(
615 "confirm :: Text -> M Bool\n",
616 "confirm prompt = do\n",
617 " v <- ask (prompt <> \" [yes/no]\")\n",
618 " let answer = case v ^? _String of { Just s -> toLower s; _ -> \"\" }\n",
619 " pure (answer == \"yes\" || answer == \"y\")\n",
620 ));
621 out.push_str(concat!(
622 "repl :: Text -> (Text -> M (Maybe a)) -> M a\n",
623 "repl prompt dispatch = do\n",
624 " v <- ask prompt\n",
625 " let cmd = case v ^? _String of { Just s -> s; _ -> \"\" }\n",
626 " r <- dispatch cmd\n",
627 " case r of\n",
628 " Just a -> pure a\n",
629 " Nothing -> repl prompt dispatch\n",
630 ));
631 out.push_str(concat!(
632 "memo :: Text -> M Value -> M Value\n",
633 "memo k compute = do\n",
634 " cached <- kvGet k\n",
635 " case cached of\n",
636 " Just v -> pure v\n",
637 " Nothing -> do { v <- compute; kvSet k v; pure v }\n",
638 ));
639 out.push_str(concat!(
640 "kvModify :: Text -> (Maybe Value -> Value) -> M Value\n",
641 "kvModify k f = do\n",
642 " old <- kvGet k\n",
643 " let new = f old\n",
644 " kvSet k new\n",
645 " pure new\n",
646 ));
647 out.push_str(concat!(
648 "kvIncr :: Text -> M Int\n",
649 "kvIncr k = do\n",
650 " old <- kvGet k\n",
651 " let n = case old >>= (^? _Int) of { Just i -> i; _ -> 0 }\n",
652 " let n' = n + 1\n",
653 " kvSet k (toJSON n')\n",
654 " pure n'\n",
655 ));
656 out.push_str(concat!(
657 "kvAppend :: Text -> Value -> M [Value]\n",
658 "kvAppend k v = do\n",
659 " old <- kvGet k\n",
660 " let xs = case old >>= (^? _Array) of { Just arr -> arr; _ -> [] }\n",
661 " let xs' = xs ++ [v]\n",
662 " kvSet k (toJSON xs')\n",
663 " pure xs'\n",
664 ));
665 out.push_str(concat!(
666 "supervised :: Text -> M Value -> (Value -> Maybe a) -> M a\n",
667 "supervised label body check = do\n",
668 " say (\"[\" <> label <> \"] running...\")\n",
669 " v <- body\n",
670 " case check v of\n",
671 " Just a -> say (\"[\" <> label <> \"] done\") >> pure a\n",
672 " Nothing -> do\n",
673 " correction <- ask (\"[\" <> label <> \"] result: \" <> show v <> \"\\nHow should I adjust?\")\n",
674 " supervised label body check\n",
675 ));
676 out.push_str(concat!(
677 "gather :: [(Text, Value -> a)] -> M [a]\n",
678 "gather [] = pure []\n",
679 "gather ((q, parse):rest) = do\n",
680 " v <- ask q\n",
681 " as <- gather rest\n",
682 " pure (parse v : as)\n",
683 ));
684 out.push_str(concat!(
685 "mapFiles :: [Text] -> (Text -> Text -> M Text) -> M [Text]\n",
686 "mapFiles paths transform = mapM (\\p -> do\n",
687 " content <- fsRead p\n",
688 " result <- transform p content\n",
689 " fsWrite p result\n",
690 " pure p) paths\n",
691 ));
692 out.push_str(concat!(
693 "searchProcess :: Lang -> Text -> [Text] -> (Match -> M a) -> M [a]\n",
694 "searchProcess lang pat paths process = do\n",
695 " matches <- sgFind lang pat paths\n",
696 " mapM process matches\n",
697 ));
698 out.push_str(concat!(
699 "readGlob :: Text -> M [(Text, Text)]\n",
700 "readGlob pat = fsGlob pat >>= mapM (\\p -> (,) p <$> fsRead p)\n",
701 ));
702 out.push_str(concat!(
703 "runChecked :: Text -> M Text\n",
704 "runChecked cmd = do\n",
705 " (ec, out, err) <- run cmd\n",
706 " if ec == 0 then pure out\n",
707 " else error (\"command failed: \" <> cmd <> \"\\n\" <> err)\n",
708 ));
709 out.push_str(concat!(
710 "runJson :: Text -> M Value\n",
711 "runJson = send . RunJson\n",
712 ));
713 out.push_str(concat!(
714 "mapFile :: Text -> (Text -> Text) -> M ()\n",
715 "mapFile path f = fsRead path >>= \\c -> fsWrite path (f c)\n",
716 ));
717 out.push_str(concat!(
718 "mapFileM :: Text -> (Text -> M Text) -> M ()\n",
719 "mapFileM path f = fsRead path >>= f >>= fsWrite path\n",
720 ));
721 out.push_str(concat!(
722 "searchFiles :: Text -> Text -> M [(Text, Int, Text)]\n",
723 "searchFiles pat needle = do\n",
724 " files <- fsGlob pat\n",
725 " fmap concat $ forM files $ \\path -> do\n",
726 " content <- fsRead path\n",
727 " let ls = zip [(1::Int)..] (T.lines content)\n",
728 " pure [(path, n, l) | (n, l) <- ls, T.isInfixOf needle l]\n",
729 ));
730 out.push_str(concat!(
731 "lineCount :: Text -> M Int\n",
732 "lineCount path = length . T.lines <$> fsRead path\n",
733 ));
734 out.push_str(concat!(
735 "fileContains :: Text -> Text -> M Bool\n",
736 "fileContains path needle = T.isInfixOf needle <$> fsRead path\n",
737 ));
738 out.push_str(concat!(
739 "kvAll :: M [(Text, Value)]\n",
740 "kvAll = do\n",
741 " ks <- kvKeys\n",
742 " vs <- mapM kvGet ks\n",
743 " pure (zipWith (\\k mv -> (k, maybe Null id mv)) ks vs)\n",
744 ));
745 out.push_str(concat!(
746 "kvClear :: M ()\n",
747 "kvClear = kvKeys >>= mapM_ kvDel\n",
748 ));
749 out.push_str(concat!(
750 "runAll :: [Text] -> M [(Int, Text, Text)]\n",
751 "runAll = mapM run\n",
752 ));
753
754 out.push_str(concat!(
758 "extLang :: Text -> Maybe Lang\n",
759 "extLang f\n",
760 " | T.isSuffixOf \".rs\" f = Just Rust\n",
761 " | T.isSuffixOf \".py\" f = Just Python\n",
762 " | T.isSuffixOf \".ts\" f = Just TypeScript\n",
763 " | T.isSuffixOf \".tsx\" f = Just TypeScript\n",
764 " | T.isSuffixOf \".js\" f = Just JavaScript\n",
765 " | T.isSuffixOf \".jsx\" f = Just JavaScript\n",
766 " | T.isSuffixOf \".go\" f = Just Go\n",
767 " | T.isSuffixOf \".java\" f = Just Java\n",
768 " | T.isSuffixOf \".c\" f = Just C\n",
769 " | T.isSuffixOf \".cpp\" f = Just Cpp\n",
770 " | T.isSuffixOf \".cc\" f = Just Cpp\n",
771 " | T.isSuffixOf \".hs\" f = Just Haskell\n",
772 " | T.isSuffixOf \".nix\" f = Just Nix\n",
773 " | otherwise = Nothing\n",
774 ));
775
776 out.push_str(concat!(
778 "funcPattern :: Lang -> Text\n",
779 "funcPattern Rust = \"fn $NAME\"\n",
780 "funcPattern Python = \"def $NAME\"\n",
781 "funcPattern Go = \"func $NAME\"\n",
782 "funcPattern JavaScript = \"function $NAME\"\n",
783 "funcPattern TypeScript = \"function $NAME\"\n",
784 "funcPattern Java = \"$TYPE $NAME($$$PARAMS)\"\n",
785 "funcPattern C = \"$TYPE $NAME($$$PARAMS)\"\n",
786 "funcPattern Cpp = \"$TYPE $NAME($$$PARAMS)\"\n",
787 "funcPattern Haskell = \"$NAME $$$ARGS = $$$BODY\"\n",
788 "funcPattern _ = \"$NAME\"\n",
789 ));
790
791 out.push_str(concat!(
793 "changedFunctions :: Text -> M [Value]\n",
794 "changedFunctions ref = do\n",
795 " diffs <- gitDiff ref\n",
796 " let entries = catMaybes $ map (\\d -> do\n",
797 " p <- d ?. \"path\" >>= asText\n",
798 " s <- d ?. \"status\" >>= asText\n",
799 " lang <- extLang p\n",
800 " Just (p, s, lang)) diffs\n",
801 " forM entries $ \\(p, s, lang) -> do\n",
802 " fns <- if s == \"D\" then pure []\n",
803 " else do\n",
804 " ms <- sgFind lang (funcPattern lang) [p]\n",
805 " pure $ map (\\m -> object [\"name\" .= var m \"NAME\", \"line\" .= mLine m]) ms\n",
806 " pure $ object [\"file\" .= p, \"status\" .= s, \"functions\" .= fns]\n",
807 ));
808
809 out.push_str(concat!(
811 "reviewCommit :: Text -> M Value\n",
812 "reviewCommit hash = do\n",
813 " meta <- gitShow hash\n",
814 " diffs <- gitDiff hash\n",
815 " let h = maybe \"\" id (meta ?. \"hash\" >>= asText)\n",
816 " let subj = maybe \"\" id (meta ?. \"subject\" >>= asText)\n",
817 " let auth = maybe \"\" id (meta ?. \"author\" >>= asText)\n",
818 " let dt = maybe 0 id (meta ?. \"date\" >>= asInt)\n",
819 " let bod = maybe \"\" id (meta ?. \"body\" >>= asText)\n",
820 " let entries = catMaybes $ map (\\d -> do\n",
821 " p <- d ?. \"path\" >>= asText\n",
822 " s <- d ?. \"status\" >>= asText\n",
823 " Just (p, s, extLang p)) diffs\n",
824 " files <- forM entries $ \\(p, s, ml) -> do\n",
825 " fns <- case ml of\n",
826 " Nothing -> pure []\n",
827 " Just lang -> if s == \"D\" then pure []\n",
828 " else do\n",
829 " ms <- sgFind lang (funcPattern lang) [p]\n",
830 " pure $ map (\\m -> object [\"name\" .= var m \"NAME\", \"line\" .= mLine m]) ms\n",
831 " pure $ object [\"file\" .= p, \"status\" .= s, \"functions\" .= fns]\n",
832 " pure $ object [\"hash\" .= h, \"subject\" .= subj, \"author\" .= auth,\n",
833 " \"date\" .= dt, \"body\" .= bod, \"files\" .= files]\n",
834 ));
835
836 out.push_str(concat!(
838 "staleBranches :: Int -> M [Value]\n",
839 "staleBranches maxDays = do\n",
840 " branches <- gitBranches\n",
841 " (_, nowStr, _) <- run \"date +%s\"\n",
842 " let now = maybe 0 id (parseIntM (T.strip nowStr))\n",
843 " results <- fmap catMaybes $ forM branches $ \\b -> do\n",
844 " let mname = b ?. \"name\" >>= asText\n",
845 " let mcommit = b ?. \"commit\" >>= asText\n",
846 " let isHead = maybe False id (b ?. \"is_head\" >>= asBool)\n",
847 " case (mname, mcommit) of\n",
848 " (Just name, Just c) -> do\n",
849 " info <- gitShow c\n",
850 " let date = maybe 0 id (info ?. \"date\" >>= asInt)\n",
851 " let auth = maybe \"\" id (info ?. \"author\" >>= asText)\n",
852 " let subj = maybe \"\" id (info ?. \"subject\" >>= asText)\n",
853 " let age = quot (now - date) 86400\n",
854 " if age >= maxDays\n",
855 " then pure $ Just $ object [\"name\" .= name, \"author\" .= auth,\n",
856 " \"subject\" .= subj, \"days_old\" .= age, \"is_head\" .= isHead]\n",
857 " else pure Nothing\n",
858 " _ -> pure Nothing\n",
859 " pure $ sortBy (\\a b -> let ga = maybe 0 id (a ?. \"days_old\" >>= asInt)\n",
860 " gb = maybe 0 id (b ?. \"days_old\" >>= asInt)\n",
861 " in compare gb ga) results\n",
862 ));
863
864 out.push_str(concat!(
866 "findAndPreview :: Lang -> Text -> Text -> [Text] -> M [Value]\n",
867 "findAndPreview lang pat repl paths = do\n",
868 " ms <- sgPreview lang pat repl paths\n",
869 " pure $ map (\\m -> object [\"file\" .= mFile m, \"line\" .= mLine m,\n",
870 " \"original\" .= mText m, \"replacement\" .= mReplacement m]) ms\n",
871 ));
872
873 out.push_str(concat!(
875 "todoScan :: Text -> M [Value]\n",
876 "todoScan pat = do\n",
877 " files <- fsGlob pat\n",
878 " fmap concat $ forM files $ \\path -> do\n",
879 " content <- fsRead path\n",
880 " let ls = zip [(1::Int)..] (T.lines content)\n",
881 " let hits = concatMap (\\(n, l) ->\n",
882 " let tags = filter (\\t -> T.isInfixOf t l) [\"TODO\", \"FIXME\", \"HACK\", \"XXX\"]\n",
883 " in map (\\t -> (n, l, t)) tags) ls\n",
884 " forM hits $ \\(n, l, tag) -> do\n",
885 " bl <- gitBlame path n n\n",
886 " let auth = case bl of { (x:_) -> maybe \"\" id (x ?. \"author\" >>= asText); _ -> \"\" }\n",
887 " let comm = case bl of { (x:_) -> maybe \"\" id (x ?. \"commit\" >>= asText); _ -> \"\" }\n",
888 " pure $ object [\"file\" .= path, \"line\" .= n, \"tag\" .= tag,\n",
889 " \"text\" .= T.strip l, \"author\" .= auth, \"commit\" .= comm]\n",
890 ));
891
892 out.push_str(concat!(
894 "deadCode :: Lang -> Text -> M [Value]\n",
895 "deadCode lang pat = do\n",
896 " ms <- sgFind lang (funcPattern lang) [pat]\n",
897 " let defs = take 50 $ catMaybes $ map (\\m ->\n",
898 " let n = var m \"NAME\" in\n",
899 " if T.null n then Nothing\n",
900 " else Just (mFile m, mLine m, n)) ms\n",
901 " fmap catMaybes $ forM defs $ \\(file, line, name) -> do\n",
902 " refs <- searchFiles pat name\n",
903 " let others = filter (\\(f, n, _) -> not (f == file && n == line)) refs\n",
904 " if null others then pure $ Just $ object [\"file\" .= file, \"line\" .= line, \"name\" .= name]\n",
905 " else pure Nothing\n",
906 ));
907
908 let has_llm = effects.iter().any(|e| e.type_name == "Llm");
911 let has_ask_eff = effects.iter().any(|e| e.type_name == "Ask");
912 if has_llm && has_ask_eff {
913 out.push_str("-- Heuristic combinators\n");
914 out.push_str(concat!(
915 "data Q a = Q Schema (Value -> a) Double\n",
916 "data Judged a = Sure a | Unsure Double a\n",
917 ));
918 out.push_str(concat!(
919 "instance Functor Q where\n",
920 " fmap f (Q s p t) = Q s (f . p) t\n",
921 ));
922 out.push_str(concat!(
923 "instance Applicative Q where\n",
924 " pure a = Q (SObj []) (const a) 0.6\n",
925 " Q (SObj fs1) p1 t1 <*> Q (SObj fs2) p2 t2 = Q (SObj (fs1 ++ fs2)) (\\v -> p1 v (p2 v)) (if t1 >= t2 then t1 else t2)\n",
926 " Q s1 p1 t1 <*> Q s2 p2 t2 = Q s1 (\\v -> p1 v (p2 v)) (if t1 >= t2 then t1 else t2)\n",
927 ));
928 out.push_str(concat!(
930 "h_aug :: Schema -> Schema\n",
931 "h_aug (SObj fs) = SObj (fs ++ [(\"_understood\", SBool), (\"_confident\", SBool), (\"_unambiguous\", SBool)])\n",
932 "h_aug s = SObj [(\"value\", s), (\"_understood\", SBool), (\"_confident\", SBool), (\"_unambiguous\", SBool)]\n",
933 ));
934 out.push_str(concat!(
935 "h_conf :: Value -> Double\n",
936 "h_conf v =\n",
937 " let b k = case v ^? key k . _Bool of { Just True -> 1.0; _ -> 0.0 }\n",
938 " in (b \"_understood\" + b \"_confident\" + b \"_unambiguous\") / 3.0\n",
939 ));
940 out.push_str(concat!(
941 "h_strip :: Value -> Value\n",
942 "h_strip (Object kvs) = Object (KM.delete (KM.fromText \"_unambiguous\") (KM.delete (KM.fromText \"_confident\") (KM.delete (KM.fromText \"_understood\") kvs)))\n",
943 "h_strip v = v\n",
944 ));
945 out.push_str(concat!(
947 "infixl 1 ??\n",
948 "(??) :: Q a -> Text -> M a\n",
949 "(Q schema parse threshold) ?? prompt = do\n",
950 " r <- llmJson prompt (h_aug schema)\n",
951 " let c = h_conf r\n",
952 " v <- if c >= threshold then pure (h_strip r)\n",
953 " else ask (prompt <> \"\\n[haiku \" <> pack (showDouble c) <> \"]: \" <> show (h_strip r))\n",
954 " pure (parse v)\n",
955 ));
956 out.push_str(concat!(
958 "infixl 1 ?!\n",
959 "(?!) :: Q a -> Text -> M (Judged a)\n",
960 "(Q schema parse threshold) ?! prompt = do\n",
961 " r <- llmJson prompt (h_aug schema)\n",
962 " let c = h_conf r\n",
963 " if c >= threshold\n",
964 " then pure (Sure (parse (h_strip r)))\n",
965 " else do\n",
966 " v <- ask (prompt <> \"\\n[haiku \" <> pack (showDouble c) <> \"]: \" <> show (h_strip r))\n",
967 " pure (Unsure c (parse v))\n",
968 ));
969 out.push_str(concat!(
971 "pick :: [Text] -> Q Text\n",
972 "pick cats = Q (SObj [(\"pick\", SEnum cats)]) (\\v -> case v ^? key \"pick\" . _String of { Just s -> s; _ -> error \"Q: missing 'pick' in response\" }) 0.6\n",
973 ));
974 out.push_str(concat!(
975 "yn :: Q Bool\n",
976 "yn = Q (SObj [(\"answer\", SBool)]) (\\v -> case v ^? key \"answer\" . _Bool of { Just b -> b; _ -> error \"Q: missing 'answer' in response\" }) 0.6\n",
977 ));
978 out.push_str(concat!(
979 "obj :: Schema -> Q Value\n",
980 "obj s = Q s id 0.6\n",
981 ));
982 out.push_str(concat!(
983 "txt :: Text -> Q Text\n",
984 "txt k = Q (SObj [(k, SStr)]) (\\v -> case v ^? key k . _String of { Just s -> s; _ -> error (\"Q: missing '\" <> k <> \"' in response\") }) 0.6\n",
985 ));
986 out.push_str(concat!(
987 "num :: Text -> Q Double\n",
988 "num k = Q (SObj [(k, SNum)]) (\\v -> case v ^? key k . _Number of { Just n -> n; _ -> error (\"Q: missing '\" <> k <> \"' in response\") }) 0.6\n",
989 ));
990 out.push_str(concat!(
991 "bar :: Double -> Q a -> Q a\n",
992 "bar t (Q s p _) = Q s p t\n",
993 ));
994 out.push_str(concat!(
996 "triage :: Q b -> (a -> Text) -> [a] -> M [(a, b)]\n",
997 "triage q render = mapM (\\x -> (,) x <$> (q ?? render x))\n",
998 ));
999 out.push_str(concat!(
1000 "findTally :: Eq a => a -> [(a, Int)] -> Maybe [(a, Int)]\n",
1001 "findTally _ [] = Nothing\n",
1002 "findTally x ((k, n):rest) = if x == k then Just ((k, n + 1) : rest) else case findTally x rest of { Just rest' -> Just ((k, n) : rest'); Nothing -> Nothing }\n",
1003 ));
1004 out.push_str(concat!(
1005 "tallyList :: Eq a => [a] -> [(a, Int)]\n",
1006 "tallyList = foldl' (\\acc x -> case findTally x acc of { Just acc' -> acc'; Nothing -> acc ++ [(x, 1)] }) []\n",
1007 ));
1008 out.push_str(concat!(
1009 "survey :: Eq b => Q b -> (a -> Text) -> [a] -> M [(b, Int)]\n",
1010 "survey q render xs = do\n",
1011 " bs <- mapM (\\x -> q ?? render x) xs\n",
1012 " pure (tallyList bs)\n",
1013 ));
1014 out.push_str(concat!(
1015 "sift :: Q Bool -> (a -> Text) -> [a] -> M ([a], [a])\n",
1016 "sift q render xs = do\n",
1017 " tagged <- mapM (\\x -> (,) x <$> (q ?? render x)) xs\n",
1018 " pure (map fst (filter snd tagged), map fst (filter (not . snd) tagged))\n",
1019 ));
1020 }
1021
1022 out.push('\n');
1023 }
1024
1025 out
1026}
1027
1028pub fn aeson_imports() -> String {
1032 concat!(
1033 "qualified Tidepool.Aeson as Aeson\n",
1034 "qualified Tidepool.Aeson.KeyMap as KM\n",
1035 )
1036 .into()
1037}
1038
1039pub fn build_effect_stack_type(effects: &[EffectDecl]) -> String {
1040 if effects.is_empty() {
1041 "'[]".to_string()
1042 } else {
1043 let names: Vec<&str> = effects.iter().map(|e| e.type_name).collect();
1044 format!("'[{}]", names.join(", "))
1045 }
1046}
1047
1048fn build_eval_tool_description(effects: &[EffectDecl]) -> String {
1049 let mut desc = String::from(concat!(
1050 "Write Haskell do-notation in `code`. The server wraps it in a module ",
1051 "with the effect stack, pragmas, and imports. ",
1052 "Use `pure x` as the last line to return a value. ",
1053 "Use `send (Constructor args)` to invoke effects. ",
1054 "First call is slow (~2s). Subsequent calls are cached.\n",
1055 "Return values are automatically rendered to JSON by the Rust runtime \u{2014} ",
1056 "Int becomes a number, [Char] becomes a string, Bool becomes true/false, ",
1057 "lists become arrays, etc. Prefer `pure x` over `send (Print (show x))` ",
1058 "for returning results.",
1059 ));
1060
1061 if !effects.is_empty() {
1062 desc.push_str("\nAvailable effects (use `send` to invoke):\n");
1063 for eff in effects {
1064 desc.push_str(&format!("\n{}: {}\n", eff.type_name, eff.description));
1065 for ctor in eff.constructors {
1066 desc.push_str(&format!(" {}\n", ctor));
1067 }
1068 }
1069
1070 let has_console = effects.iter().any(|e| e.type_name == "Console");
1072 let has_helpers = has_console || effects.iter().any(|e| !e.helpers.is_empty());
1073 if has_helpers {
1074 desc.push_str("\nBuilt-in helpers (always available, no need to define):\n");
1075 if has_console {
1076 desc.push_str(" say :: Text -> M ()\n");
1077 }
1078 for eff in effects {
1079 for h in eff.helpers {
1080 if let Some(sig) = h.lines().next() {
1082 desc.push_str(&format!(" {}\n", sig));
1083 }
1084 }
1085 }
1086 desc.push_str(
1087 "\nPrefer helpers over raw `send`: `say \"hi\"` not `send (Print \"hi\")`.\n",
1088 );
1089 desc.push_str("Use `>>=` chains and `<$>`/`<*>` for dense composition. Named bindings as escape hatch.\n");
1090 desc.push('\n');
1091 desc.push_str(concat!(
1092 "User library: `Library` is auto-imported from `.tidepool/lib/Library.hs`. ",
1093 "Other modules in `.tidepool/lib/` can be imported explicitly via the `imports` field.\n\n",
1094 "Prelude polymorphic ops: `len` for length of Text or [a], ",
1095 "`isNull` for emptiness of Text or [a], ",
1096 "`stake`/`sdrop` for take/drop on both Text and [a]. ",
1097 "`intercalate` joins Text (not lists). ",
1098 "`joinText` is an alias. `tReverse` reverses Text. ",
1099 "List-only: `length`, `take`, `drop`, `null` remain unchanged.",
1100 ));
1101 }
1102
1103 let has_llm = effects.iter().any(|e| e.type_name == "Llm");
1104 let has_ask_desc = effects.iter().any(|e| e.type_name == "Ask");
1105 if has_llm && has_ask_desc {
1106 desc.push_str(concat!(
1107 "\n\nHeuristic combinators (Library, auto-imported):\n",
1108 " Q a — first-class question (schema + parser + confidence gate)\n",
1109 " pick cats ?? prompt -- classify (M Text)\n",
1110 " yn ?? prompt -- yes/no (M Bool)\n",
1111 " obj schema ?? prompt -- structured extraction (M Value)\n",
1112 " txt \"field\" ?? prompt -- single text field (M Text)\n",
1113 " num \"field\" ?? prompt -- single number field (M Double)\n",
1114 " (,) <$> pick cs <*> num \"n\" ?? p -- Applicative: merged schema, one call\n",
1115 " bar 0.95 q ?? prompt -- raise threshold\n",
1116 " q ?! prompt -- returns Sure a | Unsure Double a\n",
1117 " triage q render items -- batch: [(item, answer)]\n",
1118 " survey q render items -- tally: [(answer, count)]\n",
1119 " sift yn render items -- partition: ([true], [false])\n",
1120 ));
1121 }
1122 }
1123
1124 desc
1125}
1126
1127pub fn template_haskell(
1128 preamble: &str,
1129 effect_stack: &str,
1130 code: &str,
1131 imports: &str,
1132 helpers: &str,
1133 input: Option<&serde_json::Value>,
1134 budget: Option<u32>,
1135) -> String {
1136 let mut out = String::new();
1137
1138 if !imports.is_empty() {
1142 let insert_point = preamble.find("default (Int").unwrap_or(preamble.len());
1143 out.push_str(&preamble[..insert_point]);
1144 for imp in imports.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1145 out.push_str(&format!("import {}\n", imp));
1146 }
1147 out.push_str(&preamble[insert_point..]);
1148 } else {
1149 out.push_str(preamble);
1150 }
1151
1152 out.push_str("-- [user]\n");
1154
1155 if !helpers.is_empty() {
1156 out.push_str(helpers);
1157 if !helpers.ends_with('\n') {
1158 out.push('\n');
1159 }
1160 out.push('\n');
1161 }
1162
1163 if let Some(val) = input {
1165 out.push_str("input :: Aeson.Value\n");
1166 out.push_str(&format!("input = {}\n\n", json_to_haskell(val)));
1167 }
1168
1169 out.push_str(&format!("result :: Eff {} Value\n", effect_stack));
1170 out.push_str("result = do\n");
1171 if budget.is_some() {
1172 out.push_str(" kvSet \"__sayChars\" (toJSON (0 :: Int))\n");
1173 }
1174 out.push_str(" _r <- do\n");
1175 for line in code.lines() {
1176 out.push_str(&format!(" {}\n", line));
1177 }
1178 if let Some(b) = budget {
1179 out.push_str(" _scV <- kvGet \"__sayChars\"\n");
1180 out.push_str(" let _sayC = case _scV of { Just b -> case b ^? _Number of { Just n -> round n; _ -> 0 }; Nothing -> 0 }\n");
1181 out.push_str(&format!(
1182 " paginateResult (max' 100 ({} - _sayC)) (toJSON _r)\n",
1183 b
1184 ));
1185 } else {
1186 out.push_str(" paginateResult 4096 (toJSON _r)\n");
1187 }
1188
1189 out
1190}
1191
1192fn json_to_haskell(val: &serde_json::Value) -> String {
1194 match val {
1195 serde_json::Value::Null => "Aeson.Null".into(),
1196 serde_json::Value::Bool(b) => {
1197 format!("Aeson.Bool {}", if *b { "True" } else { "False" })
1198 }
1199 serde_json::Value::Number(n) => {
1200 format!("Aeson.Number (fromIntegral ({} :: Int))", n)
1201 }
1202 serde_json::Value::String(s) => {
1203 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
1204 format!("Aeson.String \"{}\"", escaped)
1205 }
1206 serde_json::Value::Array(arr) => {
1207 let elems: Vec<String> = arr.iter().map(json_to_haskell).collect();
1208 format!("toJSON [{}]", elems.join(", "))
1209 }
1210 serde_json::Value::Object(map) => {
1211 let pairs: Vec<String> = map
1212 .iter()
1213 .map(|(k, v)| {
1214 let escaped_k = k.replace('\\', "\\\\").replace('"', "\\\"");
1215 format!("\"{}\" .= {}", escaped_k, json_to_haskell(v))
1216 })
1217 .collect();
1218 format!("object [{}]", pairs.join(", "))
1219 }
1220 }
1221}
1222
1223fn format_panic_payload(payload: Box<dyn std::any::Any + Send>) -> String {
1228 if let Some(s) = payload.downcast_ref::<String>() {
1229 s.clone()
1230 } else if let Some(s) = payload.downcast_ref::<&str>() {
1231 s.to_string()
1232 } else {
1233 "unknown panic".to_string()
1234 }
1235}
1236
1237fn format_error_with_source(title: &str, error: &str, source: &str) -> String {
1238 let user_section = source
1240 .find("-- [user]\n")
1241 .map(|pos| &source[pos + "-- [user]\n".len()..])
1242 .unwrap_or(source);
1243 format!(
1244 "## {}\n{}\n\n## User Code\n```haskell\n{}\n```",
1245 title, error, user_section
1246 )
1247}
1248
1249fn rejected_import(import_str: &str) -> Option<&str> {
1255 const BLOCKED: &[&str] = &[
1256 "System.IO.Unsafe",
1257 "System.IO",
1258 "System.Process",
1259 "System.Posix",
1260 "System.Directory",
1261 "System.Environment",
1262 "GHC.IO",
1263 "GHC.Conc",
1264 "Foreign",
1265 "Network",
1266 "Control.Concurrent",
1267 ];
1268 let mut parts = import_str.split_whitespace();
1270 let mut module = parts.next().unwrap_or("");
1271 if module == "qualified" {
1272 module = parts.next().unwrap_or("");
1273 }
1274 let module = module.split('(').next().unwrap_or("").trim();
1276
1277 for prefix in BLOCKED {
1278 if module.starts_with(prefix) {
1279 return Some(module);
1280 }
1281 }
1282 None
1283}
1284
1285#[derive(Clone, Default)]
1293pub struct CapturedOutput {
1294 lines: Arc<std::sync::Mutex<Vec<String>>>,
1295}
1296
1297impl CapturedOutput {
1298 pub fn new() -> Self {
1299 Self::default()
1300 }
1301
1302 pub fn push(&self, line: String) {
1304 self.lines
1305 .lock()
1306 .unwrap_or_else(|e| {
1307 tracing::warn!("CapturedOutput mutex was poisoned, recovering");
1308 e.into_inner()
1309 })
1310 .push(line);
1311 }
1312
1313 pub fn drain(&self) -> Vec<String> {
1315 let mut lines = self.lines.lock().unwrap_or_else(|e| {
1316 tracing::warn!("CapturedOutput mutex was poisoned, recovering");
1317 e.into_inner()
1318 });
1319 std::mem::take(&mut *lines)
1320 }
1321
1322 pub fn snapshot(&self) -> Vec<String> {
1324 self.lines
1325 .lock()
1326 .unwrap_or_else(|e| {
1327 tracing::warn!("CapturedOutput mutex was poisoned, recovering");
1328 e.into_inner()
1329 })
1330 .clone()
1331 }
1332}
1333
1334enum SessionMessage {
1340 Suspended { prompt: String },
1342 Completed { result: String },
1344 Error { error: String },
1346}
1347
1348struct EvalSession {
1350 response_tx: std::sync::mpsc::Sender<String>,
1352 session_rx: tokio::sync::mpsc::UnboundedReceiver<SessionMessage>,
1354 source: Arc<str>,
1356 created_at: std::time::Instant,
1358 captured_output: CapturedOutput,
1360}
1361
1362struct AskDispatcher {
1367 inner: Box<dyn McpEffectHandler>,
1368 ask_tag: u64,
1369 session_tx: tokio::sync::mpsc::UnboundedSender<SessionMessage>,
1370 response_rx: std::sync::mpsc::Receiver<String>,
1371}
1372
1373impl DispatchEffect<CapturedOutput> for AskDispatcher {
1374 fn dispatch(
1375 &mut self,
1376 tag: u64,
1377 request: &tidepool_eval::value::Value,
1378 cx: &tidepool_effect::dispatch::EffectContext<'_, CapturedOutput>,
1379 ) -> Result<tidepool_eval::value::Value, tidepool_effect::error::EffectError> {
1380 if tag == self.ask_tag {
1381 let prompt = extract_ask_prompt(request, cx.table())
1383 .map_err(tidepool_effect::error::EffectError::Handler)?;
1384
1385 let _ = self.session_tx.send(SessionMessage::Suspended { prompt });
1387
1388 let response = self.response_rx.recv().map_err(|_| {
1390 tidepool_effect::error::EffectError::Handler(
1391 "Ask session closed (timeout or client disconnected)".into(),
1392 )
1393 })?;
1394
1395 let json_val: serde_json::Value =
1397 serde_json::from_str(&response).unwrap_or(serde_json::Value::String(response));
1398 let core_val = json_val
1399 .to_value(cx.table())
1400 .map_err(tidepool_effect::error::EffectError::Bridge)?;
1401 Ok(core_val)
1402 } else {
1403 self.inner.dispatch(tag, request, cx)
1404 }
1405 }
1406}
1407
1408fn extract_ask_prompt(
1414 request: &tidepool_eval::value::Value,
1415 table: &tidepool_repr::DataConTable,
1416) -> Result<String, String> {
1417 use tidepool_eval::value::Value;
1418
1419 if let Value::Con(_, fields) = request {
1420 if let Some(prompt_val) = fields.first() {
1421 match String::from_value(prompt_val, table) {
1423 Ok(s) => return Ok(s),
1424 Err(e) => {
1425 return Err(format!(
1429 "ask prompt could not be evaluated to Text: {e}. \
1430 The expression passed to `ask` likely crashed during evaluation \
1431 (check for unresolved externals or runtime errors in the prompt string)."
1432 ));
1433 }
1434 }
1435 }
1436 }
1437 Err(format!(
1438 "ask received unexpected request shape (expected Con(Ask, [text])): {:?}",
1439 request
1440 ))
1441}
1442
1443pub trait McpEffectHandler:
1449 DispatchEffect<CapturedOutput> + DynClone + Send + Sync + 'static
1450{
1451}
1452clone_trait_object!(McpEffectHandler);
1453
1454impl<T> McpEffectHandler for T where
1455 T: DispatchEffect<CapturedOutput> + Clone + Send + Sync + 'static
1456{
1457}
1458
1459#[derive(Clone)]
1461pub struct TidepoolMcpServer<H> {
1462 inner: TidepoolMcpServerImpl,
1463 _phantom: PhantomData<H>,
1464}
1465
1466#[derive(Clone)]
1468pub struct TidepoolMcpServerImpl {
1469 handler_factory: Arc<dyn McpEffectHandler>,
1470 include: Vec<PathBuf>,
1471 haskell_preamble: String,
1472 effect_stack_type: String,
1473 eval_tool_description: String,
1474 has_user_library: bool,
1476 ask_tag: u64,
1478 effect_names: Vec<String>,
1480 continuations: Arc<std::sync::Mutex<HashMap<String, EvalSession>>>,
1481 next_cont_id: Arc<AtomicU64>,
1482 eval_semaphore: Arc<tokio::sync::Semaphore>,
1483}
1484
1485impl TidepoolMcpServerImpl {
1486 fn next_continuation_id(&self) -> String {
1487 let id = self.next_cont_id.fetch_add(1, Ordering::Relaxed);
1488 format!("cont_{}", id)
1489 }
1490
1491 fn evict_oldest_continuation(&self) {
1495 let mut conts = self.continuations.lock().unwrap_or_else(|e| e.into_inner());
1496 if let Some(oldest_key) = conts
1497 .iter()
1498 .min_by_key(|(_, s)| s.created_at)
1499 .map(|(k, _)| k.clone())
1500 {
1501 tracing::info!(cont_id = %oldest_key, "evicting oldest continuation under pressure");
1502 conts.remove(&oldest_key);
1503 }
1504 }
1505
1506 async fn handle_session_result(
1507 &self,
1508 op: &str,
1509 mut session_rx: tokio::sync::mpsc::UnboundedReceiver<SessionMessage>,
1510 source: Arc<str>,
1511 response_tx: std::sync::mpsc::Sender<String>,
1512 captured_output: CapturedOutput,
1513 ) -> Result<CallToolResult, McpError> {
1514 let eval_timeout = Duration::from_secs(EVAL_TIMEOUT_SECS);
1515 match timeout(eval_timeout, session_rx.recv()).await {
1516 Ok(Some(message)) => {
1517 let output = match &message {
1518 SessionMessage::Completed { .. } | SessionMessage::Error { .. } => {
1519 captured_output.drain()
1520 }
1521 SessionMessage::Suspended { .. } => captured_output.snapshot(),
1522 };
1523
1524 match message {
1525 SessionMessage::Completed { result } => {
1526 tracing::info!("{} completed", op);
1527 let mut response = String::new();
1528 if !output.is_empty() {
1529 response.push_str("## Output\n");
1530 for line in &output {
1531 response.push_str(line);
1532 response.push('\n');
1533 }
1534 response.push_str("\n## Result\n");
1535 }
1536 response.push_str(&result);
1537 Ok(CallToolResult::success(vec![Content::text(response)]))
1538 }
1539 SessionMessage::Suspended { prompt } => {
1540 tracing::info!(prompt = %prompt, "{} suspended on Ask", op);
1541 let cont_id = self.next_continuation_id();
1542 let mut json_obj = serde_json::json!({
1543 "suspended": true,
1544 "continuation_id": cont_id,
1545 "prompt": prompt,
1546 });
1547 if !output.is_empty() {
1548 if let Some(obj) = json_obj.as_object_mut() {
1549 obj.insert("output".into(), serde_json::Value::from(output));
1550 }
1551 }
1552 self.continuations
1553 .lock()
1554 .unwrap_or_else(|e| {
1555 tracing::warn!("continuation store mutex was poisoned, recovering");
1556 e.into_inner()
1557 })
1558 .insert(
1559 cont_id.clone(),
1560 EvalSession {
1561 response_tx,
1562 session_rx,
1563 source: Arc::clone(&source),
1564 created_at: std::time::Instant::now(),
1565 captured_output,
1566 },
1567 );
1568 Ok(CallToolResult::success(vec![Content::text(
1569 json_obj.to_string(),
1570 )]))
1571 }
1572 SessionMessage::Error { error } => {
1573 let mut error_msg = format_error_with_source("Error", &error, &source);
1574 if !output.is_empty() {
1575 error_msg.push_str("\n\n## Output So Far\n");
1576 for line in &output {
1577 error_msg.push_str(line);
1578 error_msg.push('\n');
1579 }
1580 }
1581 tracing::error!("{} failed: {}", op, error);
1582 Ok(CallToolResult::error(vec![Content::text(error_msg)]))
1583 }
1584 }
1585 }
1586 Ok(None) => {
1587 tracing::error!("{} thread crashed", op);
1588 let mut crash_info = String::new();
1589 let crash_log = async {
1590 use tokio::io::{AsyncReadExt, AsyncSeekExt};
1591 let mut file = tokio::fs::File::open(".tidepool/crash.log").await.ok()?;
1592 let meta = file.metadata().await.ok()?;
1593 let len = meta.len();
1594 const MAX_CRASH_LOG_BYTES: u64 = 65536;
1595 if len > MAX_CRASH_LOG_BYTES {
1596 file.seek(std::io::SeekFrom::End(-(MAX_CRASH_LOG_BYTES as i64)))
1597 .await
1598 .ok()?;
1599 }
1600 let mut buf = Vec::new();
1601 file.read_to_end(&mut buf).await.ok()?;
1602 Some(String::from_utf8_lossy(&buf).into_owned())
1603 }
1604 .await;
1605
1606 if let Some(content) = crash_log {
1607 let lines: Vec<&str> = content.lines().rev().take(5).collect();
1608 if !lines.is_empty() {
1609 crash_info.push_str("\n\n## Recent Crash Log Entries\n```\n");
1610 for line in lines.into_iter().rev() {
1611 crash_info.push_str(line);
1612 crash_info.push('\n');
1613 }
1614 crash_info.push_str("```\n");
1615 }
1616 }
1617 let error_msg = format_error_with_source(
1618 "Crash",
1619 &format!(
1620 "{} thread crashed (likely SIGILL from exhausted case branch or SIGSEGV from invalid memory access). Set RUST_LOG=debug for JIT diagnostics on stderr.{}",
1621 op, crash_info
1622 ),
1623 &source,
1624 );
1625 Ok(CallToolResult::error(vec![Content::text(error_msg)]))
1626 }
1627 Err(_elapsed) => {
1628 tracing::error!("{} timed out after {}s", op, EVAL_TIMEOUT_SECS);
1629 let error_msg = format_error_with_source(
1630 "Timeout",
1631 &format!(
1632 "{} timed out after {}s. This usually means an infinite loop or unbounded recursion.",
1633 op, EVAL_TIMEOUT_SECS
1634 ),
1635 &source,
1636 );
1637 Ok(CallToolResult::error(vec![Content::text(error_msg)]))
1638 }
1639 }
1640 }
1641
1642 async fn eval(&self, req: EvalRequest) -> Result<CallToolResult, McpError> {
1643 tracing::info!(len = req.code.len(), "eval request");
1644
1645 for imp in req
1647 .imports
1648 .lines()
1649 .map(|l| l.trim())
1650 .filter(|l| !l.is_empty())
1651 {
1652 if let Some(module) = rejected_import(imp) {
1653 return Ok(CallToolResult::error(vec![Content::text(format!(
1654 "Blocked import: `{}` is not available in the Tidepool sandbox.",
1655 module,
1656 ))]));
1657 }
1658 }
1659
1660 let mut all_imports = aeson_imports();
1661 all_imports.push_str(&req.imports);
1662 let source: Arc<str> = template_haskell(
1663 &self.haskell_preamble,
1664 &self.effect_stack_type,
1665 &req.code,
1666 &all_imports,
1667 &req.helpers,
1668 req.input.as_ref(),
1669 Some(req.max_len.unwrap_or(4096)),
1670 )
1671 .into();
1672
1673 let handlers = dyn_clone::clone_box(&*self.handler_factory);
1674 let include_refs: Vec<PathBuf> = self.include.clone();
1675 let source_for_blocking = Arc::clone(&source);
1676 let captured = CapturedOutput::new();
1677 let captured_for_blocking = captured.clone();
1678 let ask_tag = self.ask_tag;
1679 let effect_names = self.effect_names.clone();
1680
1681 let (session_tx, session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionMessage>();
1683 let (response_tx, response_rx) = std::sync::mpsc::channel::<String>();
1684
1685 let permit = match self.eval_semaphore.clone().try_acquire_owned() {
1686 Ok(p) => p,
1687 Err(_) => {
1688 self.evict_oldest_continuation();
1690 tokio::task::yield_now().await;
1692 self.eval_semaphore
1693 .clone()
1694 .try_acquire_owned()
1695 .map_err(|_| {
1696 McpError::internal_error(
1697 "Server busy: too many concurrent evaluations. Please try again in a moment.",
1698 None,
1699 )
1700 })?
1701 }
1702 };
1703
1704 let thread_session_tx = session_tx;
1706 let _handle = std::thread::Builder::new()
1707 .name("tidepool-eval".into())
1708 .stack_size(32 * 1024 * 1024)
1709 .spawn(move || {
1710 let _permit = permit;
1711 tidepool_codegen::signal_safety::install();
1715
1716 let include_paths: Vec<&Path> = include_refs.iter().map(|p| p.as_path()).collect();
1717 let mut ask_dispatcher = AskDispatcher {
1718 inner: handlers,
1719 ask_tag,
1720 session_tx: thread_session_tx.clone(),
1721 response_rx,
1722 };
1723
1724 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1725 tidepool_runtime::compile_and_run(
1726 &source_for_blocking,
1727 "result",
1728 &include_paths,
1729 &mut ask_dispatcher,
1730 &captured_for_blocking,
1731 )
1732 }));
1733
1734 match result {
1735 Ok(Ok(eval_result)) => {
1736 let _ = thread_session_tx.send(SessionMessage::Completed {
1737 result: eval_result.to_string_pretty(),
1738 });
1739 }
1740 Ok(Err(e)) => {
1741 let diagnostics = tidepool_runtime::drain_diagnostics();
1742 let mut error_detail = e.to_string();
1743 if let Some(tag_str) = error_detail.strip_prefix("Unhandled effect at tag ")
1745 {
1746 if let Ok(tag) = tag_str.trim().parse::<usize>() {
1747 if tag < effect_names.len() {
1748 let effect_name = &effect_names[tag];
1749 error_detail =
1750 format!("{} (effect: {})", error_detail, effect_name);
1751 }
1752 }
1753 let effects_list: String = effect_names
1754 .iter()
1755 .enumerate()
1756 .map(|(i, name)| format!(" {} = {}", i, name))
1757 .collect::<Vec<_>>()
1758 .join("\n");
1759 error_detail
1760 .push_str(&format!("\n\nRegistered effects:\n{}", effects_list));
1761 }
1762 if !diagnostics.is_empty() {
1763 error_detail.push_str("\n\n## JIT Diagnostics\n");
1764 for d in &diagnostics {
1765 error_detail.push_str(d);
1766 error_detail.push('\n');
1767 }
1768 }
1769 let _ = thread_session_tx.send(SessionMessage::Error {
1770 error: error_detail,
1771 });
1772 }
1773 Err(panic_payload) => {
1774 let diagnostics = tidepool_runtime::drain_diagnostics();
1775 let mut error_detail = format_panic_payload(panic_payload);
1776 if !diagnostics.is_empty() {
1777 error_detail.push_str("\n\n## JIT Diagnostics\n");
1778 for d in &diagnostics {
1779 error_detail.push_str(d);
1780 error_detail.push('\n');
1781 }
1782 }
1783 let _ = thread_session_tx.send(SessionMessage::Error {
1784 error: error_detail,
1785 });
1786 }
1787 }
1788 })
1789 .map_err(|e| McpError::internal_error(format!("thread spawn error: {}", e), None))?;
1790
1791 self.handle_session_result("eval", session_rx, source, response_tx, captured)
1793 .await
1794 }
1795
1796 async fn resume(&self, req: ResumeRequest) -> Result<CallToolResult, McpError> {
1797 tracing::info!(continuation_id = %req.continuation_id, "resume request");
1798
1799 let session = {
1800 let mut conts = self.continuations.lock().unwrap_or_else(|e| {
1801 tracing::warn!("continuation store mutex was poisoned, recovering");
1802 e.into_inner()
1803 });
1804 conts.remove(&req.continuation_id).ok_or_else(|| {
1805 McpError::invalid_params(
1806 format!(
1807 "Unknown or expired continuation_id: {}",
1808 req.continuation_id
1809 ),
1810 None,
1811 )
1812 })?
1813 };
1814
1815 session
1817 .response_tx
1818 .send(req.response)
1819 .map_err(|_| McpError::internal_error("eval thread is no longer running", None))?;
1820
1821 let source = session.source.clone();
1822 let response_tx = session.response_tx.clone();
1823 let captured = session.captured_output.clone();
1824
1825 self.handle_session_result("resume", session.session_rx, source, response_tx, captured)
1827 .await
1828 }
1829}
1830
1831impl ServerHandler for TidepoolMcpServerImpl {
1832 fn get_info(&self) -> ServerInfo {
1833 ServerInfo {
1834 instructions: Some(self.eval_tool_description.clone()),
1835 capabilities: ServerCapabilities::builder().enable_tools().build(),
1836 ..Default::default()
1837 }
1838 }
1839
1840 async fn call_tool(
1841 &self,
1842 request: CallToolRequestParams,
1843 _context: RequestContext<RoleServer>,
1844 ) -> Result<CallToolResult, McpError> {
1845 let args = request.arguments.unwrap_or_default();
1846 match request.name.as_ref() {
1847 "eval" => {
1848 let req: EvalRequest = serde_json::from_value(serde_json::Value::Object(args))
1849 .map_err(|e| {
1850 McpError::invalid_params(format!("invalid params: {}", e), None)
1851 })?;
1852 self.eval(req).await
1853 }
1854 "resume" => {
1855 let req: ResumeRequest = serde_json::from_value(serde_json::Value::Object(args))
1856 .map_err(|e| {
1857 McpError::invalid_params(format!("invalid params: {}", e), None)
1858 })?;
1859 self.resume(req).await
1860 }
1861 _ => Err(McpError {
1862 code: ErrorCode::METHOD_NOT_FOUND,
1863 message: format!("Tool not found: {}", request.name).into(),
1864 data: None,
1865 }),
1866 }
1867 }
1868
1869 async fn list_tools(
1870 &self,
1871 _request: Option<PaginatedRequestParams>,
1872 _context: RequestContext<RoleServer>,
1873 ) -> Result<ListToolsResult, McpError> {
1874 fn schema_to_map(
1875 schema: schemars::Schema,
1876 ) -> Result<Arc<serde_json::Map<String, serde_json::Value>>, McpError> {
1877 let json = serde_json::to_value(&schema).map_err(|e| {
1878 McpError::internal_error(format!("Failed to serialize schema: {}", e), None)
1879 })?;
1880 match json {
1881 serde_json::Value::Object(o) => Ok(Arc::new(o)),
1882 _ => Ok(Arc::new(serde_json::Map::new())),
1883 }
1884 }
1885
1886 let tools = vec![
1887 Tool {
1888 name: "eval".into(),
1889 title: None,
1890 description: Some(self.eval_tool_description.clone().into()),
1891 input_schema: schema_to_map(schemars::schema_for!(EvalRequest))?,
1892 output_schema: None,
1893 annotations: None,
1894 icons: None,
1895 meta: None,
1896 execution: None,
1897 },
1898 Tool {
1899 name: "resume".into(),
1900 title: None,
1901 description: Some(
1902 "Resume a suspended Haskell evaluation. When eval returns \
1903 {\"suspended\": true, \"continuation_id\": \"...\", \"prompt\": \"...\"}, \
1904 call this tool with the continuation_id and your response to the prompt."
1905 .into(),
1906 ),
1907 input_schema: schema_to_map(schemars::schema_for!(ResumeRequest))?,
1908 output_schema: None,
1909 annotations: None,
1910 icons: None,
1911 meta: None,
1912 execution: None,
1913 },
1914 ];
1915
1916 Ok(ListToolsResult {
1917 tools,
1918 next_cursor: None,
1919 meta: None,
1920 })
1921 }
1922}
1923
1924impl<H> TidepoolMcpServer<H>
1929where
1930 H: DispatchEffect<CapturedOutput> + Clone + Send + Sync + 'static + CollectEffectDecls,
1931{
1932 pub fn new(handler: H) -> Self {
1937 let mut decls = H::collect_decls();
1938 let ask_tag = decls.len() as u64;
1939 decls.push(ask_decl());
1940 let effect_names: Vec<String> = decls.iter().map(|d| d.type_name.to_string()).collect();
1941 Self {
1942 inner: TidepoolMcpServerImpl {
1943 handler_factory: Arc::new(handler),
1944 include: Vec::new(),
1945 haskell_preamble: build_preamble(&decls, false),
1946 effect_stack_type: build_effect_stack_type(&decls),
1947 eval_tool_description: build_eval_tool_description(&decls),
1948 has_user_library: false,
1949 ask_tag,
1950 effect_names,
1951 continuations: Arc::new(std::sync::Mutex::new(HashMap::new())),
1952 next_cont_id: Arc::new(AtomicU64::new(1)),
1953 eval_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_EVALS)),
1954 },
1955 _phantom: PhantomData,
1956 }
1957 }
1958
1959 pub fn with_include(mut self, paths: Vec<PathBuf>) -> Self {
1961 self.inner.include = paths;
1962 self
1963 }
1964
1965 pub fn with_prelude(mut self, fallback: PathBuf) -> Self {
1975 let prelude_dir = std::env::var_os("TIDEPOOL_PRELUDE_DIR")
1976 .map(PathBuf::from)
1977 .unwrap_or(fallback);
1978 self.inner.include.push(prelude_dir);
1979
1980 let user_lib = PathBuf::from(".tidepool/lib");
1982 if user_lib.is_dir() {
1983 self.inner.has_user_library = user_lib.join("Library.hs").exists();
1984 self.inner.include.push(user_lib);
1985 if self.inner.has_user_library {
1986 let mut decls = H::collect_decls();
1988 decls.push(ask_decl());
1989 self.inner.haskell_preamble = build_preamble(&decls, true);
1990 self.inner.eval_tool_description.push_str(
1992 "\n\nUser library: `Library` is auto-imported from `.tidepool/lib/Library.hs`. \
1993 Other modules in `.tidepool/lib/` can be imported explicitly via the `imports` field."
1994 );
1995 }
1996 }
1997
1998 self
1999 }
2000
2001 pub async fn serve_stdio(self) -> Result<(), Box<dyn std::error::Error>> {
2003 self.inner
2004 .serve((stdin(), stdout()))
2005 .await?
2006 .waiting()
2007 .await?;
2008 Ok(())
2009 }
2010
2011 pub async fn serve_http(
2013 self,
2014 addr: std::net::SocketAddr,
2015 ) -> Result<(), Box<dyn std::error::Error>> {
2016 use rmcp::transport::streamable_http_server::{
2017 session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
2018 };
2019 use std::sync::Arc;
2020
2021 let template = self.inner;
2022 let config = StreamableHttpServerConfig::default();
2023 let cancel = config.cancellation_token.clone();
2024 let service = StreamableHttpService::new(
2025 move || Ok(template.clone()),
2026 Arc::new(LocalSessionManager::default()),
2027 config,
2028 );
2029 async fn health() -> axum::Json<serde_json::Value> {
2030 axum::Json(serde_json::json!({"status": "ok"}))
2031 }
2032
2033 let router = axum::Router::new()
2034 .route("/health", axum::routing::get(health))
2035 .nest_service("/mcp", service);
2036 let listener = tokio::net::TcpListener::bind(addr).await?;
2037 eprintln!(
2038 "Tidepool MCP v{} listening on http://{}/mcp",
2039 env!("CARGO_PKG_VERSION"),
2040 addr,
2041 );
2042 axum::serve(listener, router)
2043 .with_graceful_shutdown(async move {
2044 tokio::signal::ctrl_c().await.ok();
2045 cancel.cancel();
2046 })
2047 .await?;
2048 Ok(())
2049 }
2050}
2051
2052#[cfg(test)]
2057mod tests {
2058 use super::*;
2059
2060 #[test]
2061 fn test_eval_request_string_code() {
2062 let json = serde_json::json!({"code": "let x = 1\npure x"});
2063 let req: EvalRequest = serde_json::from_value(json).unwrap();
2064 assert_eq!(req.code, "let x = 1\npure x");
2065 assert!(req.imports.is_empty());
2066 assert!(req.helpers.is_empty());
2067 }
2068
2069 #[test]
2070 fn test_eval_request_string_imports() {
2071 let json = serde_json::json!({"code": "pure 42", "imports": "Data.List (sort)\nData.Char"});
2072 let req: EvalRequest = serde_json::from_value(json).unwrap();
2073 assert_eq!(req.imports, "Data.List (sort)\nData.Char");
2074 }
2075
2076 #[test]
2077 fn test_rejected_imports() {
2078 assert!(rejected_import("System.IO.Unsafe (unsafePerformIO)").is_some());
2079 assert!(rejected_import("System.Process (callCommand)").is_some());
2080 assert!(rejected_import("System.Posix.Signals").is_some());
2081 assert!(rejected_import("GHC.IO.Handle").is_some());
2082 assert!(rejected_import("Network.Socket").is_some());
2083 assert!(rejected_import("Control.Concurrent (forkIO)").is_some());
2084 assert!(rejected_import("Foreign.Ptr").is_some());
2085 assert!(rejected_import("Data.List (sort)").is_none());
2087 assert!(rejected_import("Data.Map.Strict").is_none());
2088 assert!(rejected_import("Tidepool.Text").is_none());
2089 assert!(rejected_import("qualified Data.Text as T").is_none());
2090 }
2091
2092 #[test]
2093 fn test_build_preamble() {
2094 let effects = vec![
2095 EffectDecl {
2096 type_name: "Console",
2097 description: "Print output",
2098 constructors: &["Print :: Text -> Console ()"],
2099 type_defs: &[],
2100 helpers: &[],
2101 },
2102 EffectDecl {
2103 type_name: "KV",
2104 description: "Key-value store",
2105 constructors: &[
2106 "KvGet :: Text -> KV (Maybe Text)",
2107 "KvSet :: Text -> Text -> KV ()",
2108 ],
2109 type_defs: &[],
2110 helpers: &[],
2111 },
2112 ];
2113 let preamble = build_preamble(&effects, false);
2114 assert!(preamble.contains("data Console a where"));
2115 assert!(preamble.contains(" Print :: Text -> Console ()"));
2116 assert!(preamble.contains("data KV a where"));
2117 }
2118
2119 #[test]
2120 fn test_build_effect_stack_type() {
2121 let effects = vec![
2122 EffectDecl {
2123 type_name: "Console",
2124 description: "",
2125 constructors: &[],
2126 type_defs: &[],
2127 helpers: &[],
2128 },
2129 EffectDecl {
2130 type_name: "KV",
2131 description: "",
2132 constructors: &[],
2133 type_defs: &[],
2134 helpers: &[],
2135 },
2136 EffectDecl {
2137 type_name: "Fs",
2138 description: "",
2139 constructors: &[],
2140 type_defs: &[],
2141 helpers: &[],
2142 },
2143 ];
2144 assert_eq!(build_effect_stack_type(&effects), "'[Console, KV, Fs]");
2145 assert_eq!(build_effect_stack_type(&[]), "'[]");
2146 }
2147
2148 #[test]
2149 fn test_template_haskell() {
2150 let effects = vec![EffectDecl {
2151 type_name: "Console",
2152 description: "",
2153 constructors: &["Print :: Text -> Console ()"],
2154 type_defs: &[],
2155 helpers: &[],
2156 }];
2157 let preamble = build_preamble(&effects, false);
2158 let stack = build_effect_stack_type(&effects);
2159 let source = "let x = 42\npure x";
2160
2161 let result = template_haskell(&preamble, &stack, source, "", "", None, None);
2162
2163 assert!(result.contains("module Expr where"));
2164 assert!(result.contains("import Control.Monad.Freer hiding (run)"));
2165 assert!(result.contains("data Console a where"));
2166 assert!(result.contains("result :: Eff '[Console] Value"));
2167 assert!(result.contains("result = do"));
2168 assert!(result.contains(" let x = 42"));
2169 assert!(result.contains(" pure x"));
2170 }
2171
2172 #[test]
2173 fn test_eval_tool_description_includes_effects() {
2174 let effects = vec![EffectDecl {
2175 type_name: "Console",
2176 description: "Print to console",
2177 constructors: &["Print :: Text -> Console ()"],
2178 type_defs: &[],
2179 helpers: &["say :: Text -> M ()\nsay = send . Print"],
2180 }];
2181 let desc = build_eval_tool_description(&effects);
2182 assert!(desc.contains("Console: Print to console"));
2183 assert!(desc.contains("Print :: Text -> Console ()"));
2184 assert!(desc.contains("say :: Text -> M ()"));
2185 assert!(desc.contains("Built-in helpers"));
2186 }
2187
2188 #[test]
2189 fn test_preamble_includes_helpers() {
2190 let decls = standard_decls();
2191 let preamble = build_preamble(&decls, false);
2192 assert!(preamble.contains("say :: Text -> M ()\nsay t"));
2193 assert!(preamble.contains("kvGet :: Text -> M (Maybe Value)\nkvGet = send . KvGet"));
2194 assert!(preamble.contains("fsRead :: Text -> M Text\nfsRead = send . FsRead"));
2195 assert!(preamble.contains("httpGet :: Text -> M Value\nhttpGet = send . HttpGet"));
2196 assert!(preamble.contains(
2197 "metaConstructors :: M [(Text, Int)]\nmetaConstructors = send MetaConstructors"
2198 ));
2199 assert!(preamble.contains("metaVersion :: M Text\nmetaVersion = send MetaVersion"));
2200 assert!(preamble.contains("ask :: Text -> M Value\nask = send . Ask"));
2201 }
2202
2203 #[test]
2204 fn test_format_panic_payload() {
2205 use std::any::Any;
2206
2207 let s = "string panic".to_string();
2208 let payload: Box<dyn Any + Send> = Box::new(s);
2209 assert_eq!(format_panic_payload(payload), "string panic");
2210
2211 let s = "str panic";
2212 let payload: Box<dyn Any + Send> = Box::new(s);
2213 assert_eq!(format_panic_payload(payload), "str panic");
2214
2215 let payload: Box<dyn Any + Send> = Box::new(42);
2216 assert_eq!(format_panic_payload(payload), "unknown panic");
2217 }
2218
2219 #[test]
2220 fn test_format_error_with_source() {
2221 let title = "Error";
2222 let error = "Type mismatch";
2223 let source = "preamble stuff\n-- [user]\nresult = do\n pure 42\n";
2224 let formatted = format_error_with_source(title, error, source);
2225
2226 assert!(formatted.contains("## Error"));
2227 assert!(formatted.contains("Type mismatch"));
2228 assert!(formatted.contains("## User Code"));
2229 assert!(formatted.contains("```haskell\nresult = do\n pure 42\n\n```"));
2230 assert!(!formatted.contains("preamble stuff"));
2232 }
2233
2234 #[test]
2235 fn test_format_error_no_marker_shows_full() {
2236 let formatted = format_error_with_source("Error", "oops", "full source");
2237 assert!(formatted.contains("full source"));
2238 }
2239
2240 #[test]
2241 fn test_ask_decl() {
2242 let decl = ask_decl();
2243 assert_eq!(decl.type_name, "Ask");
2244 assert_eq!(decl.constructors.len(), 1);
2245 assert!(decl.constructors[0].contains("Ask :: Text -> Ask Value"));
2246 }
2247
2248 #[test]
2249 fn test_standard_decls_includes_ask() {
2250 let decls = standard_decls();
2251 assert_eq!(decls.len(), 10);
2252 assert_eq!(decls[4].type_name, "Http");
2253 assert_eq!(decls[5].type_name, "Exec");
2254 assert_eq!(decls[6].type_name, "Meta");
2255 assert_eq!(decls[7].type_name, "Git");
2256 assert_eq!(decls[8].type_name, "Llm");
2257 assert_eq!(decls[9].type_name, "Ask");
2258 }
2259
2260 #[test]
2261 fn test_resume_request_parse() {
2262 let json = serde_json::json!({
2263 "continuation_id": "cont_1",
2264 "response": "hello"
2265 });
2266 let req: ResumeRequest = serde_json::from_value(json).unwrap();
2267 assert_eq!(req.continuation_id, "cont_1");
2268 assert_eq!(req.response, "hello");
2269 }
2270
2271 #[test]
2272 fn test_ask_in_preamble() {
2273 let decls = standard_decls();
2274 let preamble = build_preamble(&decls, false);
2275 assert!(preamble.contains("data Ask a where"));
2276 assert!(preamble.contains(" Ask :: Text -> Ask Value"));
2277 assert!(preamble
2278 .contains("type M = Eff '[Console, KV, Fs, SG, Http, Exec, Meta, Git, Llm, Ask]"));
2279 }
2280
2281 #[test]
2282 fn test_ask_in_effect_stack_type() {
2283 let decls = standard_decls();
2284 let stack = build_effect_stack_type(&decls);
2285 assert_eq!(
2286 stack,
2287 "'[Console, KV, Fs, SG, Http, Exec, Meta, Git, Llm, Ask]"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_preamble_hides_run_from_freer() {
2293 let decls = standard_decls();
2294 let preamble = build_preamble(&decls, false);
2295 assert!(preamble.contains("import Control.Monad.Freer hiding (run)"));
2296 assert!(preamble.contains("run :: Text -> M (Int, Text, Text)\nrun = send . Run"));
2298 }
2299
2300 #[test]
2301 fn test_preamble_text_error_shadow() {
2302 let decls = standard_decls();
2303 let preamble = build_preamble(&decls, false);
2304 assert!(preamble.contains("import Tidepool.Prelude hiding (error)"));
2306 assert!(preamble.contains("import qualified Prelude as P"));
2308 assert!(preamble.contains("error :: Text -> a\nerror = P.error . T.unpack"));
2309 }
2310
2311 #[test]
2312 fn test_exec_decl_has_run_json() {
2313 let decl = exec_decl();
2314 assert_eq!(decl.type_name, "Exec");
2315 assert!(decl
2316 .constructors
2317 .iter()
2318 .any(|c| c.contains("RunJson :: Text -> Exec Value")));
2319 assert!(decl
2320 .constructors
2321 .iter()
2322 .any(|c| c.contains("Run :: Text -> Exec (Int, Text, Text)")));
2323 assert!(decl
2324 .constructors
2325 .iter()
2326 .any(|c| c.contains("RunIn :: Text -> Text -> Exec (Int, Text, Text)")));
2327 }
2328
2329 #[test]
2330 fn test_preamble_orchestration_helpers() {
2331 let decls = standard_decls();
2332 let preamble = build_preamble(&decls, true);
2333 assert!(preamble.contains("runChecked :: Text -> M Text"));
2335 assert!(preamble.contains("else error (\"command failed: \""));
2336 assert!(preamble.contains("runJson :: Text -> M Value\nrunJson = send . RunJson"));
2338 assert!(preamble.contains("mapFile :: Text -> (Text -> Text) -> M ()"));
2340 assert!(preamble.contains("mapFileM :: Text -> (Text -> M Text) -> M ()"));
2341 assert!(preamble.contains("searchFiles :: Text -> Text -> M [(Text, Int, Text)]"));
2342 assert!(preamble.contains("lineCount :: Text -> M Int"));
2343 assert!(preamble.contains("fileContains :: Text -> Text -> M Bool"));
2344 assert!(preamble.contains("kvAll :: M [(Text, Value)]"));
2346 assert!(preamble.contains("kvClear :: M ()"));
2347 assert!(preamble.contains("runAll :: [Text] -> M [(Int, Text, Text)]"));
2348 assert!(preamble.contains("extLang :: Text -> Maybe Lang"));
2350 assert!(preamble.contains("funcPattern :: Lang -> Text"));
2351 assert!(preamble.contains("changedFunctions :: Text -> M [Value]"));
2352 assert!(preamble.contains("reviewCommit :: Text -> M Value"));
2353 assert!(preamble.contains("staleBranches :: Int -> M [Value]"));
2354 assert!(preamble.contains("findAndPreview :: Lang -> Text -> Text -> [Text] -> M [Value]"));
2355 assert!(preamble.contains("todoScan :: Text -> M [Value]"));
2356 assert!(preamble.contains("deadCode :: Lang -> Text -> M [Value]"));
2357 assert!(preamble.contains("data Q a = Q Schema (Value -> a) Double"));
2359 assert!(preamble.contains("data Judged a = Sure a | Unsure Double a"));
2360 assert!(preamble.contains("(??) :: Q a -> Text -> M a"));
2361 assert!(preamble.contains("(?!) :: Q a -> Text -> M (Judged a)"));
2362 assert!(preamble.contains("pick :: [Text] -> Q Text"));
2363 assert!(preamble.contains("yn :: Q Bool"));
2364 assert!(preamble.contains("obj :: Schema -> Q Value"));
2365 assert!(preamble.contains("txt :: Text -> Q Text"));
2366 assert!(preamble.contains("num :: Text -> Q Double"));
2367 assert!(preamble.contains("bar :: Double -> Q a -> Q a"));
2368 assert!(preamble.contains("triage :: Q b -> (a -> Text) -> [a] -> M [(a, b)]"));
2369 assert!(preamble.contains("survey :: Eq b => Q b -> (a -> Text) -> [a] -> M [(b, Int)]"));
2370 assert!(preamble.contains("sift :: Q Bool -> (a -> Text) -> [a] -> M ([a], [a])"));
2371 }
2372
2373 #[test]
2374 fn test_preamble_no_orchestration_without_library() {
2375 let decls = standard_decls();
2376 let preamble = build_preamble(&decls, false);
2377 assert!(!preamble.contains("runChecked"));
2379 assert!(!preamble.contains("runJson :: Text -> M Value"));
2380 }
2381
2382 #[test]
2383 fn test_preamble_sg_rule_operators() {
2384 let decls = standard_decls();
2385 let preamble = build_preamble(&decls, false);
2386 assert!(preamble.contains("infixr 6 .+."));
2388 assert!(preamble.contains("(.+.) :: Value -> Value -> Value"));
2389 assert!(preamble.contains("KM.unionWith const"));
2390 assert!(preamble.contains("infixr 5 .&."));
2392 assert!(preamble.contains("infixr 4 .|."));
2393 assert!(preamble.contains("infixl 7 ?>"));
2395 assert!(preamble.contains("infixl 7 <?"));
2396 assert!(preamble.contains("rField :: Text -> Value"));
2398 assert!(preamble.contains("rStopBy :: Text -> Value"));
2399 }
2400
2401 #[test]
2402 fn test_parse_constructor_no_args() {
2403 let p = parse_constructor("GitBranches :: Git [Value]").unwrap();
2404 assert_eq!(
2405 p,
2406 ParsedConstructor {
2407 name: "GitBranches".into(),
2408 arity: 0
2409 }
2410 );
2411 }
2412
2413 #[test]
2414 fn test_parse_constructor_two_args() {
2415 let p = parse_constructor("GitLog :: Text -> Int -> Git [Value]").unwrap();
2416 assert_eq!(
2417 p,
2418 ParsedConstructor {
2419 name: "GitLog".into(),
2420 arity: 2
2421 }
2422 );
2423 }
2424
2425 #[test]
2426 fn test_parse_constructor_nested_types() {
2427 let p =
2428 parse_constructor("HttpRequest :: Text -> Text -> [(Text,Text)] -> Text -> Http Value")
2429 .unwrap();
2430 assert_eq!(
2431 p,
2432 ParsedConstructor {
2433 name: "HttpRequest".into(),
2434 arity: 4
2435 }
2436 );
2437 }
2438
2439 #[test]
2440 fn test_preamble_required_imports() {
2441 let decls = standard_decls();
2442 let preamble = build_preamble(&decls, false);
2443 assert!(preamble.contains("import Tidepool.Prelude hiding (error)"));
2444 assert!(preamble.contains("import qualified Data.Text as T"));
2445 assert!(preamble.contains("import Control.Monad.Freer hiding (run)"));
2446 assert!(preamble.contains("import qualified Tidepool.Aeson.KeyMap as KM"));
2447 }
2448
2449 #[test]
2450 fn test_template_haskell_truncation() {
2451 let effects = vec![EffectDecl {
2452 type_name: "Console",
2453 description: "",
2454 constructors: &["Print :: Text -> Console ()"],
2455 type_defs: &[],
2456 helpers: &[],
2457 }];
2458 let preamble = build_preamble(&effects, false);
2459 let stack = build_effect_stack_type(&effects);
2460 let source = "pure 42";
2461
2462 let result = template_haskell(&preamble, &stack, source, "", "", None, Some(1024));
2464 assert!(result.contains("kvSet \"__sayChars\" (toJSON (0 :: Int))"));
2465 assert!(result.contains("paginateResult (max' 100 (1024 - _sayC)) (toJSON _r)"));
2466
2467 let result = template_haskell(&preamble, &stack, source, "", "", None, None);
2469 assert!(result.contains("paginateResult 4096 (toJSON _r)"));
2470 }
2471
2472 #[test]
2473 fn test_template_haskell_input() {
2474 let effects = vec![EffectDecl {
2475 type_name: "Console",
2476 description: "",
2477 constructors: &["Print :: Text -> Console ()"],
2478 type_defs: &[],
2479 helpers: &[],
2480 }];
2481 let preamble = build_preamble(&effects, false);
2482 let stack = build_effect_stack_type(&effects);
2483 let source = "pure 42";
2484 let input = serde_json::json!({"val": 123});
2485
2486 let result = template_haskell(&preamble, &stack, source, "", "", Some(&input), None);
2487
2488 assert!(result.contains("input :: Aeson.Value"));
2489 assert!(
2490 result.contains("input = object [\"val\" .= Aeson.Number (fromIntegral (123 :: Int))]")
2491 );
2492 }
2493
2494 #[test]
2495 fn test_eval_timeout_value() {
2496 assert_eq!(EVAL_TIMEOUT_SECS, 120);
2497 }
2498
2499 #[test]
2500 fn test_effect_decls_basic_validation() {
2501 let console = console_decl();
2502 assert_eq!(console.type_name, "Console");
2503 assert!(console.constructors[0].contains("Print"));
2504
2505 let kv = kv_decl();
2506 assert_eq!(kv.type_name, "KV");
2507 assert!(kv.constructors.iter().any(|c| c.contains("KvGet")));
2508
2509 let fs = fs_decl();
2510 assert_eq!(fs.type_name, "Fs");
2511 assert!(fs.constructors.iter().any(|c| c.contains("FsRead")));
2512
2513 let http = http_decl();
2514 assert_eq!(http.type_name, "Http");
2515 assert!(http.constructors.iter().any(|c| c.contains("HttpGet")));
2516 }
2517
2518 #[test]
2519 fn test_eval_request_helpers() {
2520 let json = serde_json::json!({
2521 "code": "pure 42",
2522 "helpers": "foo :: Int -> Int\nfoo x = x + 1"
2523 });
2524 let req: EvalRequest = serde_json::from_value(json).unwrap();
2525 assert_eq!(req.helpers, "foo :: Int -> Int\nfoo x = x + 1");
2526 }
2527
2528 #[test]
2529 fn test_eval_request_input() {
2530 let json = serde_json::json!({
2531 "code": "pure 42",
2532 "input": {"key": "value", "num": 123}
2533 });
2534 let req: EvalRequest = serde_json::from_value(json).unwrap();
2535 assert!(req.input.is_some());
2536 let input = req.input.unwrap();
2537 assert_eq!(input["key"], "value");
2538 assert_eq!(input["num"], 123);
2539 }
2540
2541 #[test]
2542 fn test_json_to_haskell() {
2543 let val = serde_json::json!({
2544 "str": "hello",
2545 "bool": true,
2546 "null": null,
2547 "num": 42,
2548 "arr": [1, 2],
2549 "obj": {"a": 1}
2550 });
2551 let haskell = json_to_haskell(&val);
2552 assert!(haskell.contains("\"str\" .= Aeson.String \"hello\""));
2553 assert!(haskell.contains("\"bool\" .= Aeson.Bool True"));
2554 assert!(haskell.contains("\"null\" .= Aeson.Null"));
2555 assert!(haskell.contains("\"num\" .= Aeson.Number (fromIntegral (42 :: Int))"));
2556 assert!(haskell.contains("\"arr\" .= toJSON [Aeson.Number (fromIntegral (1 :: Int)), Aeson.Number (fromIntegral (2 :: Int))]"));
2557 assert!(
2558 haskell.contains("\"obj\" .= object [\"a\" .= Aeson.Number (fromIntegral (1 :: Int))]")
2559 );
2560 }
2561
2562 #[tokio::test]
2563 async fn test_handle_session_result_completed() {
2564 let server = create_mock_server();
2565 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2566 let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2567 let source: Arc<str> = "test source".into();
2568 let captured = CapturedOutput::new();
2569 captured.push("log1".into());
2570
2571 tx.send(SessionMessage::Completed {
2572 result: "42".into(),
2573 })
2574 .unwrap();
2575
2576 let res = server
2577 .handle_session_result("eval", rx, source, resp_tx, captured)
2578 .await
2579 .unwrap();
2580 assert_eq!(res.is_error, Some(false));
2581 let text = match &res.content[0].raw {
2582 RawContent::Text(t) => &t.text,
2583 _ => panic!("Expected text content"),
2584 };
2585 assert!(text.contains("## Output\nlog1\n"));
2586 assert!(text.contains("\n## Result\n42"));
2587 }
2588
2589 #[tokio::test]
2590 async fn test_handle_session_result_suspended() {
2591 let server = create_mock_server();
2592 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2593 let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2594 let source: Arc<str> = "test source".into();
2595 let captured = CapturedOutput::new();
2596
2597 tx.send(SessionMessage::Suspended {
2598 prompt: "what is your name?".into(),
2599 })
2600 .unwrap();
2601
2602 let res = server
2603 .handle_session_result("eval", rx, source, resp_tx, captured)
2604 .await
2605 .unwrap();
2606 assert_eq!(res.is_error, Some(false));
2607 let text = match &res.content[0].raw {
2608 RawContent::Text(t) => &t.text,
2609 _ => panic!("Expected text content"),
2610 };
2611 let json: serde_json::Value = serde_json::from_str(text).unwrap();
2612 assert_eq!(json["suspended"], true);
2613 assert_eq!(json["prompt"], "what is your name?");
2614 assert!(json["continuation_id"]
2615 .as_str()
2616 .unwrap()
2617 .starts_with("cont_"));
2618
2619 let cont_id = json["continuation_id"].as_str().unwrap();
2621 let conts = server.continuations.lock().unwrap();
2622 assert!(conts.contains_key(cont_id));
2623 }
2624
2625 #[tokio::test]
2626 async fn test_handle_session_result_error() {
2627 let server = create_mock_server();
2628 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2629 let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2630 let source: Arc<str> = "test source".into();
2631 let captured = CapturedOutput::new();
2632
2633 tx.send(SessionMessage::Error {
2634 error: "oops".into(),
2635 })
2636 .unwrap();
2637
2638 let res = server
2639 .handle_session_result("eval", rx, source, resp_tx, captured)
2640 .await
2641 .unwrap();
2642 assert_eq!(res.is_error, Some(true));
2643 let text = match &res.content[0].raw {
2644 RawContent::Text(t) => &t.text,
2645 _ => panic!("Expected text content"),
2646 };
2647 assert!(text.contains("## Error"));
2648 assert!(text.contains("oops"));
2649 }
2650
2651 #[tokio::test]
2652 async fn test_handle_session_result_crash() {
2653 let server = create_mock_server();
2654 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2655 let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2656 let source: Arc<str> = "test source".into();
2657 let captured = CapturedOutput::new();
2658
2659 drop(tx);
2661
2662 let res = server
2663 .handle_session_result("eval", rx, source, resp_tx, captured)
2664 .await
2665 .unwrap();
2666 assert_eq!(res.is_error, Some(true));
2667 let text = match &res.content[0].raw {
2668 RawContent::Text(t) => &t.text,
2669 _ => panic!("Expected text content"),
2670 };
2671 assert!(text.contains("## Crash"));
2672 assert!(text.contains("eval thread crashed"));
2673 }
2674
2675 #[tokio::test]
2676 async fn test_handle_session_result_timeout() {
2677 tokio::time::pause();
2678
2679 let server = create_mock_server();
2680 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
2681 let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2682 let source: Arc<str> = "test source".into();
2683 let captured = CapturedOutput::new();
2684
2685 let handle = tokio::spawn(async move {
2686 server
2687 .handle_session_result("eval", rx, source, resp_tx, captured)
2688 .await
2689 });
2690
2691 tokio::time::advance(Duration::from_secs(EVAL_TIMEOUT_SECS + 1)).await;
2693
2694 let res = handle.await.unwrap().unwrap();
2695 assert_eq!(res.is_error, Some(true));
2696 let text = match &res.content[0].raw {
2697 RawContent::Text(t) => &t.text,
2698 _ => panic!("Expected text content"),
2699 };
2700 assert!(text.contains("## Timeout"));
2701 assert!(text.contains("timed out"));
2702 }
2703
2704 fn create_mock_server() -> TidepoolMcpServerImpl {
2705 #[derive(Clone)]
2706 struct MockHandler;
2707 impl DispatchEffect<CapturedOutput> for MockHandler {
2708 fn dispatch(
2709 &mut self,
2710 _tag: u64,
2711 _request: &tidepool_eval::value::Value,
2712 _cx: &tidepool_effect::EffectContext<'_, CapturedOutput>,
2713 ) -> Result<tidepool_eval::value::Value, tidepool_effect::error::EffectError>
2714 {
2715 Ok(tidepool_eval::value::Value::Lit(
2716 tidepool_repr::Literal::LitInt(0),
2717 ))
2718 }
2719 }
2720
2721 TidepoolMcpServerImpl {
2722 handler_factory: Arc::new(MockHandler),
2723 include: Vec::new(),
2724 haskell_preamble: String::new(),
2725 effect_stack_type: String::new(),
2726 eval_tool_description: String::new(),
2727 has_user_library: false,
2728 ask_tag: 0,
2729 effect_names: Vec::new(),
2730 continuations: Arc::new(std::sync::Mutex::new(HashMap::new())),
2731 next_cont_id: Arc::new(AtomicU64::new(1)),
2732 eval_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_EVALS)),
2733 }
2734 }
2735
2736 #[test]
2737 fn test_rejected_import_edge_cases() {
2738 assert!(rejected_import("qualified System.IO.Unsafe as Safe").is_some());
2740 assert!(rejected_import(" System.IO.Unsafe ").is_some());
2742 assert!(rejected_import("Data.Map (Map, fromList)").is_none());
2744 assert!(rejected_import("Tidepool.Table").is_none());
2746 assert!(rejected_import("").is_none());
2748 }
2749
2750 #[test]
2751 fn test_format_error_with_source_multiline() {
2752 let title = "Compile Error";
2753 let error = "Variable not in scope: x";
2754 let source = "module Test where\n-- [user]\nmain = do\n print x\n print y\n print z";
2755 let formatted = format_error_with_source(title, error, source);
2756
2757 assert!(formatted.contains("## Compile Error"));
2758 assert!(formatted.contains("Variable not in scope: x"));
2759 assert!(formatted.contains("## User Code"));
2760 assert!(formatted.contains("main = do\n print x\n print y\n print z"));
2761 assert!(!formatted.contains("module Test where"));
2762 }
2763
2764 #[test]
2765 fn test_format_error_empty_source() {
2766 let formatted = format_error_with_source("Error", "msg", "");
2767 assert!(formatted.contains("## Error"));
2768 assert!(formatted.contains("msg"));
2769 assert!(formatted.contains("## User Code"));
2770 }
2771
2772 #[test]
2773 fn test_captured_output_drain() {
2774 let output = CapturedOutput::new();
2775 output.push("line 1".to_string());
2776 output.push("line 2".to_string());
2777
2778 let drained = output.drain();
2779 assert_eq!(drained, vec!["line 1", "line 2"]);
2780
2781 let empty = output.drain();
2782 assert!(empty.is_empty());
2783 }
2784}