feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
|
||||
|
||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
|
||||
@@ -37,6 +38,9 @@ export type AgentModelsResponse = {
|
||||
models: AgentModelOption[];
|
||||
};
|
||||
|
||||
export type AgentInputDelivery = "steer" | "queue";
|
||||
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
|
||||
|
||||
export type AgentChatSession = {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
@@ -70,6 +74,21 @@ export type AgentChatSessionSummary = {
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
export type AgentRecentActivity = {
|
||||
id: string;
|
||||
kind: "alert" | "task" | "analysis" | "session";
|
||||
title: string;
|
||||
occurred_at: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
origin: "backend" | "agent" | "demo";
|
||||
};
|
||||
|
||||
export type AgentPromptSuggestion = {
|
||||
question: string;
|
||||
activityId: string | null;
|
||||
};
|
||||
|
||||
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||
sessionId: string;
|
||||
isTitleManuallyEdited: boolean;
|
||||
@@ -94,7 +113,12 @@ export type AgentSessionStreamDataEventType =
|
||||
| "session_title"
|
||||
| "done"
|
||||
| "error"
|
||||
| "auth_required";
|
||||
| "auth_required"
|
||||
| "credential_refresh_required"
|
||||
| "credential_refreshed"
|
||||
| "credential_refresh_failed"
|
||||
| "agent_message"
|
||||
| "user_input";
|
||||
|
||||
export type AgentSessionStreamEvent =
|
||||
| {
|
||||
@@ -112,6 +136,12 @@ export type AgentSessionStreamEvent =
|
||||
};
|
||||
|
||||
export type AgentApiClient = {
|
||||
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
|
||||
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
|
||||
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
|
||||
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
|
||||
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
|
||||
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
|
||||
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
|
||||
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
|
||||
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
|
||||
@@ -143,17 +173,18 @@ export type AgentApiClient = {
|
||||
options: { sessionId: string; answers: string[][] }
|
||||
) => Promise<void>;
|
||||
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
|
||||
refreshCredential: (sessionId: string, requestId: string) => Promise<void>;
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type AgentApiClientOptions = {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getProjectId?: () => Promise<string>;
|
||||
getProjectId?: () => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
|
||||
|
||||
const CHAT_PATH = "/api/v1/agent/chat";
|
||||
const API_PATH = "/api/v1";
|
||||
|
||||
export function createAgentApiClient(
|
||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||
@@ -187,6 +218,44 @@ export function createAgentApiClient(
|
||||
};
|
||||
|
||||
return {
|
||||
async submitResponseInput(sessionId, text, delivery, id) {
|
||||
return requestJson<AgentResponseInput>("/responses", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
|
||||
});
|
||||
},
|
||||
async listResponseInputs(sessionId) {
|
||||
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
|
||||
return result.items;
|
||||
},
|
||||
async updateResponseInput(sessionId, id, delivery) {
|
||||
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
|
||||
});
|
||||
},
|
||||
async generatePromptSuggestions(activities, model, signal) {
|
||||
const result = await requestJson<unknown>("/suggestions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ activities, model }),
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(result) || !Array.isArray(result.suggestions) || result.suggestions.length < 1 || result.suggestions.length > 3) {
|
||||
throw new Error("Invalid Agent suggestions");
|
||||
}
|
||||
const ids = new Set(activities.map((item) => item.id));
|
||||
const questions = new Set<string>();
|
||||
return result.suggestions.map((item): AgentPromptSuggestion => {
|
||||
if (!isRecord(item) || typeof item.question !== "string" || !item.question.trim() || item.question.length > 180 ||
|
||||
(item.activity_id !== null && (typeof item.activity_id !== "string" || !ids.has(item.activity_id)))) {
|
||||
throw new Error("Invalid Agent suggestion");
|
||||
}
|
||||
const question = item.question.trim();
|
||||
if (questions.has(question)) throw new Error("Duplicate Agent suggestion");
|
||||
questions.add(question);
|
||||
return { question, activityId: item.activity_id as string | null };
|
||||
});
|
||||
},
|
||||
async createSession(options) {
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
@@ -254,6 +323,22 @@ export function createAgentApiClient(
|
||||
await readSessionSseStream(response, options.onEvent);
|
||||
},
|
||||
|
||||
async listAgentMessages(sessionId, options) {
|
||||
const query = new URLSearchParams({ limit: "50" });
|
||||
if (options?.cursor) query.set("cursor", options.cursor);
|
||||
return agentMessagePageSchema.parse(await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}/agent-messages?${query}`, { signal: options?.signal }
|
||||
));
|
||||
},
|
||||
|
||||
async streamAgentMessages(sessionId, options) {
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}/agent-messages/stream`, { signal: options.signal });
|
||||
if (!response.ok) throw new Error(getResponseErrorMessage(await response.json(), response.status));
|
||||
await readSessionSseStream(response, (event) => {
|
||||
if (event.type === "agent_message" && event.sessionId === sessionId) options.onMessage();
|
||||
});
|
||||
},
|
||||
|
||||
async updateSessionTitle(sessionId, title, options) {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
@@ -329,6 +414,13 @@ export function createAgentApiClient(
|
||||
});
|
||||
},
|
||||
|
||||
async refreshCredential(sessionId, requestId) {
|
||||
await options.getAccessToken?.(true);
|
||||
await requestJson(`/session/${encodeURIComponent(sessionId)}/credentials`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_id: requestId })
|
||||
});
|
||||
},
|
||||
async abort(sessionId) {
|
||||
await requestJson<unknown>("/abort", {
|
||||
method: "POST",
|
||||
@@ -346,7 +438,7 @@ async function fetchWithFallback(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getProjectId?: () => Promise<string>
|
||||
getProjectId?: () => Promise<string | undefined>
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
@@ -361,7 +453,7 @@ async function fetchWithFallback(
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
@@ -603,7 +695,12 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
||||
value === "session_title" ||
|
||||
value === "done" ||
|
||||
value === "error" ||
|
||||
value === "credential_refresh_required" ||
|
||||
value === "credential_refreshed" ||
|
||||
value === "credential_refresh_failed" ||
|
||||
value === "auth_required"
|
||||
|| value === "agent_message"
|
||||
|| value === "user_input"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user