@townco/agent 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,8 @@ export declare class AgentAcpAdapter implements acp.Agent {
24
24
  private agentVersion;
25
25
  private agentDescription;
26
26
  private agentSuggestedPrompts;
27
+ private agentInitialMessage;
28
+ private agentUiConfig;
27
29
  private currentToolOverheadTokens;
28
30
  private currentMcpOverheadTokens;
29
31
  constructor(agent: AgentRunner, connection: acp.AgentSideConnection, agentDir?: string, agentName?: string);
@@ -48,6 +50,11 @@ export declare class AgentAcpAdapter implements acp.Agent {
48
50
  private saveSessionToDisk;
49
51
  initialize(_params: acp.InitializeRequest): Promise<acp.InitializeResponse>;
50
52
  newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse>;
53
+ /**
54
+ * Store an initial message in the session.
55
+ * Called by the HTTP transport after sending the initial message via SSE.
56
+ */
57
+ storeInitialMessage(sessionId: string, content: string): Promise<void>;
51
58
  loadSession(params: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse>;
52
59
  authenticate(_params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | undefined>;
53
60
  setSessionMode(_params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse>;
@@ -105,6 +105,8 @@ export class AgentAcpAdapter {
105
105
  agentVersion;
106
106
  agentDescription;
107
107
  agentSuggestedPrompts;
108
+ agentInitialMessage;
109
+ agentUiConfig;
108
110
  currentToolOverheadTokens = 0; // Track tool overhead for current turn
109
111
  currentMcpOverheadTokens = 0; // Track MCP overhead for current turn
110
112
  constructor(agent, connection, agentDir, agentName) {
@@ -117,6 +119,8 @@ export class AgentAcpAdapter {
117
119
  this.agentVersion = agent.definition.version;
118
120
  this.agentDescription = agent.definition.description;
119
121
  this.agentSuggestedPrompts = agent.definition.suggestedPrompts;
122
+ this.agentInitialMessage = agent.definition.initialMessage;
123
+ this.agentUiConfig = agent.definition.uiConfig;
120
124
  this.noSession = process.env.TOWN_NO_SESSION === "true";
121
125
  this.storage =
122
126
  agentDir && agentName && !this.noSession
@@ -129,6 +133,7 @@ export class AgentAcpAdapter {
129
133
  agentVersion: this.agentVersion,
130
134
  agentDescription: this.agentDescription,
131
135
  suggestedPrompts: this.agentSuggestedPrompts,
136
+ initialMessage: this.agentInitialMessage,
132
137
  noSession: this.noSession,
133
138
  hasStorage: this.storage !== null,
134
139
  sessionStoragePath: this.storage ? `${agentDir}/.sessions` : null,
@@ -259,6 +264,10 @@ export class AgentAcpAdapter {
259
264
  ...(this.agentSuggestedPrompts
260
265
  ? { suggestedPrompts: this.agentSuggestedPrompts }
261
266
  : {}),
267
+ ...(this.agentInitialMessage
268
+ ? { initialMessage: this.agentInitialMessage }
269
+ : {}),
270
+ ...(this.agentUiConfig ? { uiConfig: this.agentUiConfig } : {}),
262
271
  ...(toolsMetadata.length > 0 ? { tools: toolsMetadata } : {}),
263
272
  ...(mcpsMetadata.length > 0 ? { mcps: mcpsMetadata } : {}),
264
273
  ...(subagentsMetadata.length > 0 ? { subagents: subagentsMetadata } : {}),
@@ -273,10 +282,38 @@ export class AgentAcpAdapter {
273
282
  context: [],
274
283
  requestParams: params,
275
284
  });
285
+ // Note: Initial message is sent by the HTTP transport when SSE connection is established
286
+ // This ensures the message is delivered after the client is ready to receive it
276
287
  return {
277
288
  sessionId,
278
289
  };
279
290
  }
291
+ /**
292
+ * Store an initial message in the session.
293
+ * Called by the HTTP transport after sending the initial message via SSE.
294
+ */
295
+ async storeInitialMessage(sessionId, content) {
296
+ const session = this.sessions.get(sessionId);
297
+ if (!session) {
298
+ logger.warn("Cannot store initial message - session not found", {
299
+ sessionId,
300
+ });
301
+ return;
302
+ }
303
+ // Add the initial message as an assistant message
304
+ const initialMessage = {
305
+ role: "assistant",
306
+ content: [{ type: "text", text: content }],
307
+ timestamp: new Date().toISOString(),
308
+ };
309
+ session.messages.push(initialMessage);
310
+ // Save to disk if session persistence is enabled
311
+ await this.saveSessionToDisk(sessionId, session);
312
+ logger.debug("Stored initial message in session", {
313
+ sessionId,
314
+ contentPreview: content.slice(0, 100),
315
+ });
316
+ }
280
317
  async loadSession(params) {
281
318
  if (!this.storage) {
282
319
  throw new Error("Session storage is not configured");
@@ -510,13 +547,11 @@ export class AgentAcpAdapter {
510
547
  ? session.context[session.context.length - 1]
511
548
  : undefined;
512
549
  // Calculate context size for this snapshot
513
- // Build message pointers for the new context (previous messages + new user message)
550
+ // Build message pointers for the new context (previous messages only, NOT the new user message)
551
+ // The new user message will be passed separately via the prompt parameter
514
552
  const messageEntries = previousContext
515
- ? [
516
- ...previousContext.messages,
517
- { type: "pointer", index: session.messages.length - 1 },
518
- ]
519
- : [{ type: "pointer", index: 0 }];
553
+ ? [...previousContext.messages]
554
+ : [];
520
555
  // Resolve message entries to actual messages
521
556
  const contextMessages = [];
522
557
  for (const entry of messageEntries) {
@@ -741,6 +776,23 @@ export class AgentAcpAdapter {
741
776
  toolCallBlock.completedAt = Date.now();
742
777
  }
743
778
  }
779
+ // Forward tool_call_update with _meta to the client (for subagent connection info, etc.)
780
+ if (updateMsg._meta) {
781
+ logger.info("Forwarding tool_call_update with _meta to client", {
782
+ toolCallId: updateMsg.toolCallId,
783
+ status: updateMsg.status,
784
+ _meta: updateMsg._meta,
785
+ });
786
+ this.connection.sessionUpdate({
787
+ sessionId: params.sessionId,
788
+ update: {
789
+ sessionUpdate: "tool_call_update",
790
+ toolCallId: updateMsg.toolCallId,
791
+ status: updateMsg.status,
792
+ _meta: updateMsg._meta,
793
+ },
794
+ });
795
+ }
744
796
  }
745
797
  // Handle tool_output - update ToolCallBlock with output content
746
798
  if ("sessionUpdate" in msg && msg.sessionUpdate === "tool_output") {
@@ -808,33 +860,53 @@ export class AgentAcpAdapter {
808
860
  // Check if we already have a partial assistant message in messages
809
861
  const lastMessage = session.messages[session.messages.length - 1];
810
862
  let partialMessageIndex;
863
+ let userMessageIndex;
864
+ let isFirstToolInTurn;
811
865
  if (lastMessage && lastMessage.role === "assistant") {
812
- // Update existing partial message
866
+ // Update existing partial message (subsequent tool in same turn)
813
867
  session.messages[session.messages.length - 1] =
814
868
  partialAssistantMessage;
815
869
  partialMessageIndex = session.messages.length - 1;
870
+ userMessageIndex = session.messages.length - 2;
871
+ isFirstToolInTurn = false;
816
872
  }
817
873
  else {
818
- // Add new partial message
874
+ // Add new partial message (first tool in this turn)
819
875
  session.messages.push(partialAssistantMessage);
820
876
  partialMessageIndex = session.messages.length - 1;
877
+ userMessageIndex = session.messages.length - 2;
878
+ isFirstToolInTurn = true;
821
879
  }
822
880
  // Get the latest context
823
881
  const latestContext = session.context.length > 0
824
882
  ? session.context[session.context.length - 1]
825
883
  : undefined;
826
884
  // Build message entries for the new context
827
- // Check if we already have a pointer to this message (during mid-turn updates)
828
885
  const existingMessages = latestContext?.messages ?? [];
829
- const lastEntry = existingMessages[existingMessages.length - 1];
830
- const alreadyHasPointer = lastEntry?.type === "pointer" &&
831
- lastEntry.index === partialMessageIndex;
832
- const messageEntries = alreadyHasPointer
833
- ? existingMessages // Don't add duplicate pointer
834
- : [
886
+ // Check if we already have pointers to these messages
887
+ const hasUserPointer = existingMessages.some((entry) => entry.type === "pointer" && entry.index === userMessageIndex);
888
+ const hasAssistantPointer = existingMessages.some((entry) => entry.type === "pointer" &&
889
+ entry.index === partialMessageIndex);
890
+ let messageEntries;
891
+ if (isFirstToolInTurn && !hasUserPointer) {
892
+ // First tool: add both user and assistant pointers
893
+ messageEntries = [
835
894
  ...existingMessages,
895
+ { type: "pointer", index: userMessageIndex },
836
896
  { type: "pointer", index: partialMessageIndex },
837
897
  ];
898
+ }
899
+ else if (!hasAssistantPointer) {
900
+ // Subsequent tool or user already added: just update/add assistant pointer
901
+ messageEntries = [
902
+ ...existingMessages,
903
+ { type: "pointer", index: partialMessageIndex },
904
+ ];
905
+ }
906
+ else {
907
+ // Both pointers already exist (updating existing assistant message)
908
+ messageEntries = existingMessages;
909
+ }
838
910
  // Resolve message entries to actual messages
839
911
  const contextMessages = [];
840
912
  for (const entry of messageEntries) {
@@ -977,13 +1049,19 @@ export class AgentAcpAdapter {
977
1049
  ? session.context[session.context.length - 1]
978
1050
  : undefined;
979
1051
  // Calculate final context size
980
- // Build message pointers for the new context
1052
+ // Build message pointers for the new context (add both user and assistant messages)
1053
+ const userMessageIndex = session.messages.length - 2;
1054
+ const assistantMessageIndex = session.messages.length - 1;
981
1055
  const messageEntries = previousContext
982
1056
  ? [
983
1057
  ...previousContext.messages,
984
- { type: "pointer", index: session.messages.length - 1 },
1058
+ { type: "pointer", index: userMessageIndex },
1059
+ { type: "pointer", index: assistantMessageIndex },
985
1060
  ]
986
- : [{ type: "pointer", index: session.messages.length - 1 }];
1061
+ : [
1062
+ { type: "pointer", index: userMessageIndex },
1063
+ { type: "pointer", index: assistantMessageIndex },
1064
+ ];
987
1065
  // Resolve message entries to actual messages
988
1066
  const contextMessages = [];
989
1067
  for (const entry of messageEntries) {
@@ -10,6 +10,7 @@ import { streamSSE } from "hono/streaming";
10
10
  import { createLogger, isSubagent } from "../logger.js";
11
11
  import { makeRunnerFromDefinition } from "../runner";
12
12
  import { AgentAcpAdapter } from "./adapter";
13
+ import { SessionStorage } from "./session-storage";
13
14
  const logger = createLogger("http");
14
15
  /**
15
16
  * Compress a payload using gzip if it's too large for PostgreSQL NOTIFY
@@ -65,10 +66,19 @@ export function makeHttpTransport(agent, agentDir, agentName) {
65
66
  const outbound = new TransformStream();
66
67
  const bridge = acp.ndJsonStream(outbound.writable, inbound.readable);
67
68
  const agentRunner = "definition" in agent ? agent : makeRunnerFromDefinition(agent);
68
- new acp.AgentSideConnection((conn) => new AgentAcpAdapter(agentRunner, conn, agentDir, agentName), bridge);
69
+ // Store adapter reference so we can call methods on it (e.g., storeInitialMessage)
70
+ let acpAdapter = null;
71
+ new acp.AgentSideConnection((conn) => {
72
+ acpAdapter = new AgentAcpAdapter(agentRunner, conn, agentDir, agentName);
73
+ return acpAdapter;
74
+ }, bridge);
69
75
  const app = new Hono();
70
76
  // Track active SSE streams by sessionId for direct output delivery
71
77
  const sseStreams = new Map();
78
+ // Track sessions that have already received initial message
79
+ const initialMessageSentSessions = new Set();
80
+ // Get initial message config from agent definition
81
+ const initialMessageConfig = agentRunner.definition.initialMessage;
72
82
  const decoder = new TextDecoder();
73
83
  const encoder = new TextEncoder();
74
84
  (async () => {
@@ -270,6 +280,28 @@ export function makeHttpTransport(agent, agentDir, agentName) {
270
280
  allowMethods: ["GET", "POST", "OPTIONS"],
271
281
  }));
272
282
  app.get("/health", (c) => c.json({ ok: true }));
283
+ // List available sessions
284
+ app.get("/sessions", async (c) => {
285
+ if (!agentDir || !agentName) {
286
+ return c.json({ sessions: [], error: "Session storage not configured" });
287
+ }
288
+ const noSession = process.env.TOWN_NO_SESSION === "true";
289
+ if (noSession) {
290
+ return c.json({ sessions: [], error: "Sessions disabled" });
291
+ }
292
+ try {
293
+ const storage = new SessionStorage(agentDir, agentName);
294
+ const sessions = await storage.listSessionsWithMetadata();
295
+ return c.json({ sessions });
296
+ }
297
+ catch (error) {
298
+ logger.error("Failed to list sessions", { error });
299
+ return c.json({
300
+ sessions: [],
301
+ error: error instanceof Error ? error.message : String(error),
302
+ }, 500);
303
+ }
304
+ });
273
305
  // Serve static files from agent directory (for generated images, etc.)
274
306
  if (agentDir) {
275
307
  app.get("/static/*", async (c) => {
@@ -319,6 +351,47 @@ export function makeHttpTransport(agent, agentDir, agentName) {
319
351
  // Register this stream for direct tool output delivery
320
352
  sseStreams.set(sessionId, stream);
321
353
  await stream.writeSSE({ event: "ping", data: "{}" });
354
+ // Send initial message if configured and not already sent for this session
355
+ if (initialMessageConfig?.enabled &&
356
+ initialMessageConfig.content &&
357
+ !initialMessageSentSessions.has(sessionId)) {
358
+ initialMessageSentSessions.add(sessionId);
359
+ // Process template variables in the content
360
+ let content = initialMessageConfig.content;
361
+ content = content.replace(/\{\{\.AgentName\}\}/g, agentName ?? "Agent");
362
+ const displayName = agentRunner.definition.displayName;
363
+ content = content.replace(/\{\{\.DisplayName\}\}/g, displayName ?? agentName ?? "Agent");
364
+ const initialMessage = {
365
+ jsonrpc: "2.0",
366
+ method: "session/update",
367
+ params: {
368
+ sessionId,
369
+ update: {
370
+ sessionUpdate: "agent_message_chunk",
371
+ content: {
372
+ type: "text",
373
+ text: content,
374
+ },
375
+ _meta: {
376
+ isInitialMessage: true,
377
+ isReplay: true, // Mark as replay so UI adds it to messages
378
+ },
379
+ },
380
+ },
381
+ };
382
+ await stream.writeSSE({
383
+ event: "message",
384
+ data: JSON.stringify(initialMessage),
385
+ });
386
+ // Store the initial message in the session for persistence
387
+ if (acpAdapter) {
388
+ await acpAdapter.storeInitialMessage(sessionId, content);
389
+ }
390
+ logger.info("Sent initial message via SSE", {
391
+ sessionId,
392
+ contentPreview: content.slice(0, 100),
393
+ });
394
+ }
322
395
  const hb = setInterval(() => {
323
396
  // Heartbeat to keep proxies from terminating idle connections
324
397
  void stream.writeSSE({ event: "ping", data: "{}" });
@@ -150,4 +150,18 @@ export declare class SessionStorage {
150
150
  * List all session IDs
151
151
  */
152
152
  listSessions(): Promise<string[]>;
153
+ /**
154
+ * Session summary for listing
155
+ */
156
+ /**
157
+ * List all sessions with metadata
158
+ * Returns sessions sorted by updatedAt (most recent first)
159
+ */
160
+ listSessionsWithMetadata(): Promise<Array<{
161
+ sessionId: string;
162
+ createdAt: string;
163
+ updatedAt: string;
164
+ messageCount: number;
165
+ firstUserMessage?: string;
166
+ }>>;
153
167
  }
@@ -236,4 +236,51 @@ export class SessionStorage {
236
236
  throw new Error(`Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`);
237
237
  }
238
238
  }
239
+ /**
240
+ * Session summary for listing
241
+ */
242
+ /**
243
+ * List all sessions with metadata
244
+ * Returns sessions sorted by updatedAt (most recent first)
245
+ */
246
+ async listSessionsWithMetadata() {
247
+ if (!existsSync(this.sessionsDir)) {
248
+ return [];
249
+ }
250
+ try {
251
+ const files = readdirSync(this.sessionsDir);
252
+ const sessionFiles = files.filter((file) => file.endsWith(".json") && !file.endsWith(".tmp"));
253
+ const sessions = [];
254
+ for (const file of sessionFiles) {
255
+ const sessionId = file.replace(".json", "");
256
+ try {
257
+ const session = this.loadSessionSync(sessionId);
258
+ if (session) {
259
+ // Find the first user message for preview
260
+ const firstUserMsg = session.messages.find((m) => m.role === "user");
261
+ const firstUserText = firstUserMsg?.content.find((c) => c.type === "text");
262
+ const entry = {
263
+ sessionId: session.sessionId,
264
+ createdAt: session.metadata.createdAt,
265
+ updatedAt: session.metadata.updatedAt,
266
+ messageCount: session.messages.length,
267
+ };
268
+ if (firstUserText && "text" in firstUserText) {
269
+ entry.firstUserMessage = firstUserText.text.slice(0, 100);
270
+ }
271
+ sessions.push(entry);
272
+ }
273
+ }
274
+ catch {
275
+ // Skip invalid sessions
276
+ }
277
+ }
278
+ // Sort by updatedAt, most recent first
279
+ sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
280
+ return sessions;
281
+ }
282
+ catch (error) {
283
+ throw new Error(`Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`);
284
+ }
285
+ }
239
286
  }
@@ -26,6 +26,15 @@ export declare const HookConfigSchema: z.ZodObject<{
26
26
  }, z.core.$strip>]>>;
27
27
  callback: z.ZodString;
28
28
  }, z.core.$strip>;
29
+ /** Initial message configuration schema. */
30
+ export declare const InitialMessageSchema: z.ZodObject<{
31
+ enabled: z.ZodBoolean;
32
+ content: z.ZodString;
33
+ }, z.core.$strip>;
34
+ /** UI configuration schema for controlling the chat interface appearance. */
35
+ export declare const UiConfigSchema: z.ZodObject<{
36
+ hideTopBar: z.ZodOptional<z.ZodBoolean>;
37
+ }, z.core.$strip>;
29
38
  /** Agent definition schema. */
30
39
  export declare const AgentDefinitionSchema: z.ZodObject<{
31
40
  displayName: z.ZodOptional<z.ZodString>;
@@ -74,4 +83,11 @@ export declare const AgentDefinitionSchema: z.ZodObject<{
74
83
  }, z.core.$strip>]>>;
75
84
  callback: z.ZodString;
76
85
  }, z.core.$strip>>>;
86
+ initialMessage: z.ZodOptional<z.ZodObject<{
87
+ enabled: z.ZodBoolean;
88
+ content: z.ZodString;
89
+ }, z.core.$strip>>;
90
+ uiConfig: z.ZodOptional<z.ZodObject<{
91
+ hideTopBar: z.ZodOptional<z.ZodBoolean>;
92
+ }, z.core.$strip>>;
77
93
  }, z.core.$strip>;
@@ -71,6 +71,18 @@ export const HookConfigSchema = z.object({
71
71
  .optional(),
72
72
  callback: z.string(),
73
73
  });
74
+ /** Initial message configuration schema. */
75
+ export const InitialMessageSchema = z.object({
76
+ /** Whether the agent should send an initial message when a session starts. */
77
+ enabled: z.boolean(),
78
+ /** The content of the initial message to send. Supports template variables like {{.AgentName}}. */
79
+ content: z.string(),
80
+ });
81
+ /** UI configuration schema for controlling the chat interface appearance. */
82
+ export const UiConfigSchema = z.object({
83
+ /** Whether to hide the top bar (session switcher, debugger link, settings). Useful for embedded/deployed mode. */
84
+ hideTopBar: z.boolean().optional(),
85
+ });
74
86
  /** Agent definition schema. */
75
87
  export const AgentDefinitionSchema = z.object({
76
88
  /** Human-readable display name for the agent (shown in UI). */
@@ -84,4 +96,8 @@ export const AgentDefinitionSchema = z.object({
84
96
  mcps: z.array(McpConfigSchema).optional(),
85
97
  harnessImplementation: z.literal("langchain").optional(),
86
98
  hooks: z.array(HookConfigSchema).optional(),
99
+ /** Configuration for an initial message the agent sends when a session starts. */
100
+ initialMessage: InitialMessageSchema.optional(),
101
+ /** UI configuration for controlling the chat interface appearance. */
102
+ uiConfig: UiConfigSchema.optional(),
87
103
  });
@@ -8,7 +8,7 @@ export declare const zAgentRunnerParams: z.ZodObject<{
8
8
  suggestedPrompts: z.ZodOptional<z.ZodArray<z.ZodString>>;
9
9
  systemPrompt: z.ZodNullable<z.ZodString>;
10
10
  model: z.ZodString;
11
- tools: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">]>, z.ZodObject<{
11
+ tools: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">, z.ZodLiteral<"browser">]>, z.ZodObject<{
12
12
  type: z.ZodLiteral<"custom">;
13
13
  modulePath: z.ZodString;
14
14
  }, z.core.$strip>, z.ZodObject<{
@@ -52,6 +52,13 @@ export declare const zAgentRunnerParams: z.ZodObject<{
52
52
  }, z.core.$strip>]>>;
53
53
  callback: z.ZodString;
54
54
  }, z.core.$strip>>>;
55
+ initialMessage: z.ZodOptional<z.ZodObject<{
56
+ enabled: z.ZodBoolean;
57
+ content: z.ZodString;
58
+ }, z.core.$strip>>;
59
+ uiConfig: z.ZodOptional<z.ZodObject<{
60
+ hideTopBar: z.ZodOptional<z.ZodBoolean>;
61
+ }, z.core.$strip>>;
55
62
  }, z.core.$strip>;
56
63
  export type CreateAgentRunnerParams = z.infer<typeof zAgentRunnerParams>;
57
64
  export interface SessionMessage {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { HookConfigSchema, McpConfigSchema } from "../definition";
2
+ import { HookConfigSchema, InitialMessageSchema, McpConfigSchema, UiConfigSchema, } from "../definition";
3
3
  import { zToolType } from "./tools";
4
4
  export const zAgentRunnerParams = z.object({
5
5
  displayName: z.string().optional(),
@@ -11,4 +11,6 @@ export const zAgentRunnerParams = z.object({
11
11
  tools: z.array(zToolType).optional(),
12
12
  mcps: z.array(McpConfigSchema).optional(),
13
13
  hooks: z.array(HookConfigSchema).optional(),
14
+ initialMessage: InitialMessageSchema.optional(),
15
+ uiConfig: UiConfigSchema.optional(),
14
16
  });