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
1 change: 1 addition & 0 deletions changelog.d/refusals-are-error-results.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- A refusal now reaches the model as an error result (`isError: true`), so it can tell a refusal from a tool that ran. That covers `tune_out`'s refusals (an unknown channel, a cancel of a channel that isn't tuned out, and an enter that can't go through, such as on a channel already tuned out), `channel_open` on a tuned-out channel, the subconscious's refusals of a `deliver_summary` with no text, a `cancel_tuneout` of a channel that isn't tuned out, a `note_disposition` with no key and a `speak_in_channel` it can't make, and the api module's `Unknown tool`. Each came back unflagged (`isError: false`, or for the api module no `isError` at all), so the model read it as a tool's ordinary answer, in XML tool mode as its `<stdout>`. `channel_open`'s refusal also showed the model only its `data`, `{"refusal":"tuned-out","channelId":"…"}`, and never the text saying to cancel the tune-out with `tune_out`'s `cancel` mode; the model now reads that text. A script's call to one of these tools now gets `Error: <text>`, as for any failed call. An in-process caller of `channel_open` (`ctx.callTool`) or of the api module gets the same result as before with `isError` set, and the tuned-out refusal keeps its `data`.
1 change: 1 addition & 0 deletions changelog.d/tune-out-errors.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The tune-out coordinator's refusals say what didn't happen and what to do instead. `tune_out` on a channel that's already tuned out gives that tune-out's settings and how to change them, and its end time where it has one. A cancel of a channel that isn't tuned out, through `tune_out` or the subconscious's `cancel_tuneout`, names the connected channels that are and how a tune-out ends by itself, and says whose window its backlog goes to: "you" to the resident, "the resident" to the subconscious. A fork-bound channel says why `tune_out` won't divert it, and that calling again won't change that until its conversation ends. The subconscious's `deliver_summary`, `note_disposition` and `speak_in_channel` say what they need. `speak_in_channel`'s refusal where channel speech is off says the resident can speak there, through `deliver_summary` or by ending the tune-out with `cancel_tuneout`, and the configuration key it used to name goes to a `[tune-out]` log line for whoever runs the host.
6 changes: 3 additions & 3 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18277,12 +18277,12 @@ export class AgentFramework {
.find((e) => e.descriptor.id === channelId);
let result: { success: boolean; data?: unknown; error?: string; isError?: boolean };
if (!entry) {
result = { success: false, error: `unknown channel: ${channelId}`, isError: false };
result = { success: false, error: `unknown channel: ${channelId}`, isError: true };
} else if (input.mode === 'cancel') {
const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request');
result = r.ok
? { success: true, data: { cancelled: channelId } }
: { success: false, error: r.error, isError: false };
: { success: false, error: r.error, isError: true };
} else {
const r = coordinator.enter(entry.serverId, channelId, {
cadenceSeconds: input.cadenceSeconds,
Expand All @@ -18300,7 +18300,7 @@ export class AgentFramework {
maxWakes: r.params.maxWakes,
},
}
: { success: false, error: r.error, isError: false };
: { success: false, error: r.error, isError: true };
}
this.pushEvent({
type: 'tool-result',
Expand Down
6 changes: 4 additions & 2 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2670,11 +2670,13 @@ export class ChannelRegistry {
// A tuned-out channel is transport-open but attention-diverted; a plain
// open would silently discard the epoch (and its pending backlog dump).
// Cancelling is the tune-out coordinator's flow, reached via its own
// tool — refuse with the pointer rather than eat the state.
// tool — refuse with the pointer rather than eat the state. As an error
// result, the agent reads the pointer itself; `data` names the refusal
// for an in-process caller.
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
return {
success: false,
isError: false,
isError: true,
error:
`Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
`(tune_out with mode: 'cancel') to resume normal attention — ` +
Expand Down
2 changes: 1 addition & 1 deletion src/modules/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class ApiModule implements Module {

async handleToolCall(_call: ToolCall): Promise<ToolResult> {
// API module has no tools
return { success: false, error: 'Unknown tool' };
return { success: false, error: 'Unknown tool', isError: true };
}

async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
Expand Down
91 changes: 79 additions & 12 deletions src/tune-out/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,37 @@ export class TuneOutCoordinator {
source: string,
): { ok: true; params: TuneOutParams } | { ok: false; error: string } {
if (!this.hooks.subconsciousName()) {
return { ok: false, error: 'tune-out requires the subconscious resident (framework config `subconscious`)' };
return {
ok: false,
error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out '
+ "isn't available here, and calling it again won't change that. If you need channels diverted, ask "
+ 'whoever runs this host.',
};
}
if (this.hooks.isForkBound(channelId)) {
return { ok: false, error: `channel ${channelId} is bound to a conversation fork; tune-out and fork routing are mutually exclusive` };
return {
ok: false,
error: `Nothing changed: a conversation is bound to ${JSON.stringify(channelId)}, so its messages go to that `
+ "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change "
+ 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune '
+ 'out a different channel, or ask whoever runs this host if this one should be diverted.',
};
}
if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
return { ok: false, error: `channel ${channelId} is already tuned out (cancel first to change params)` };
const current = this.channelRegistry.getTuneOutState(serverId, channelId);
if (current) {
const p = current.params;
// A duration so long its end is past what a Date can hold has no time to show.
const expires = p.expiresAtMs !== undefined ? new Date(p.expiresAtMs) : undefined;
const until = expires && Number.isFinite(expires.getTime())
? `, until ${expires.toISOString().replace(/\.\d+Z$/, 'Z')}`
: '';
return {
ok: false,
error: `Nothing changed: ${JSON.stringify(channelId)} is already tuned out, with cadenceSeconds `
+ `${p.cadenceSeconds}, backlogCap ${p.backlogCap} and maxWakes ${p.maxWakes}${until}. To change those, `
+ 'cancel it first with tune_out mode "cancel", which delivers its backlog to you now, then call tune_out '
+ 'again with the new settings.',
};
}
const params: TuneOutParams = {
epochId: randomUUID(),
Expand Down Expand Up @@ -188,7 +212,8 @@ export class TuneOutCoordinator {
): { ok: true } | { ok: false; error: string } {
const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source);
if (!ended) {
return { ok: false, error: `channel ${channelId} is not tuned out` };
// Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
return { ok: false, error: this.notTunedOut(channelId, 'you') };
}
this.stopCadence(serverId, channelId);
const expiry = this.expiryTimers.get(this.key(serverId, channelId));
Expand Down Expand Up @@ -485,7 +510,14 @@ export class TuneOutCoordinator {
case 'deliver_summary': {
const text = String(input.text ?? '').trim();
const channelId = String(input.channelId ?? '');
if (!text) return { success: false, error: 'empty summary', isError: false };
if (!text) {
return {
success: false,
error: 'Nothing was delivered: deliver_summary needs the summary in "text". If there\'s nothing to '
+ "report, don't call it: staying silent is allowed.",
isError: true,
};
}
const subName = this.hooks.subconsciousName();
if (!subName) return { success: false, error: 'subconscious not configured', isError: true };
// Verbatim, under its own participant name, into the resident's window.
Expand All @@ -498,37 +530,72 @@ export class TuneOutCoordinator {
case 'cancel_tuneout': {
const channelId = String(input.channelId ?? '');
const located = this.findByChannel(channelId);
if (!located) return { success: false, error: `channel ${channelId} is not tuned out`, isError: false };
if (!located) return { success: false, error: this.notTunedOut(channelId, 'the resident'), isError: true };
const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
const result = this.cancel(located.serverId, channelId, 'subconscious', 'subconscious judgment', note);
return result.ok
? { success: true, data: { cancelled: true } }
: { success: false, error: result.error, isError: false };
: { success: false, error: result.error, isError: true };
}
case 'note_disposition': {
const key = String(input.key ?? '').trim();
if (!key) return { success: false, error: 'key required', isError: false };
if (!key) {
return {
success: false,
error: 'Nothing was recorded: note_disposition needs a "key", a short stable name for the note, such as '
+ '"busy-channels". To delete a note, call it with that note\'s key and an empty "text".',
isError: true,
};
}
this.noteDisposition(key, String(input.text ?? ''));
return { success: true, data: { noted: key } };
}
case 'speak_in_channel': {
if (!this.hooks.allowChannelSpeech()) {
// The setting's name is the host's, for its log; the subconscious
// reads what it can do instead.
console.warn('[tune-out] speak_in_channel refused: FrameworkConfig.subconscious.allowChannelSpeech is off');
return {
success: false,
isError: false,
error: 'channel speech is disabled for the subconscious (recipe: subconscious.allowChannelSpeech)',
isError: true,
error: "Nothing was posted: speak_in_channel is turned off for you in the resident's configuration, and "
+ "calling it again won't change that. The resident can speak there: tell it what's needed with "
+ 'deliver_summary, or, if the channel is tuned out and needs the resident now, end the tune-out with '
+ 'cancel_tuneout.',
};
}
const channelId = String(input.channelId ?? '');
const text = String(input.text ?? '').trim();
if (!text) return { success: false, error: 'empty message', isError: false };
if (!text) {
return { success: false, error: 'Nothing was posted: speak_in_channel needs the message in "text".', isError: true };
}
return this.channelRegistry.publishForAgent(channelId, text, this.hooks.subconsciousName() ?? 'Subconscious');
}
default:
return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
}
}

/**
* A cancel of a channel that isn't tuned out: which channels are, so the
* next call can name one, and how a tune-out ends without one. `backlogTo`
* names the resident as the message's reader knows it: "you" to the
* resident, "the resident" to the subconscious. It speaks of connected
* channels because listActive reads the registered ones: removing a server
* drops its channels and keeps their tune-outs, so a tune-out there is in
* neither this list nor findByChannel's lookup, and the text claims nothing
* about it.
*/
private notTunedOut(channelId: string, backlogTo: 'you' | 'the resident'): string {
const active = this.listActive().map((e) => JSON.stringify(e.channelId));
const opening = active.length === 0
? 'no connected channel is tuned out now.'
: `no connected channel ${JSON.stringify(channelId)} is tuned out. Connected channels tuned out now: `
+ `${active.join(', ')}.`;
return `Nothing was cancelled: ${opening} A tune-out also ends by itself when its duration runs out, or at the `
+ `first wake past its wake budget, and its backlog goes to ${backlogTo} then.`;
}

private findByChannel(channelId: string): { serverId: string } | null {
for (const entry of this.listActive()) {
if (entry.channelId === channelId) return { serverId: entry.serverId };
Expand Down
Loading
Loading