Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions src/main/java/dev/talos/cli/prompt/PromptDebugInspector.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import dev.talos.core.context.ContextLedgerCapture;
import dev.talos.core.context.ContextLedgerSnapshot;
import dev.talos.runtime.intent.TaskIntent;
import dev.talos.runtime.intent.TargetRef;
import dev.talos.runtime.TurnPolicyTrace;
import dev.talos.runtime.task.TaskContract;
import dev.talos.runtime.task.TaskContractResolver;
import dev.talos.spi.types.ChatMessage;
Expand Down Expand Up @@ -32,7 +31,6 @@ public static String format(PromptDebugSnapshot snapshot) {
}

TaskContract contract = TaskContractResolver.fromMessages(snapshot.messages());
TaskIntent intent = TaskContractResolver.intentFromMessages(snapshot.messages());
String frame = currentTurnFrame(snapshot.messages());
String expectedCoverage = expectedTargetCoverage(contract, frame);
String exactCoverage = exactLiteralCoverage(frame);
Expand Down Expand Up @@ -60,7 +58,7 @@ public static String format(PromptDebugSnapshot snapshot) {
.append(", mutationAllowed=").append(contract.mutationAllowed())
.append(", verificationRequired=").append(contract.verificationRequired()).append('\n');
out.append("- ").append(targetLabel(contract)).append(": ").append(joinOrNone(contract)).append('\n');
out.append("- Target roles: ").append(targetRoles(intent)).append('\n');
out.append("- Target roles: ").append(targetRoles(contract)).append('\n');
out.append("- ").append(targetCoverageLabel(contract)).append(": ").append(expectedCoverage).append('\n');
out.append("- Exact-literal coverage: ").append(exactCoverage).append("\n\n");
appendContextLedger(out);
Expand Down Expand Up @@ -184,13 +182,20 @@ private static String joinOrNone(TaskContract contract) {
.collect(Collectors.joining(", "));
}

private static String targetRoles(TaskIntent intent) {
if (intent == null || intent.targets().targets().isEmpty()) return "(none)";
return intent.targets().targets().stream()
private static String targetRoles(TaskContract contract) {
if (contract == null) return "(none)";
List<TurnPolicyTrace.RolefulTarget> targets = TurnPolicyTrace.from(
contract,
"unknown",
List.of(),
List.of())
.rolefulTargets();
if (targets.isEmpty()) return "(none)";
return targets.stream()
.sorted(Comparator
.comparing((TargetRef target) -> target.path())
.thenComparing(target -> target.role().name()))
.map(target -> target.path() + " = " + target.role().name())
.comparing((TurnPolicyTrace.RolefulTarget target) -> target.path())
.thenComparing(TurnPolicyTrace.RolefulTarget::role))
.map(target -> target.path() + " = " + target.role())
.collect(Collectors.joining(", "));
}

Expand Down
26 changes: 21 additions & 5 deletions src/main/java/dev/talos/runtime/TurnPolicyTrace.java
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,17 @@ private static String blankDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}

private static boolean mutationTargetRole(String role) {
return "MUST_MUTATE".equals(role) || "OUTPUT_DESTINATION".equals(role);
}

private static String expectedTargetRole(TaskContract contract) {
if (contract != null && !contract.mutationAllowed()) {
return contract.verificationRequired() ? "VERIFY_ONLY" : "MUST_READ";
}
return "MUST_MUTATE";
}

