Skip to content
This repository was archived by the owner on Sep 3, 2026. It is now read-only.

Commit 66107b1

Browse files
author
outlndrr
committed
feat(ui): add live SSE debug console
- Replace snapshot polling with session SSE for live chunks - Log run, tool, and SSE lifecycle for troubleshooting - Load .env via glenvy for local config - Fix OpenRouter stream setup and web_search header parsing
1 parent 8e7b5b1 commit 66107b1

22 files changed

Lines changed: 1715 additions & 9 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
build
2+
ui/build
3+
.env
24
*.ez
35
erl_crash.dump
46
*.log
57
*.db
8+
*.db-shm
9+
*.db-wal
610
*.sqlite*

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ Health check:
5858
curl http://localhost:4000/health
5959
```
6060

61+
Optional: create `.env` in project root. Runtime loads it at startup via `glenvy` before config parsing. Real process env still wins over `.env` values.
62+
63+
Example:
64+
65+
```env
66+
LUMMY_OPENROUTER_API_KEY=your_key_here
67+
LUMMY_DEFAULT_PROVIDER_ID=openrouter
68+
LUMMY_DEFAULT_MODEL_SELECTION=openai/gpt-4o-mini
69+
```
70+
6171
## Quickstart
6272

6373
Copy-paste flow:
@@ -137,6 +147,33 @@ curl -X POST http://localhost:4000/api/v1/providers/openrouter/verify \
137147
-d '{"model":"openai/gpt-4o-mini","prompt":"Say hello in five words."}'
138148
```
139149

150+
## Simple Lustre UI
151+
152+
UI source lives in `ui/` and is served by backend at `GET /ui`.
153+
154+
Build client once:
155+
156+
```sh
157+
cd ui
158+
gleam build
159+
cd ..
160+
```
161+
162+
Then run server and open:
163+
164+
```sh
165+
gleam run
166+
# browse http://localhost:4000/ui
167+
```
168+
169+
Notes:
170+
- UI currently targets `openrouter` only
171+
- UI talks to same-origin backend routes, so no CORS setup needed
172+
- backend still requires `LUMMY_OPENROUTER_API_KEY`
173+
- UI opens live session SSE stream at `/api/v1/sessions/:id/events`
174+
- UI includes debug pane for HTTP actions, SSE status, and streamed events
175+
- backend now logs request + run/provider/tool lifecycle to Erlang logger
176+
140177
## Config
141178

142179
| Variable | Default | Notes |

docs/http-api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Copy-paste examples live in `docs/quickstart.md`.
1010
- `GET /`
1111
- `GET /health`
1212
- `GET /ready`
13+
- `GET /ui`
1314
- `POST /api/v1/providers/openrouter/verify`
1415

1516
### Session API
@@ -63,6 +64,7 @@ Copy-paste examples live in `docs/quickstart.md`.
6364
## Current limitations
6465
- no websocket API yet
6566
- no session list endpoint yet
67+
- simple Lustre UI exists at `GET /ui`, consumes session SSE directly, and shows a debug pane for live events
6668
- OpenRouter verify route exists for live configuration checks
6769
- OpenRouter assistant streaming now flows through persisted `run.chunk` events
6870
- provider/model execution for agent runs is delegated through `RunActor`

gleam.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ simplifile = ">= 2.4.0 and < 3.0.0"
2626
filepath = ">= 1.1.2 and < 2.0.0"
2727
exception = ">= 2.1.0 and < 3.0.0"
2828
sqlight = ">= 1.0.3 and < 2.0.0"
29+
glenvy = ">= 2.0.1 and < 3.0.0"
2930

3031
[dev_dependencies]
3132
gleeunit = ">= 1.0.0 and < 2.0.0"

