Skip to content

Commit d5fa4cb

Browse files
committed
fix(mcp): surface prelude source discovery output
Keep print-only session eval results visible in structured MCP responses, including Clojure-style (source ...) forms that print and return nil. Add direct depends-on hints to prelude source output so agents can discover helper contracts without guessing implementation details. Verified with root mix precommit, mcp_server mix precommit, focused prelude tests, focused MCP lifecycle/output tests, and a ptc-bench-comparison source smoke.
1 parent 8e3d81f commit d5fa4cb

6 files changed

Lines changed: 95 additions & 2 deletions

File tree

lib/ptc_runner/lisp/prelude/compiler.ex

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,7 @@ defmodule PtcRunner.Lisp.Prelude.Compiler do
12391239
defp build_source_index(specs, exports) do
12401240
export_by_ref = Map.new(exports, &{&1.ref, &1})
12411241
reachable = reachable_private_symbols(specs)
1242+
dependencies_by_ref = source_dependencies(specs, reachable)
12421243

12431244
specs
12441245
|> Enum.filter(fn %Spec{} = spec ->
@@ -1247,10 +1248,58 @@ defmodule PtcRunner.Lisp.Prelude.Compiler do
12471248
|> Map.new(fn %Spec{} = spec ->
12481249
ref = ref(spec.namespace, spec.symbol)
12491250
header = source_header(ref, spec, Map.get(export_by_ref, ref))
1250-
{ref, header <> "\n" <> render_source_body(spec)}
1251+
dependencies = Map.get(dependencies_by_ref, ref, [])
1252+
{ref, header <> "\n" <> source_dependency_hint(dependencies) <> render_source_body(spec)}
12511253
end)
12521254
end
12531255

1256+
defp source_dependency_hint([]), do: ""
1257+
1258+
defp source_dependency_hint(dependencies) do
1259+
forms = Enum.map_join(dependencies, ", ", &"(source #{&1})")
1260+
";; depends-on: #{forms}\n"
1261+
end
1262+
1263+
defp source_dependencies(specs, reachable_private) do
1264+
public_symbols =
1265+
specs
1266+
|> Enum.reject(& &1.private?)
1267+
|> MapSet.new(fn %Spec{} = spec -> {spec.namespace, spec.symbol} end)
1268+
1269+
indexable_symbols = MapSet.union(public_symbols, reachable_private)
1270+
1271+
specs
1272+
|> Enum.group_by(& &1.namespace)
1273+
|> Enum.flat_map(fn {ns, ns_specs} ->
1274+
ns_symbols = Enum.map(ns_specs, & &1.symbol)
1275+
1276+
calls =
1277+
Map.new(ns_specs, fn %Spec{symbol: sym, params_form: params, body_form: body} ->
1278+
refs =
1279+
body
1280+
|> Enum.reduce([], &collect_refs(&1, param_names(params), &2))
1281+
|> Enum.uniq()
1282+
|> Enum.filter(&(&1 in ns_symbols))
1283+
1284+
{sym, refs}
1285+
end)
1286+
1287+
Enum.map(ns_specs, fn %Spec{} = spec ->
1288+
deps =
1289+
calls
1290+
|> Map.get(spec.symbol, [])
1291+
|> Enum.reject(&(&1 == spec.symbol))
1292+
|> Enum.uniq()
1293+
|> Enum.filter(&MapSet.member?(indexable_symbols, {ns, &1}))
1294+
|> Enum.map(&ref(ns, &1))
1295+
|> Enum.sort()
1296+
1297+
{ref(spec.namespace, spec.symbol), deps}
1298+
end)
1299+
end)
1300+
|> Map.new()
1301+
end
1302+
12541303
# `source` is a discovery convenience, so a Formatter gap must NEVER take down
12551304
# compilation of the whole capability prelude — it degrades that one export's
12561305
# `(source ...)` to a placeholder instead (fail-soft for cosmetics; the

mcp_server/lib/ptc_runner_mcp/envelope.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ defmodule PtcRunnerMcp.Envelope do
358358
def compact_session_success(structured) do
359359
%{"status" => Map.get(structured, "status", "ok")}
360360
|> maybe_put("result", Map.get(structured, "result"))
361+
|> maybe_put("prints", Map.get(structured, "prints"), keep_nil?: false)
361362
|> maybe_put("feedback", session_success_feedback(structured))
362363
|> maybe_put("validated", Map.get(structured, "validated"), keep_nil?: false)
363364
|> maybe_put("validated_preview", Map.get(structured, "validated_preview"), keep_nil?: false)

mcp_server/lib/ptc_runner_mcp/sessions.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,8 @@ defmodule PtcRunnerMcp.Sessions do
576576
end
577577

578578
defp session_eval_payload?(payload) when is_map(payload) do
579-
Map.has_key?(payload, "result") and is_map(Map.get(payload, "session"))
579+
Map.get(payload, "status") == "ok" and is_map(Map.get(payload, "session")) and
580+
is_map(Map.get(payload, "memory")) and Map.has_key?(payload, "history_notices")
580581
end
581582

582583
defp pop_debug_structured(response) when is_map(response) do

mcp_server/test/ptc_runner_mcp/output_limits_test.exs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,22 @@ defmodule PtcRunnerMcp.OutputLimitsTest do
9696
refute text =~ "lisp_debug"
9797
end
9898

99+
test "structured session success keeps print-only nil results visible" do
100+
envelope =
101+
%{
102+
"status" => "ok",
103+
"prints" => ["(defn profile [source opts] ...)"],
104+
"feedback" => "",
105+
"session" => %{"session_id" => "s1", "turn" => 2}
106+
}
107+
|> Envelope.ptc_lisp_session_success(response_profile: :structured)
108+
109+
assert envelope["structuredContent"]["prints"] == ["(defn profile [source opts] ...)"]
110+
111+
assert get_in(envelope, ["content", Access.at(0), "text"]) =~
112+
"(defn profile [source opts] ...)"
113+
end
114+
99115
test "session error text marks rollback and turn-local upstream calls" do
100116
text =
101117
Envelope.render_session_error_text(%{

mcp_server/test/ptc_runner_mcp/sessions_lifecycle_test.exs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,23 @@ defmodule PtcRunnerMcp.SessionsLifecycleTest do
113113
assert eval["structuredContent"]["result"] == "user=> 42"
114114
end
115115

116+
test "configured prelude source discovery is visible in session eval output" do
117+
Config.set(Map.put(Config.get(), :prelude_source, test_prelude_source()))
118+
sid = SoakHelpers.start_session()
119+
120+
eval =
121+
call("lisp_session_eval", %{
122+
"session_id" => sid,
123+
"program" => "(source smoke/plus-one)"
124+
})
125+
126+
assert eval["isError"] == false
127+
assert eval["structuredContent"]["status"] == "ok"
128+
assert [source] = eval["structuredContent"]["prints"]
129+
assert source =~ "(defn plus-one"
130+
assert get_in(eval, ["content", Access.at(0), "text"]) =~ "(defn plus-one"
131+
end
132+
116133
test "configured prelude is attached to one-shot lisp_eval" do
117134
Config.set(%{enabled: false, prelude_source: test_prelude_source()})
118135

test/ptc_runner/lisp/prelude/discovery_test.exs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,15 @@ defmodule PtcRunner.Lisp.Prelude.DiscoveryTest do
312312
refute Map.has_key?(run_return("(ns-publics 'crm)", prelude), "normalize-id")
313313
end
314314

315+
test "public source advertises source-addressable same-namespace dependencies",
316+
%{prelude: prelude} do
317+
src = run_source("(source 'crm/get-user)", prelude)
318+
319+
assert src =~ ";; depends-on: (source crm/normalize-id)"
320+
assert src =~ "(defn get-user"
321+
refute src =~ "dead-helper"
322+
end
323+
315324
test "renders reader-macro literals (#(), #\"re\", 'sym) in a body without crashing compile" do
316325
# The source precompute renders the captured body form via Formatter. A body
317326
# using #() / #"re" / 'sym must not crash compilation (Formatter lacked

0 commit comments

Comments
 (0)