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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
EXECUTOR_MAX_WORKERS = 30
CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32'))
SESSION_TYPE = 'filesystem'
VERSION = "0.261.414"
VERSION = "0.261.415"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
2 changes: 2 additions & 0 deletions application/single_app/functions_chat_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from functions_assist_submissions import normalize_submission_id
from functions_chat_message_metadata import patch_message_metadata
from functions_message_deletion import is_soft_deleted_message
from functions_orchestration_planning import PLANNING_METADATA


MODEL_FIELDS = ('model_deployment', 'model_id', 'model_endpoint_id', 'model_provider')
Expand Down Expand Up @@ -331,6 +332,7 @@ def retry_source_fingerprint(source):
'button_states', 'prompt_selection', 'image_references', 'image_generation',
'requested_reasoning_effort', 'time_zone', 'masked', 'masked_ranges',
'orchestration', 'orchestration_turn_id', 'orchestration_inputs', 'orchestration_clarification_answers',
PLANNING_METADATA,
) if key in metadata
}
return hashlib.sha256(json.dumps(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,14 @@ def mirror_orchestration_turn(conversation, user_message, sender_user, *, detail
'source_conversation_id': conversation['id'],
'source_role': 'user',
'orchestration_turn_id': metadata.get('orchestration_turn_id'),
'orchestration': {'turn_id': metadata.get('orchestration_turn_id')},
'orchestration': {
'turn_id': metadata.get('orchestration_turn_id'),
**{
key: (metadata.get('orchestration') or {})[key]
for key in ('planning_status', 'planning_submission_id')
if key in (metadata.get('orchestration') or {})
},
},
}
if isinstance(details.get('invocation_target'), dict):
extra['ai_invocation_target'] = details['invocation_target']
Expand Down
370 changes: 370 additions & 0 deletions application/single_app/functions_orchestration_planning.py

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion application/single_app/route_backend_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
filter_assistant_artifact_items,
hydrate_agent_citations_from_artifacts,
)
from functions_orchestration_planning import PlanningStateError, public_planning_message
from functions_message_deletion import (
exclude_soft_deleted_messages,
is_soft_deleted_message,
Expand Down Expand Up @@ -1648,7 +1649,13 @@ def api_get_messages():
"message": "Microsoft 365 action cards could not be loaded. Reload the conversation to try again.",
}), status_code

return jsonify({'messages': messages})
return jsonify({'messages': [public_planning_message(message) for message in messages]})
except PlanningStateError as error:
log_event(
'[ORCHESTRATION] Saved planning history could not be projected.',
level=logging.ERROR, extra={'stage': 'planning_history', 'code': error.code},
)
return jsonify({'error': error.public_message, 'code': error.code}), error.status_code
except PermissionError:
return jsonify({'error': 'Forbidden'}), 403
except LookupError:
Expand Down
632 changes: 577 additions & 55 deletions application/single_app/route_backend_orchestration.py

Large diffs are not rendered by default.

12 changes: 3 additions & 9 deletions application/v2_ui/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1529,16 +1529,10 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
stopTyping();
};