private static List<RolefulTarget> rolefulTargetsFrom(TaskIntent intent, TaskContract contract) {
LinkedHashMap<String, RolefulTarget> out = new LinkedHashMap<>();
Set<String> activeExpected = contract == null ? Set.of() : contract.expectedTargets();
Expand All @@ -211,21 +222,26 @@ private static List<RolefulTarget> rolefulTargetsFrom(TaskIntent intent, TaskCon
for (TargetRef ref : intent.targets().targets()) {
if (ref == null) continue;
String role = ref.role().name();
if (("MUST_MUTATE".equals(role) || "OUTPUT_DESTINATION".equals(role))
&& !activeExpected.contains(ref.path())) {
continue;
if (mutationTargetRole(role)) {
if (!activeExpected.contains(ref.path())) {
continue;
}
if (contract != null && !contract.mutationAllowed()) {
continue;
}
}
if ("FORBIDDEN".equals(role) && !activeForbidden.contains(ref.path())) {
continue;
}
out.putIfAbsent(ref.path() + "\u0000" + role, RolefulTarget.from(ref));
}
}
String expectedRole = expectedTargetRole(contract);
for (String expected : activeExpected.stream().sorted().toList()) {
String key = expected + "\u0000MUST_MUTATE";
String key = expected + "\u0000" + expectedRole;
out.putIfAbsent(key, new RolefulTarget(
expected,
"MUST_MUTATE",
expectedRole,
"RUNTIME_DEFAULT",
"active-contract-projection",
"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.time.Instant;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class PromptDebugInspectorTargetRolesTest {
Expand All @@ -31,4 +32,27 @@ void promptDebugShowsRolefulTargets() {
assertTrue(rendered.contains("styles.css = MUST_MUTATE"), rendered);
assertTrue(rendered.contains("index.html = VERIFY_ONLY"), rendered);
}

@Test
void promptDebugDoesNotShowReadOnlyTargetHintsAsMustMutate() {
PromptDebugSnapshot snapshot = new PromptDebugSnapshot(
"CHAT_REQUEST",
"ollama",
"gpt-oss:20b",
false,
Instant.parse("2026-05-31T00:00:00Z"),
List.of(ChatMessage.user(
"Check whether scripts.js exists and whether script.js exists. Do not change anything.")),
List.of(),
ChatRequestControls.defaults(),
"");

String rendered = PromptDebugInspector.format(snapshot);

assertTrue(rendered.contains("- Task contract: DIAGNOSE_ONLY, mutationAllowed=false"), rendered);
assertTrue(rendered.contains("scripts.js = MUST_READ"), rendered);
assertTrue(rendered.contains("script.js = MUST_READ"), rendered);
assertFalse(rendered.contains("scripts.js = MUST_MUTATE"), rendered);
assertFalse(rendered.contains("script.js = MUST_MUTATE"), rendered);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,32 @@ void recordsRolefulTargetEvidenceWhilePreservingLegacyProjection() {
&& "VERIFY_ONLY".equals(target.role())));
}

@Test
void readOnlyPolicyTraceDoesNotRenderTargetHintsAsMutationObligations() {
beginTrace();

TurnPolicyTrace policyTrace = TurnPolicyTrace.from(
TaskContractResolver.fromUserRequest(
"Check whether scripts.js exists and whether script.js exists. Do not change anything."),
"INSPECT",
List.of("talos.read_file"),
List.of("tool_use:read_file"));

LocalTurnTraceCapture.recordPolicyTrace(policyTrace);

LocalTurnTrace trace = LocalTurnTraceCapture.complete();
assertFalse(trace.taskContract().mutationAllowed());
assertEquals(List.of("script.js", "scripts.js"), trace.taskContract().expectedTargets());
assertFalse(trace.taskContract().rolefulTargets().stream()
.anyMatch(target -> "MUST_MUTATE".equals(target.role())));
assertTrue(trace.taskContract().rolefulTargets().stream()
.anyMatch(target -> "script.js".equals(target.path())
&& "MUST_READ".equals(target.role())));
assertTrue(trace.taskContract().rolefulTargets().stream()
.anyMatch(target -> "scripts.js".equals(target.path())
&& "MUST_READ".equals(target.role())));
}

@Test
void policyTraceRecordingHasDedicatedRecorderOwner() throws Exception {
Path capturePath = Path.of("src/main/java/dev/talos/runtime/trace/LocalTurnTraceCapture.java");
Expand Down