manifest.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,20 @@ packages = [
1212
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
1313
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
1414
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
15+
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
1516
{ name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" },
17+
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
1618
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
19+
{ name = "glenvy", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "nibble", "simplifile"], otp_app = "glenvy", source = "hex", outer_checksum = "A954C46D079ABE2D0A31CF36F1EB99371BE38E5F85BF821A8C90B77022852CC6" },
1720
{ name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" },
1821
{ name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
1922
{ name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
2023
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
24+
{ name = "iv", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "iv", source = "hex", outer_checksum = "607B4B118FF77C5D985CFAB0D33B7A171F14AF5924314B4AB77AA725DC66D515" },
2125
{ name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" },
2226
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
2327
{ name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "6B03DEEA38A02F276333CB27B53B16D3D45BD741B89599085A601BAF635F2006" },
28+
{ name = "nibble", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "iv"], otp_app = "nibble", source = "hex", outer_checksum = "06397501730FF486AE6F99299982A33F5EA9F8945B5A25920C82C8F924CEA481" },
2429
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
2530
{ name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" },
2631
{ name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" },
@@ -37,6 +42,7 @@ gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
3742
gleam_otp = { version = ">= 1.2.0 and < 2.0.0" }
3843
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
3944
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
45+
glenvy = { version = ">= 2.0.1 and < 3.0.0" }
4046
mist = { version = ">= 6.0.2 and < 7.0.0" }
4147
simplifile = { version = ">= 2.4.0 and < 3.0.0" }
4248
sqlight = { version = ">= 1.0.3 and < 2.0.0" }

src/lummy_agent/app/config.gleam

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import gleam/int
44
import gleam/list
55
import gleam/result
66
import gleam/string
7+
import glenvy/dotenv
8+
import simplifile
79

810
pub type ServerConfig {
911
ServerConfig(host: String, port: Int, secret_key_base: String)
@@ -73,6 +75,7 @@ pub type LoadError {
7375
InvalidInteger(name: String, value: String)
7476
BlankValue(name: String)
7577
SecretKeyBaseTooShort(minimum: Int, actual: Int)
78+
DotenvReadFailed(path: String, reason: String)
7679
}
7780

7881
const default_service_name = "lummy_agent"
@@ -210,8 +213,27 @@ pub fn defaults() -> Config {
210213
}
211214

212215
pub fn load() -> Result(Config, LoadError) {
213-
envoy.all()
214-
|> from_env
216+
{
217+
use env <- result.try(merge_dotenv_into_env(".env", envoy.all()))
218+
from_env(env)
219+
}
220+
}
221+
222+
@internal
223+
pub fn merge_dotenv_into_env(
224+
path: String,
225+
env: Dict(String, String),
226+
) -> Result(Dict(String, String), LoadError) {
227+
case dotenv.read_from(path) {
228+
Ok(dotenv_env) -> Ok(dict.merge(dotenv_env, env))
229+
Error(dotenv.Io(simplifile.Enoent)) -> Ok(env)
230+
231+
Error(dotenv.Io(error)) ->
232+
Error(DotenvReadFailed(
233+
path: path,
234+
reason: simplifile.describe_error(error),
235+
))
236+
}
215237
}
216238

217239
pub fn from_env(env: Dict(String, String)) -> Result(Config, LoadError) {
@@ -560,5 +582,8 @@ pub fn render_error(error: LoadError) -> String {
560582
<> int.to_string(minimum)
561583
<> " bytes, got "
562584
<> int.to_string(actual)
585+
586+
DotenvReadFailed(path, reason) ->
587+
"failed to read dotenv file " <> path <> ": " <> reason
563588
}
564589
}

src/lummy_agent/run/run_actor.gleam

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import gleam/erlang/process
2+
import gleam/int
23
import gleam/list
34
import gleam/option.{type Option, None, Some}
45
import gleam/otp/actor
@@ -11,6 +12,7 @@ import lummy_agent/domain/run as domain_run
1112
import lummy_agent/domain/tool_call as domain_tool_call
1213
import lummy_agent/provider/provider
1314
import lummy_agent/provider/registry
15+
import lummy_agent/runtime/log as runtime_log
1416
import lummy_agent/storage/repository
1517

1618
pub type StartArg {
@@ -590,6 +592,11 @@ fn start_model_execution(
590592
use _ <- result.try(ensure_run_ready_for_model_call(state.run))
591593
use _ <- result.try(ensure_no_active_model_execution(state))
592594
use selected_providers <- result.try(select_providers(state.config, state.run))
595+
runtime_log.info("run_actor", "model_execute_start", [
596+
#("run_id", domain_id.run_id_value(state.run.run_id)),
597+
#("model", state.run.model_selection),
598+
#("providers", provider_candidate_ids(selected_providers)),
599+
])
593600

594601
let self = state.self
595602
let worker =
@@ -649,6 +656,10 @@ fn handle_model_worker_chunk(
649656
) -> State {
650657
case state.active_model_execution {
651658
Some(execution) if execution.worker == worker -> {
659+
runtime_log.info("run_actor", "model_chunk", [
660+
#("run_id", domain_id.run_id_value(state.run.run_id)),
661+
#("chunk", log_text(chunk)),
662+
])
652663
process.send(execution.chunk_subject, chunk)
653664
state
654665
}
@@ -664,6 +675,7 @@ fn handle_model_worker_done(
664675
) -> State {
665676
case state.active_model_execution {
666677
Some(execution) if execution.worker == worker -> {
678+
log_model_done(state.run.run_id, result)
667679
process.send(execution.reply_with, result)
668680
State(..state, active_model_execution: None)
669681
}
@@ -779,6 +791,11 @@ fn start_tool_call_impl(
779791
use _ <- result.try(ensure_tool_call_belongs_to_run(state.run, tool_call))
780792
use _ <- result.try(ensure_run_not_terminal(state.run))
781793
use tool_call <- result.try(domain_tool_call.start(tool_call, started_at))
794+
runtime_log.info("run_actor", "tool_call_start", [
795+
#("run_id", domain_id.run_id_value(state.run.run_id)),
796+
#("tool_call_id", domain_id.tool_call_id_value(tool_call.tool_call_id)),
797+
#("tool_name", tool_call.tool_name),
798+
])
782799
use tool_call <- result.try(repository.insert_tool_call(
783800
state.repositories.tool_calls,
784801
tool_call,
@@ -804,6 +821,10 @@ fn succeed_tool_call_impl(
804821
state.repositories.tool_calls,
805822
tool_call,
806823
))
824+
runtime_log.info("run_actor", "tool_call_succeed", [
825+
#("run_id", domain_id.run_id_value(state.run.run_id)),
826+
#("tool_call_id", domain_id.tool_call_id_value(tool_call.tool_call_id)),
827+
])
807828
let state =
808829
State(..state, tool_calls: replace_tool_call(state.tool_calls, tool_call))
809830
Ok(#(state, tool_call))
@@ -821,6 +842,11 @@ fn fail_tool_call_impl(
821842
state.repositories.tool_calls,
822843
tool_call,
823844
))
845+
runtime_log.warn("run_actor", "tool_call_fail", [
846+
#("run_id", domain_id.run_id_value(state.run.run_id)),
847+
#("tool_call_id", domain_id.tool_call_id_value(tool_call.tool_call_id)),
848+
#("error_code", reason.code),
849+
])
824850
let state =
825851
State(..state, tool_calls: replace_tool_call(state.tool_calls, tool_call))
826852
Ok(#(state, tool_call))
@@ -841,6 +867,10 @@ fn complete_run(
841867
)
842868
use run <- result.try(domain_run.complete(state.run, ended_at, usage))
843869
use run <- result.try(repository.update_run(state.repositories.runs, run))
870+
runtime_log.info("run_actor", "run_complete", [
871+
#("run_id", domain_id.run_id_value(run.run_id)),
872+
#("total_tokens", int.to_string(usage.total_tokens)),
873+
])
844874
Ok(State(..state, run: run))
845875
}
846876

@@ -859,6 +889,10 @@ fn fail_run(
859889
)
860890
use run <- result.try(domain_run.fail(state.run, ended_at, reason))
861891
use run <- result.try(repository.update_run(state.repositories.runs, run))
892+
runtime_log.error("run_actor", "run_fail", [
893+
#("run_id", domain_id.run_id_value(run.run_id)),
894+
#("error_code", reason.code),
895+
])
862896
Ok(State(..state, run: run))
863897
}
864898

@@ -876,6 +910,9 @@ fn cancel_run(
876910
)
877911
use run <- result.try(domain_run.cancel(state.run, ended_at))
878912
use run <- result.try(repository.update_run(state.repositories.runs, run))
913+
runtime_log.warn("run_actor", "run_cancel", [
914+
#("run_id", domain_id.run_id_value(run.run_id)),
915+
])
879916
Ok(State(..state, run: run))
880917
}
881918

@@ -907,7 +944,11 @@ fn stream_with_fallback(
907944

908945
[candidate, ..rest] -> {
909946
let chunk_markers = process.new_subject()
910-
let registry.CandidateProvider(_, selected_provider) = candidate
947+
let registry.CandidateProvider(provider_id, selected_provider) = candidate
948+
runtime_log.info("run_actor", "model_stream_attempt", [
949+
#("provider_id", provider_id),
950+
#("remaining_fallbacks", int.to_string(list.length(rest))),
951+
])
911952
let result =
912953
provider.stream(selected_provider, request, cancel_subject, fn(chunk) {
913954
process.send(chunk_markers, Nil)
@@ -924,8 +965,15 @@ fn stream_with_fallback(
924965
rest
925966
{
926967
True, _, _ -> Error(error)
927-
_, True, [_, ..] ->
968+
969+
_, True, [_, ..] -> {
970+
runtime_log.warn("run_actor", "model_stream_fallback", [
971+
#("provider_id", provider_id),
972+
#("error_code", error.code),
973+
])
928974
stream_with_fallback(rest, request, cancel_subject, self, worker)
975+
}
976+
929977
_, _, _ -> Error(error)
930978
}
931979
}
@@ -1064,3 +1112,46 @@ fn render_error(error: domain_error.DomainError) -> String {
10641112
<> string.inspect(error.class)
10651113
<> ")"
10661114
}
1115+
1116+
fn provider_candidate_ids(
1117+
candidates: List(registry.CandidateProvider),
1118+
) -> String {
1119+
candidates
1120+
|> list.map(fn(candidate) {
1121+
let registry.CandidateProvider(provider_id, _) = candidate
1122+
provider_id
1123+
})
1124+
|> string.join(with: ",")
1125+
}
1126+
1127+
fn log_model_done(
1128+
run_id: domain_id.RunId,
1129+
result: Result(provider.CompletionResponse, domain_error.DomainError),
1130+
) -> Nil {
1131+
case result {
1132+
Ok(response) ->
1133+
runtime_log.info("run_actor", "model_done", [
1134+
#("run_id", domain_id.run_id_value(run_id)),
1135+
#("output", response_output_name(response)),
1136+
])
1137+
1138+
Error(error) ->
1139+
runtime_log.error("run_actor", "model_error", [
1140+
#("run_id", domain_id.run_id_value(run_id)),
1141+
#("error_code", error.code),
1142+
])
1143+
}
1144+
}
1145+
1146+
fn response_output_name(response: provider.CompletionResponse) -> String {
1147+
case response.output {
1148+
provider.AssistantText(_) -> "assistant"
1149+
provider.RequestTool(tool_name, _) -> "tool:" <> tool_name
1150+
}
1151+
}
1152+
1153+
fn log_text(value: String) -> String {
1154+
value
1155+
|> string.replace(each: "\r", with: "\\r")
1156+
|> string.replace(each: "\n", with: "\\n")
1157+
}

0 commit comments

Comments
 (0)