/**
* Stop the work in flight, whichever kind it is.
*
* A plan or run has no server-side cancel endpoint the way a chat stream does, so Stop can
* only abort the reader; the controller settles the thread either way and keeps the partial
* answer. Routed by whether the conversation has an orchestration stream open, so Stop does
* the right thing without the button needing to know which mode produced the work.
*/
/** Stop the visible stream's owner, not another orchestration running in the background. */
const handleStop = () => {
if (activeConversationId && hasActiveOrchestration(activeConversationId)) {
if (activeConversationId && useChatStore.getState().orchestrationSurface
&& hasActiveOrchestration(activeConversationId)) {
cancelOrchestration(activeConversationId);
return;
}
Expand Down
5 changes: 4 additions & 1 deletion application/v2_ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import { ImageEditor } from './ImageEditor';
import { ImageReferenceThumbnail } from './ImageReferenceThumbnail';
import { ImageProposalScope } from './ImageProposalContext';
import { normalizeFoundryAuthUrl } from '../../lib/foundryAuth';
import { messageOrchestrationTurn, savedResponseAttempt, selectedAttemptError } from '../../lib/chatRetryAttempts';
import { initialPlanningState, messageOrchestrationTurn, savedResponseAttempt, selectedAttemptError } from '../../lib/chatRetryAttempts';
import {
answerGeneratedImages,
extractProposalSpecs,
Expand Down Expand Up @@ -1797,6 +1797,9 @@ export function MessageList() {
{!here && savedResponseAttempt(message).state === 'prepared' && (
<p role="status" className="px-1 text-sm text-text-3">This retry is prepared. Select Retry to generate its response.</p>
)}
{!here && initialPlanningState(message).status === 'cancelled' && (
<p role="status" className="px-1 text-sm text-text-3">Planning stopped. Your question was saved.</p>
)}
{activeConversationId && planAnchor === message.id && !(localRetry && !localRetry.admitted) && (
<ActiveOrchestrationCard conversationId={activeConversationId} />
)}
Expand Down
21 changes: 21 additions & 0 deletions application/v2_ui/src/lib/chatRetryAttempts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ export interface ResponseAttempt {
run_id?: string;
}

export function initialPlanningState(message: ChatMessage | undefined): {
status?: string; submissionId?: string; sourceMessageId?: string;
runId?: string; runStarted?: boolean; error?: string;
} {
const value = message?.metadata?.orchestration;
if (!isRecord(value)) return {};
return {
status: typeof value.planning_status === 'string' ? value.planning_status : undefined,
submissionId: typeof value.planning_submission_id === 'string' ? value.planning_submission_id : undefined,
sourceMessageId: typeof message?.metadata?.source_message_id === 'string'
? message.metadata.source_message_id : undefined,
runId: typeof value.run_id === 'string' ? value.run_id : undefined,
runStarted: typeof value.planning_run_started === 'boolean' ? value.planning_run_started : undefined,
error: typeof value.planning_error === 'string' ? value.planning_error : undefined,
};
}

function isRecord(value: unknown): value is Json {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
Expand Down Expand Up @@ -136,6 +153,10 @@ export function retryReplyMetadata(metadata: Json, presentation: RetryPresentati
}

export function selectedAttemptError(question: ChatMessage, messages: ChatMessage[]): string | undefined {
const planning = initialPlanningState(question);
if ((!planning.runId || planning.error) && ['failed', 'interrupted'].includes(planning.status ?? '')) {
return planning.error || 'Planning did not complete. Your question was saved. You can retry it.';
}
const attempt = savedResponseAttempt(question);
if (!['failed', 'interrupted'].includes(attempt.state ?? '')) return undefined;
const thread = messageThreadId(question);
Expand Down
97 changes: 96 additions & 1 deletion application/v2_ui/src/lib/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ export interface OrchestrationPlanRequest extends OrchestrationSeeds {
message: string;
conversation_id?: string | null;
turn_id?: string;
planning_submission_id?: string;
elicitation_response?: ElicitationResponse;
elicitation_id?: string;
elicitation_revision?: number;
Expand Down Expand Up @@ -841,6 +842,34 @@ export interface PlannedTurnIdentity {
user_message_id: string;
}

export interface SavedPlanningTurn {
conversation_id: string;
turn_id: string;
user_message_id: string;
submission_id: string;
status: PlanStatus | 'planning' | 'awaiting_clarification' | 'awaiting_review' | 'interrupted';
updated_at: string;
run_id?: string;
run_started?: boolean;
error?: string;
error_code?: string;
request?: OrchestrationPlanRequest;
elicitation?: Elicitation;
}

function isSavedPlanningTurn(value: unknown): value is SavedPlanningTurn {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const turn = value as Record<string, unknown>;
return ['conversation_id', 'turn_id', 'user_message_id', 'submission_id', 'status', 'updated_at']
.every((field) => typeof turn[field] === 'string' && Boolean(turn[field]))
&& [
'planning', 'awaiting_clarification', 'awaiting_review', 'interrupted',
'draft', 'awaiting_approval', 'approved', 'running', 'waiting',
'completed', 'failed', 'cancelled', 'superseded',
].includes(String(turn.status))
&& (turn.run_started === undefined || typeof turn.run_started === 'boolean');
}

export interface PlanStreamEvent {
reasoning_adjustments?: ReasoningResolution[];
type?: 'thought' | 'orchestration_plan' | 'orchestration_elicitation' | string;
Expand All @@ -849,6 +878,8 @@ export interface PlanStreamEvent {
editor?: PlanEditorState;
export_catalog?: OrchestrationExportFormat[];
turn_identity?: PlannedTurnIdentity;
server_managed?: boolean;
turn?: SavedPlanningTurn;
done?: boolean;
error?: string;
details?: unknown;
Expand Down Expand Up @@ -888,11 +919,14 @@ export interface PlanStreamHandlers {
* new conversation's id by the path it already knows rather than a second mechanism.
*/
onConversationMetadata?: (event: ChatStreamEvent) => void;
onUserMessagePersisted?: (event: ChatStreamEvent) => void;
onTurn?: (turn: SavedPlanningTurn) => void;
/** The planner produced a plan; the stream is over. */
onPlan?: (
plan: OrchestrationPlan,
exportCatalog?: OrchestrationExportFormat[],
turnIdentity?: PlannedTurnIdentity,
serverManaged?: boolean,
) => void;
/** The planner asked a question instead of planning; the stream is over. */
onElicitation?: (elicitation: Elicitation) => void;
Expand Down Expand Up @@ -965,6 +999,51 @@ export const ORCHESTRATION_PLAN_PATH = '/api/v2/orchestration/plan';
export const ORCHESTRATION_RUN_PATH = '/api/v2/orchestration/run';
export const ORCHESTRATION_RUNS_PATH = '/api/v2/orchestration/runs';

export async function fetchOrchestrationTurn(
conversationId: string,
turnId: string,
options: { messageId?: string; includeRequest?: boolean; signal?: AbortSignal } = {},
): Promise<SavedPlanningTurn> {
const params = new URLSearchParams({ conversation_id: conversationId });
if (options.messageId) params.set('message_id', options.messageId);
if (options.includeRequest) params.set('include_request', '1');
const payload = await api.get<{ turn: SavedPlanningTurn }>(
`/api/v2/orchestration/turns/${encodeURIComponent(turnId)}?${params}`, options.signal,
);
if (!isSavedPlanningTurn(payload.turn)
|| payload.turn.conversation_id !== conversationId || payload.turn.turn_id !== turnId
|| (options.messageId && payload.turn.user_message_id !== options.messageId)
|| (options.includeRequest && (
!payload.turn.request || typeof payload.turn.request.message !== 'string'
|| payload.turn.request.conversation_id !== conversationId || payload.turn.request.turn_id !== turnId
))) {
throw new Error('The saved planning status did not match this question.');
}
return payload.turn;
}

export async function cancelOrchestrationTurn(
conversationId: string, turnId: string, submissionId: string, messageId?: string,
): Promise<SavedPlanningTurn | null> {
const result = await api.post<{
success: boolean; turn_id: string; submission_id: string; turn?: SavedPlanningTurn;
}>(
`/api/v2/orchestration/turns/${encodeURIComponent(turnId)}/cancel`,
{
conversation_id: conversationId, submission_id: submissionId,
...(messageId && !messageId.startsWith('pending-user-') ? { message_id: messageId } : {}),
},
);
if (!result.success || result.turn_id !== turnId || result.submission_id !== submissionId) {
throw new Error('Stop could not be confirmed for this planning submission.');
}
if (result.turn && (!isSavedPlanningTurn(result.turn)
|| result.turn.conversation_id !== conversationId || result.turn.turn_id !== turnId)) {
throw new Error('Stop returned an invalid saved planning status.');
}
return result.turn ?? null;
}

/* -------------------------------------------------------------------------- */
/* Stored runs */
/* -------------------------------------------------------------------------- */
Expand Down Expand Up @@ -1334,6 +1413,22 @@ async function readPlanStream(
return false;
}

if (event.type === 'user_message_persisted') {
handlers.onUserMessagePersisted?.(event as ChatStreamEvent);
return false;
}

if (event.type === 'orchestration_turn') {
if (!isSavedPlanningTurn(event.turn)) {
result.errored = true;
handlers.onError?.('The saved planning status was invalid.');
} else {
result.completed = true;
handlers.onTurn?.(event.turn);
}
return true;
}

const editor = event.editor ? {
...event.editor,
...(Array.isArray(event.export_catalog) ? { export_catalog: event.export_catalog } : {}),
Expand All @@ -1355,7 +1450,7 @@ async function readPlanStream(
}
result.completed = true;
if (result.plan) {
handlers.onPlan?.(result.plan, event.export_catalog, event.turn_identity);
handlers.onPlan?.(result.plan, event.export_catalog, event.turn_identity, event.server_managed === true);
}
return true;
}
Expand Down
Loading
Loading