Merge remote-tracking branch 'origin/main' into feat/telegram-channel
# Conflicts: # apps/desktop/electron/electron-env.d.ts # apps/desktop/electron/ipc/index.ts # apps/desktop/electron/preload.ts # apps/desktop/src/App.tsx # apps/desktop/src/pages/layout.tsx # src/agent/async-agent.ts # src/agent/runner.ts # src/hub/hub.ts
This commit is contained in:
commit
23905daaa1
85 changed files with 7368 additions and 470 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
|
||||
import type { MulticaEvent } from "./events.js";
|
||||
|
|
@ -85,6 +84,10 @@ export class Agent {
|
|||
private readonly stderr: NodeJS.WritableStream;
|
||||
private initialized = false;
|
||||
|
||||
// Internal run state
|
||||
private _internalRun = false;
|
||||
private _runMutex: Promise<void> = Promise.resolve();
|
||||
|
||||
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
|
||||
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
|
||||
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
|
||||
|
|
@ -353,7 +356,49 @@ export class Agent {
|
|||
}
|
||||
}
|
||||
|
||||
async run(prompt: string, images?: ImageContent[]): Promise<AgentRunResult> {
|
||||
async run(prompt: string): Promise<AgentRunResult> {
|
||||
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
|
||||
return this.withRunMutex(() => this._run(prompt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a prompt as an internal turn.
|
||||
* Messages are persisted with `internal: true` and rolled back from
|
||||
* in-memory state after the turn completes, so they do not pollute
|
||||
* the main conversation context.
|
||||
*/
|
||||
async runInternal(prompt: string): Promise<AgentRunResult> {
|
||||
return this.withRunMutex(async () => {
|
||||
const messageCountBefore = this.agent.state.messages.length;
|
||||
this._internalRun = true;
|
||||
try {
|
||||
const result = await this._run(prompt);
|
||||
return result;
|
||||
} finally {
|
||||
this._internalRun = false;
|
||||
// Roll back internal messages from in-memory state
|
||||
const current = this.agent.state.messages;
|
||||
if (current.length > messageCountBefore) {
|
||||
this.agent.replaceMessages(current.slice(0, messageCountBefore));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async withRunMutex<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Chain on the mutex so only one run executes at a time
|
||||
const prev = this._runMutex;
|
||||
let resolve: () => void;
|
||||
this._runMutex = new Promise<void>((r) => { resolve = r; });
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
resolve!();
|
||||
}
|
||||
}
|
||||
|
||||
private async _run(prompt: string): Promise<AgentRunResult> {
|
||||
await this.ensureInitialized();
|
||||
this.output.state.lastAssistantText = "";
|
||||
|
||||
|
|
@ -363,7 +408,7 @@ export class Agent {
|
|||
// Loop to exhaust all candidate profiles on rotatable errors
|
||||
while (true) {
|
||||
try {
|
||||
await this.agent.prompt(prompt, images);
|
||||
await this.agent.prompt(prompt);
|
||||
break; // success — exit loop
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
|
@ -443,8 +488,10 @@ export class Agent {
|
|||
private handleSessionEvent(event: AgentEvent) {
|
||||
if (event.type === "message_end") {
|
||||
const message = event.message as AgentMessage;
|
||||
this.session.saveMessage(message);
|
||||
if (message.role === "assistant") {
|
||||
this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined);
|
||||
// Skip compaction during internal runs — internal messages will be
|
||||
// rolled back from memory afterwards, so compacting now would be incorrect.
|
||||
if (message.role === "assistant" && !this._internalRun) {
|
||||
void this.maybeCompact();
|
||||
}
|
||||
}
|
||||
|
|
@ -511,6 +558,40 @@ export class Agent {
|
|||
return this.agent.state.tools?.map(t => t.name) ?? [];
|
||||
}
|
||||
|
||||
/** Whether the agent is currently executing an internal run */
|
||||
get isInternalRun(): boolean {
|
||||
return this._internalRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a synthetic assistant message into both in-memory state and session JSONL.
|
||||
* Used after an internal run to keep the LLM summary visible in future turns
|
||||
* while the internal prompt stays hidden.
|
||||
*/
|
||||
persistAssistantSummary(text: string): void {
|
||||
const model = this.agent.state.model;
|
||||
const message = {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text }],
|
||||
api: model?.api ?? "openai-completions",
|
||||
provider: model?.provider ?? "internal",
|
||||
model: model?.id ?? "unknown",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.agent.appendMessage(message);
|
||||
this.session.saveMessage(message);
|
||||
}
|
||||
|
||||
/** Ensure session messages are loaded from disk (idempotent) */
|
||||
async ensureInitialized(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
|
@ -522,11 +603,19 @@ export class Agent {
|
|||
this.initialized = true;
|
||||
}
|
||||
|
||||
/** Get all messages from the current session */
|
||||
/** Get all messages from the current session (in-memory state) */
|
||||
getMessages(): AgentMessage[] {
|
||||
return this.agent.state.messages.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from session storage with filtering.
|
||||
* By default, internal messages are excluded.
|
||||
*/
|
||||
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
|
||||
return this.session.loadMessages(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all skills with their eligibility status.
|
||||
* Returns empty array if skills are disabled.
|
||||
|
|
@ -596,6 +685,27 @@ export class Agent {
|
|||
return this.profile?.getProfile()?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile directory path, if profile is enabled.
|
||||
*/
|
||||
getProfileDir(): string | undefined {
|
||||
return this.profile?.getProfileDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heartbeat configuration from profile config.
|
||||
*/
|
||||
getHeartbeatConfig():
|
||||
| {
|
||||
enabled?: boolean | undefined;
|
||||
every?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
ackMaxChars?: number | undefined;
|
||||
}
|
||||
| undefined {
|
||||
return this.profile?.getHeartbeatConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent display name from profile config.
|
||||
*/
|
||||
|
|
@ -771,6 +881,7 @@ export class Agent {
|
|||
user: profile.user,
|
||||
workspace: profile.workspace,
|
||||
memory: profile.memory,
|
||||
heartbeat: profile.heartbeat,
|
||||
config: profile.config,
|
||||
},
|
||||
profileDir: this.profile!.getProfileDir(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue