mirror of
https://github.com/kennethnym/aris.git
synced 2026-06-15 12:01:18 +01:00
Compare commits
2 Commits
feat/defau
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fc443d967d | |||
| 9836d7499b |
@@ -15,20 +15,8 @@ interface AuthSession {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProposedAction {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
sourceId?: string
|
||||
actionId?: string
|
||||
params?: unknown
|
||||
requiresConfirmation: true
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface QueryResponse {
|
||||
message: string
|
||||
proposedActions: ProposedAction[]
|
||||
}
|
||||
|
||||
interface QueryToolDefinition {
|
||||
@@ -187,7 +175,6 @@ async function askAgent(backendUrl: string, cookies: CookieJar, message: string)
|
||||
}
|
||||
|
||||
console.log(`\nagent> ${data.message || "(no message)"}`)
|
||||
printProposedActions(data.proposedActions)
|
||||
console.log("")
|
||||
}
|
||||
|
||||
@@ -366,22 +353,6 @@ function printHelp(): void {
|
||||
console.log(" /quit Exit\n")
|
||||
}
|
||||
|
||||
function printProposedActions(actions: ProposedAction[]): void {
|
||||
if (actions.length === 0) return
|
||||
|
||||
console.log("\nProposed actions:")
|
||||
for (const action of actions) {
|
||||
console.log(`- ${action.title} (${action.id})`)
|
||||
console.log(` ${action.description}`)
|
||||
if (action.sourceId || action.actionId) {
|
||||
console.log(` source=${action.sourceId ?? "-"} action=${action.actionId ?? "-"}`)
|
||||
}
|
||||
if (action.params !== undefined) {
|
||||
console.log(` params=${JSON.stringify(action.params)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function askRequired(
|
||||
label: string,
|
||||
defaultValue?: string,
|
||||
@@ -579,9 +550,7 @@ function isAuthSession(value: unknown): value is AuthSession {
|
||||
|
||||
function isQueryResponse(value: unknown): value is QueryResponse {
|
||||
if (!isJsonObject(value)) return false
|
||||
if (typeof value.message !== "string") return false
|
||||
if (!Array.isArray(value.proposedActions)) return false
|
||||
return value.proposedActions.every(isProposedAction)
|
||||
return typeof value.message === "string"
|
||||
}
|
||||
|
||||
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
|
||||
@@ -616,20 +585,6 @@ function isSourceActionDefinition(value: unknown): value is { id: string; descri
|
||||
)
|
||||
}
|
||||
|
||||
function isProposedAction(value: unknown): value is ProposedAction {
|
||||
if (!isJsonObject(value)) return false
|
||||
|
||||
return (
|
||||
typeof value.id === "string" &&
|
||||
typeof value.title === "string" &&
|
||||
typeof value.description === "string" &&
|
||||
(value.sourceId === undefined || typeof value.sourceId === "string") &&
|
||||
(value.actionId === undefined || typeof value.actionId === "string") &&
|
||||
value.requiresConfirmation === true &&
|
||||
typeof value.createdAt === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,28 @@ describe("query debug tools", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("executes source action directly", async () => {
|
||||
const tools = createTestDebugTools()
|
||||
const params = { title: "Buy tea" }
|
||||
|
||||
const result = await tools.execute("user-1", "freya_execute_action", {
|
||||
sourceId: "freya.reminders",
|
||||
actionId: "create-reminder",
|
||||
params,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
sourceId: "freya.reminders",
|
||||
actionId: "create-reminder",
|
||||
result: {
|
||||
sourceId: "freya.reminders",
|
||||
actionId: "create-reminder",
|
||||
params,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createTestDebugTools() {
|
||||
@@ -109,6 +131,16 @@ function createTestDebugTools() {
|
||||
async listActions(sourceId: string) {
|
||||
return actions[sourceId] ?? {}
|
||||
},
|
||||
async executeAction(sourceId: string, actionId: string, params: unknown) {
|
||||
const sourceActions = actions[sourceId]
|
||||
if (!sourceActions) {
|
||||
throw new Error(`Source not found: ${sourceId}`)
|
||||
}
|
||||
if (!(actionId in sourceActions)) {
|
||||
throw new Error(`Action "${actionId}" not found on source "${sourceId}"`)
|
||||
}
|
||||
return { sourceId, actionId, params }
|
||||
},
|
||||
},
|
||||
hasSource(sourceId: string) {
|
||||
return sourceId in actions
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { contextKey, type ContextKeyPart } from "@freya/core"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
import type { ProposedAction } from "./query-agent.ts"
|
||||
|
||||
type ToolParams = Record<string, unknown>
|
||||
|
||||
@@ -23,7 +22,7 @@ const FreyaGetContextTool = "freya_get_context"
|
||||
const FreyaListContextTool = "freya_list_context"
|
||||
const FreyaGetSourceDataTool = "freya_get_source_data"
|
||||
const FreyaGetFeedItemTool = "freya_get_feed_item"
|
||||
const FreyaProposeActionTool = "freya_propose_action"
|
||||
const FreyaExecuteActionTool = "freya_execute_action"
|
||||
|
||||
export function createQueryDebugTools(sessionManager: UserSessionManager): QueryDebugTools {
|
||||
return new DefaultQueryDebugTools(sessionManager)
|
||||
@@ -86,14 +85,12 @@ class DefaultQueryDebugTools implements QueryDebugTools {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: FreyaProposeActionTool,
|
||||
label: "Propose FREYA Action",
|
||||
description: "Create a proposed action object without executing it.",
|
||||
name: FreyaExecuteActionTool,
|
||||
label: "Execute FREYA Action",
|
||||
description: "Execute an available source action immediately.",
|
||||
parameters: {
|
||||
title: "string",
|
||||
description: "string",
|
||||
sourceId: "string?",
|
||||
actionId: "string?",
|
||||
sourceId: "string",
|
||||
actionId: "string",
|
||||
params: "unknown?",
|
||||
},
|
||||
},
|
||||
@@ -114,8 +111,8 @@ class DefaultQueryDebugTools implements QueryDebugTools {
|
||||
return this.listContext(userId)
|
||||
case FreyaGetSourceDataTool:
|
||||
return this.getSourceData(userId, expectToolParams(params, ["sourceId"]))
|
||||
case FreyaProposeActionTool:
|
||||
return proposeAction(expectToolParams(params, ["title", "description"]))
|
||||
case FreyaExecuteActionTool:
|
||||
return this.executeAction(userId, expectToolParams(params, ["sourceId", "actionId"]))
|
||||
default:
|
||||
throw new Error(`Unknown debug tool: ${toolName}`)
|
||||
}
|
||||
@@ -322,27 +319,20 @@ class DefaultQueryDebugTools implements QueryDebugTools {
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function proposeAction(params: ToolParams): unknown {
|
||||
const sourceId = optionalString(params, "sourceId")
|
||||
const actionId = optionalString(params, "actionId")
|
||||
const action: ProposedAction = {
|
||||
id: crypto.randomUUID(),
|
||||
title: expectString(params, "title"),
|
||||
description: expectString(params, "description"),
|
||||
requiresConfirmation: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(sourceId ? { sourceId } : {}),
|
||||
...(actionId ? { actionId } : {}),
|
||||
...("params" in params ? { params: params.params } : {}),
|
||||
}
|
||||
private async executeAction(userId: string, params: ToolParams): Promise<unknown> {
|
||||
const sourceId = expectString(params, "sourceId")
|
||||
const actionId = expectString(params, "actionId")
|
||||
const actionParams = "params" in params ? params.params : undefined
|
||||
const userSession = await this.sessionManager.getOrCreate(userId)
|
||||
const result = await userSession.engine.executeAction(sourceId, actionId, actionParams)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
proposedActionId: action.id,
|
||||
requiresConfirmation: true,
|
||||
proposedAction: action,
|
||||
sourceId,
|
||||
actionId,
|
||||
result: result ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.ts"
|
||||
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
|
||||
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
|
||||
|
||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts"
|
||||
@@ -80,21 +80,10 @@ describe("POST /api/agent", () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("collects text deltas and proposed actions", async () => {
|
||||
const action: ProposedAction = {
|
||||
id: "proposal-1",
|
||||
title: "Update commute line",
|
||||
description: "Set the user's commute line to Victoria.",
|
||||
sourceId: "freya.tfl",
|
||||
actionId: "set-lines-of-interest",
|
||||
params: ["victoria"],
|
||||
requiresConfirmation: true,
|
||||
createdAt: "2026-06-12T12:00:00.000Z",
|
||||
}
|
||||
test("collects text deltas", async () => {
|
||||
const agent = new FakeQueryAgent([
|
||||
{ type: "text_delta", text: "You should " },
|
||||
{ type: "text_delta", text: "leave at 8:30." },
|
||||
{ type: "action_proposed", action },
|
||||
{ type: "done" },
|
||||
])
|
||||
const app = buildTestApp(agent, "user-1")
|
||||
@@ -112,10 +101,8 @@ describe("POST /api/agent", () => {
|
||||
|
||||
const body = (await res.json()) as {
|
||||
message: string
|
||||
proposedActions: ProposedAction[]
|
||||
}
|
||||
expect(body.message).toBe("You should leave at 8:30.")
|
||||
expect(body.proposedActions).toEqual([action])
|
||||
})
|
||||
|
||||
test("returns 400 for invalid body", async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
|
||||
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
|
||||
|
||||
import { InMemoryResourceLoader } from "./in-memory-resource-loader.ts"
|
||||
import defaultSystemPrompt from "./prompts/system.txt"
|
||||
@@ -28,24 +28,18 @@ export interface PiQueryAgentConfig {
|
||||
apiKey?: string
|
||||
cwd?: string
|
||||
systemPrompt?: string
|
||||
clock?: () => Date
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
proposedActions: ProposedAction[]
|
||||
}
|
||||
|
||||
export class PiQueryAgent implements QueryAgent {
|
||||
private readonly sessionManager: UserSessionManager
|
||||
private readonly cwd: string
|
||||
private readonly systemPrompt: string
|
||||
private readonly clock: () => Date
|
||||
private readonly modelProvider: string
|
||||
private readonly modelId: string
|
||||
private readonly apiKey: string | undefined
|
||||
private readonly sessions = new Map<string, PiSession>()
|
||||
private readonly pendingSessions = new Map<string, Promise<PiSession>>()
|
||||
private readonly activeRuns = new Map<string, ActiveRun>()
|
||||
private readonly activeRuns = new Map<string, symbol>()
|
||||
|
||||
constructor(config: PiQueryAgentConfig) {
|
||||
this.sessionManager = config.sessionManager
|
||||
@@ -54,7 +48,6 @@ export class PiQueryAgent implements QueryAgent {
|
||||
this.apiKey = config.apiKey
|
||||
this.cwd = config.cwd ?? tmpdir()
|
||||
this.systemPrompt = config.systemPrompt ?? defaultSystemPrompt
|
||||
this.clock = config.clock ?? (() => new Date())
|
||||
}
|
||||
|
||||
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
|
||||
@@ -66,7 +59,7 @@ export class PiQueryAgent implements QueryAgent {
|
||||
return
|
||||
}
|
||||
|
||||
const run: ActiveRun = { proposedActions: [] }
|
||||
const run = Symbol(input.userId)
|
||||
this.activeRuns.set(input.userId, run)
|
||||
|
||||
let session: PiSession
|
||||
@@ -117,9 +110,6 @@ export class PiQueryAgent implements QueryAgent {
|
||||
void this.runPrompt(session, input)
|
||||
.then(() => {
|
||||
if (runFailed) return
|
||||
for (const action of run.proposedActions) {
|
||||
pushRunEvent({ type: "action_proposed", action })
|
||||
}
|
||||
pushRunEvent({ type: "done" })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -161,7 +151,7 @@ export class PiQueryAgent implements QueryAgent {
|
||||
this.activeRuns.clear()
|
||||
}
|
||||
|
||||
private clearActiveRun(userId: string, run: ActiveRun): void {
|
||||
private clearActiveRun(userId: string, run: symbol): void {
|
||||
if (this.activeRuns.get(userId) === run) {
|
||||
this.activeRuns.delete(userId)
|
||||
}
|
||||
@@ -214,10 +204,6 @@ export class PiQueryAgent implements QueryAgent {
|
||||
customTools: createFreyaAgentTools({
|
||||
userId,
|
||||
sessionManager: this.sessionManager,
|
||||
clock: this.clock,
|
||||
proposeAction: (action) => {
|
||||
this.activeRuns.get(userId)?.proposedActions.push(action)
|
||||
},
|
||||
}),
|
||||
tools: [...FREYA_AGENT_TOOL_NAMES],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<identity>
|
||||
You are Freya. You are a digital companion created by Kenneth. His twitter is @kennethnym.
|
||||
You have access to user data via the context graph. It stores the latest snapshot of all user data and context.
|
||||
It reactively updates based on external events, such as, but not exclusively, when the user moves, when a new email arrives, when weather updates are available, and when transit alerts are issued.
|
||||
</identity>
|
||||
|
||||
<action>
|
||||
@@ -15,9 +17,9 @@ freya_list_context: when you need to inspect all current context graph entries.
|
||||
|
||||
freya_get_source_data: when you need current feed items, context entries, actions, or errors for a specific source ID.
|
||||
|
||||
freya_propose_action: when the user asks to change state or when you recommend a concrete action that should be confirmed first. This tool only proposes an action. It does not execute the action.
|
||||
freya_execute_action: when the user asks you to perform an available source action, or when the source action is non-mutating and tool-like. This executes immediately.
|
||||
|
||||
if you need more information to answer user's query, call freya_propose_action with freya.web-search source id.
|
||||
If you need more information to answer user's query, call freya_execute_action with sourceId "freya.web-search", actionId "search", and params containing query and numResults, for example {"query":"latest relevant information","numResults":5}.
|
||||
</action>
|
||||
|
||||
<behavior>
|
||||
|
||||
@@ -3,22 +3,10 @@ export interface QueryAgentAsk {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ProposedAction {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
sourceId?: string
|
||||
actionId?: string
|
||||
params?: unknown
|
||||
requiresConfirmation: true
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type QueryAgentEvent =
|
||||
| { type: "text_delta"; text: string }
|
||||
| { type: "tool_start"; toolName: string }
|
||||
| { type: "tool_end"; toolName: string; ok: boolean }
|
||||
| { type: "action_proposed"; action: ProposedAction }
|
||||
| { type: "done" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
@@ -30,7 +18,6 @@ export interface QueryAgent {
|
||||
|
||||
export interface QueryAgentResponse {
|
||||
message: string
|
||||
proposedActions: ProposedAction[]
|
||||
}
|
||||
|
||||
export class QueryAgentError extends Error {
|
||||
@@ -45,16 +32,12 @@ export async function collectQueryAgentResponse(
|
||||
input: QueryAgentAsk,
|
||||
): Promise<QueryAgentResponse> {
|
||||
let message = ""
|
||||
const proposedActions: ProposedAction[] = []
|
||||
|
||||
for await (const event of agent.ask(input)) {
|
||||
switch (event.type) {
|
||||
case "text_delta":
|
||||
message += event.text
|
||||
break
|
||||
case "action_proposed":
|
||||
proposedActions.push(event.action)
|
||||
break
|
||||
case "error":
|
||||
throw new QueryAgentError(event.message)
|
||||
case "tool_start":
|
||||
@@ -64,5 +47,5 @@ export async function collectQueryAgentResponse(
|
||||
}
|
||||
}
|
||||
|
||||
return { message, proposedActions }
|
||||
return { message }
|
||||
}
|
||||
|
||||
@@ -3,15 +3,12 @@ import { Type } from "typebox"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
import type { QueryDebugTools } from "./debug-tools.ts"
|
||||
import type { ProposedAction } from "./query-agent.ts"
|
||||
|
||||
import { createQueryDebugTools } from "./debug-tools.ts"
|
||||
|
||||
interface CreateFreyaAgentToolsConfig {
|
||||
userId: string
|
||||
sessionManager: UserSessionManager
|
||||
clock: () => Date
|
||||
proposeAction(action: ProposedAction): void
|
||||
}
|
||||
|
||||
export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context"
|
||||
@@ -20,7 +17,7 @@ export const FREYA_GET_CONTEXT_TOOL = "freya_get_context"
|
||||
export const FREYA_LIST_CONTEXT_TOOL = "freya_list_context"
|
||||
export const FREYA_GET_SOURCE_DATA_TOOL = "freya_get_source_data"
|
||||
export const FREYA_GET_FEED_ITEM_TOOL = "freya_get_feed_item"
|
||||
export const FREYA_PROPOSE_ACTION_TOOL = "freya_propose_action"
|
||||
export const FREYA_EXECUTE_ACTION_TOOL = "freya_execute_action"
|
||||
|
||||
export const FREYA_AGENT_TOOL_NAMES = [
|
||||
FREYA_LIST_SOURCES_TOOL,
|
||||
@@ -29,7 +26,7 @@ export const FREYA_AGENT_TOOL_NAMES = [
|
||||
FREYA_QUERY_CONTEXT_TOOL,
|
||||
FREYA_LIST_CONTEXT_TOOL,
|
||||
FREYA_GET_SOURCE_DATA_TOOL,
|
||||
FREYA_PROPOSE_ACTION_TOOL,
|
||||
FREYA_EXECUTE_ACTION_TOOL,
|
||||
]
|
||||
|
||||
export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
|
||||
@@ -121,28 +118,21 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
|
||||
execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params),
|
||||
})
|
||||
|
||||
const proposeActionTool = defineTool({
|
||||
name: FREYA_PROPOSE_ACTION_TOOL,
|
||||
label: "Propose FREYA Action",
|
||||
description: "Create a proposed action for the user to review. This never executes the action.",
|
||||
const executeActionTool = defineTool({
|
||||
name: FREYA_EXECUTE_ACTION_TOOL,
|
||||
label: "Execute FREYA Action",
|
||||
description:
|
||||
"Execute an available FREYA source action immediately without creating a proposal.",
|
||||
parameters: Type.Object({
|
||||
title: Type.String({ description: "Short user-facing action title." }),
|
||||
description: Type.String({
|
||||
description: "What will happen if the user confirms this action.",
|
||||
}),
|
||||
sourceId: Type.Optional(
|
||||
Type.String({ description: "Source ID that should execute the action, if known." }),
|
||||
),
|
||||
actionId: Type.Optional(
|
||||
Type.String({ description: "Source action ID to execute after confirmation, if known." }),
|
||||
),
|
||||
sourceId: Type.String({ description: "Source ID that should execute the action." }),
|
||||
actionId: Type.String({ description: "Source action ID to execute." }),
|
||||
params: Type.Optional(
|
||||
Type.Unknown({
|
||||
description: "Parameters to pass to the source action after confirmation.",
|
||||
description: "Parameters to pass to the source action.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => executeProposeActionTool(config, params),
|
||||
execute: async (_toolCallId, params) => executeActionToolCall(config, params),
|
||||
})
|
||||
|
||||
return [
|
||||
@@ -152,7 +142,7 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
|
||||
queryContextTool,
|
||||
listContextTool,
|
||||
getSourceDataTool,
|
||||
proposeActionTool,
|
||||
executeActionTool,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -285,28 +275,26 @@ async function executeGetSourceDataTool(
|
||||
}
|
||||
}
|
||||
|
||||
function executeProposeActionTool(
|
||||
async function executeActionToolCall(
|
||||
config: CreateFreyaAgentToolsConfig,
|
||||
params: {
|
||||
title: string
|
||||
description: string
|
||||
sourceId?: string
|
||||
actionId?: string
|
||||
sourceId: string
|
||||
actionId: string
|
||||
params?: unknown
|
||||
},
|
||||
) {
|
||||
const action: ProposedAction = {
|
||||
id: crypto.randomUUID(),
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
requiresConfirmation: true,
|
||||
createdAt: config.clock().toISOString(),
|
||||
...(params.sourceId ? { sourceId: params.sourceId } : {}),
|
||||
...(params.actionId ? { actionId: params.actionId } : {}),
|
||||
...(params.params !== undefined ? { params: params.params } : {}),
|
||||
}
|
||||
const userSession = await config.sessionManager.getOrCreate(config.userId)
|
||||
const result = await userSession.engine.executeAction(
|
||||
params.sourceId,
|
||||
params.actionId,
|
||||
params.params,
|
||||
)
|
||||
|
||||
config.proposeAction(action)
|
||||
const actionExecution = {
|
||||
sourceId: params.sourceId,
|
||||
actionId: params.actionId,
|
||||
result: result ?? null,
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
@@ -314,11 +302,10 @@ function executeProposeActionTool(
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
proposedActionId: action.id,
|
||||
requiresConfirmation: true,
|
||||
...actionExecution,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: { proposedAction: action },
|
||||
details: { actionExecution },
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user