Compare commits

..

1 Commits

Author SHA1 Message Date
tjb-tech 971c0e123d feat(memory): add auto-dream consolidation 2026-05-09 06:01:03 +00:00
103 changed files with 304 additions and 8009 deletions
-10
View File
@@ -6,16 +6,6 @@ The format is based on Keep a Changelog, and this project currently tracks chang
## [Unreleased]
### Added
- Hooks now support a `priority` field (default `0`). Within an event, hooks run highest-priority first, and hooks sharing a priority keep their registration order. This lets users order, for example, a security-check hook ahead of a logging hook regardless of where each is declared in settings or contributed by plugins.
- `edit_file` and `write_file` in the React TUI now preview a unified diff before applying file changes, let users approve once or for the rest of the session, and skip the extra prompt automatically in `full_auto` mode.
### Fixed
- Codex subscription requests now pass reasoning effort separately, enabling `gpt-5.5` with `xhigh` effort instead of treating `gpt-5.5 xhigh` as an unsupported model name.
- Telegram channel now delivers replies again under `ohmo init --no-interactive` and other configs that do not write a `reply_to_message` field. `TelegramConfig` declares `reply_to_message: bool = True` so the attribute access in `TelegramChannel.send` no longer raises `AttributeError` and outbound progress/tool-hint/final messages are sent as expected. See issue #243.
## [0.1.9] - 2026-05-07
### Added
-16
View File
@@ -567,22 +567,6 @@ Use `/skills` to list loaded skills with their source and path. User-invocable s
**Compatible with [anthropics/skills](https://github.com/anthropics/skills)** — use the `SKILL.md` directory layout above.
### 🌐 Web search and proxy settings
Built-in `web_search` uses DuckDuckGo HTML search by default. In regions where that endpoint is unreachable, point OpenHarness at a trusted public HTML search endpoint or your own SearXNG instance:
```bash
export OPENHARNESS_WEB_SEARCH_URL="https://your-searxng.example/search"
```
`web_search` and `web_fetch` keep `trust_env=False` for SSRF safety, so they do not automatically inherit `HTTP_PROXY` / `HTTPS_PROXY`. If you need a proxy, opt in with an OpenHarness-specific variable:
```bash
export OPENHARNESS_WEB_PROXY="http://127.0.0.1:7890"
```
The proxy URL must be HTTP/HTTPS and cannot contain embedded credentials.
### 🔌 Plugin System
**Compatible with [claude-code plugins](https://github.com/anthropics/claude-code/tree/main/plugins)**. Tested with 12 official plugins:
+7 -98
View File
@@ -1,7 +1,6 @@
import React, {useDeferredValue, useEffect, useMemo, useRef, useState} from 'react';
import React, {useDeferredValue, useEffect, useMemo, useState} from 'react';
import {Box, Text, useApp, useInput} from 'ink';
import {readClipboardImage, type ImageAttachment} from './clipboardImage.js';
import {CommandPicker} from './components/CommandPicker.js';
import {ConversationView} from './components/ConversationView.js';
import {ModalHost} from './components/ModalHost.js';
@@ -12,7 +11,7 @@ import {SwarmPanel} from './components/SwarmPanel.js';
import {TodoPanel} from './components/TodoPanel.js';
import {useBackendSession} from './hooks/useBackendSession.js';
import {ThemeProvider, useTheme} from './theme/ThemeContext.js';
import type {FrontendConfig, ImageAttachmentPayload} from './types.js';
import type {FrontendConfig} from './types.js';
const rawReturnSubmit = process.env.OPENHARNESS_FRONTEND_RAW_RETURN === '1';
const scriptedSteps = (() => {
@@ -65,8 +64,6 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
const [modalInput, setModalInput] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
const [clipboardStatus, setClipboardStatus] = useState<string | null>(null);
const [lastEscapeAt, setLastEscapeAt] = useState(0);
const [scriptIndex, setScriptIndex] = useState(0);
const [pickerIndex, setPickerIndex] = useState(0);
@@ -80,7 +77,6 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
const deferredTodoMarkdown = useDeferredValue(session.todoMarkdown);
const deferredSwarmTeammates = useDeferredValue(session.swarmTeammates);
const deferredSwarmNotifications = useDeferredValue(session.swarmNotifications);
const clipboardStatusTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const nextTheme = session.status.theme;
@@ -89,47 +85,6 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
}
}, [session.status.theme, setThemeName]);
useEffect(() => {
return () => {
if (clipboardStatusTimerRef.current) {
clearTimeout(clipboardStatusTimerRef.current);
}
};
}, []);
const setTemporaryClipboardStatus = (message: string): void => {
setClipboardStatus(message);
if (clipboardStatusTimerRef.current) {
clearTimeout(clipboardStatusTimerRef.current);
}
clipboardStatusTimerRef.current = setTimeout(() => {
setClipboardStatus(null);
clipboardStatusTimerRef.current = null;
}, 2500);
};
const attachClipboardImage = (): void => {
void (async () => {
const image = await readClipboardImage();
if (!image) {
setTemporaryClipboardStatus('No image found in clipboard');
return;
}
setImageAttachments((items) => [...items, image]);
setTemporaryClipboardStatus(`Attached ${image.label}`);
})().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
setTemporaryClipboardStatus(`Clipboard image unavailable: ${message}`);
});
};
const imagePayloads = (): ImageAttachmentPayload[] =>
imageAttachments.map((image) => ({
media_type: image.media_type,
data: image.data,
source_path: image.source_path,
}));
// Current tool name for spinner
const currentToolName = useMemo(() => {
for (let i = deferredTranscript.length - 1; i >= 0; i--) {
@@ -236,11 +191,6 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
return;
}
if (!session.busy && key.ctrl && chunk === 'v') {
attachClipboardImage();
return;
}
// Let ink-text-input handle pasted text directly.
if (isPaste) {
return;
@@ -320,41 +270,6 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
return;
}
// --- Edit diff modal (also appears while busy) ---
if (session.modal?.kind === 'edit_diff') {
if (chunk.toLowerCase() === 'y') {
session.sendRequest({
type: 'permission_response',
request_id: session.modal.request_id,
allowed: true,
permission_reply: 'once',
});
session.setModal(null);
return;
}
if (chunk.toLowerCase() === 'a') {
session.sendRequest({
type: 'permission_response',
request_id: session.modal.request_id,
allowed: true,
permission_reply: 'always',
});
session.setModal(null);
return;
}
if (chunk.toLowerCase() === 'n' || isEscape) {
session.sendRequest({
type: 'permission_response',
request_id: session.modal.request_id,
allowed: false,
permission_reply: 'reject',
});
session.setModal(null);
return;
}
return;
}
// --- Question modal (also appears while busy) ---
if (session.modal?.kind === 'question') {
return; // Let TextInput in ModalHost handle input
@@ -417,9 +332,8 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
if (isEscape) {
const now = Date.now();
if ((input || imageAttachments.length > 0) && now - lastEscapeAt < 500) {
if (input && now - lastEscapeAt < 500) {
setInput('');
setImageAttachments([]);
setHistoryIndex(-1);
setLastEscapeAt(0);
return;
@@ -459,7 +373,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
setModalInput('');
return;
}
if ((!value.trim() && imageAttachments.length === 0) || session.busy || !session.ready) {
if (!value.trim() || session.busy || !session.ready) {
if (session.busy && value.trim() === '/stop') {
session.sendRequest({type: 'interrupt'});
session.setBusyLabel('Stopping current operation...');
@@ -468,19 +382,16 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
return;
}
// Check if it's an interactive command
if (imageAttachments.length === 0 && handleCommand(value)) {
if (handleCommand(value)) {
setHistory((items) => [...items, value]);
setHistoryIndex(-1);
setInput('');
return;
}
session.sendRequest({type: 'submit_line', line: value, images: imagePayloads()});
if (value.trim()) {
setHistory((items) => [...items, value]);
}
session.sendRequest({type: 'submit_line', line: value});
setHistory((items) => [...items, value]);
setHistoryIndex(-1);
setInput('');
setImageAttachments([]);
session.setBusy(true);
};
@@ -565,8 +476,6 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
toolName={session.busy ? currentToolName : undefined}
statusLabel={session.busy ? (session.busyLabel ?? (currentToolName ? `Running ${currentToolName}...` : 'Running agent loop...')) : undefined}
suppressSubmit={showPicker}
imageAttachmentLabels={imageAttachments.map((image) => image.label)}
clipboardStatus={clipboardStatus}
/>
)}
-173
View File
@@ -1,173 +0,0 @@
import {execFile} from 'node:child_process';
import {mkdtemp, readFile, rm, stat} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
export type ImageAttachment = {
id: string;
label: string;
media_type: string;
data: string;
source_path?: string;
size_bytes?: number;
};
const MAX_CLIPBOARD_IMAGE_BYTES = 15 * 1024 * 1024;
const EXEC_TIMEOUT_MS = 2500;
type ClipboardImageRead = {
data: Buffer;
mediaType: string;
label: string;
};
export async function readClipboardImage(): Promise<ImageAttachment | null> {
const image = await readClipboardImageData();
if (!image) {
return null;
}
return {
id: `clipboard-${Date.now()}-${Math.random().toString(16).slice(2)}`,
label: image.label,
media_type: image.mediaType,
data: image.data.toString('base64'),
source_path: `clipboard:${image.label}`,
size_bytes: image.data.length,
};
}
async function readClipboardImageData(): Promise<ClipboardImageRead | null> {
if (process.platform === 'darwin') {
return readMacClipboardImage();
}
if (process.platform === 'win32') {
return readWindowsClipboardImage();
}
return readLinuxClipboardImage();
}
async function readMacClipboardImage(): Promise<ClipboardImageRead | null> {
const tempDir = await mkdtemp(join(tmpdir(), 'openharness-clipboard-'));
try {
const pngPath = join(tempDir, 'clipboard.png');
if (await runFileCommand('pngpaste', [pngPath])) {
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
}
if (await writeMacClipboardClass('PNGf', pngPath)) {
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
}
const tiffPath = join(tempDir, 'clipboard.tiff');
if (await writeMacClipboardClass('TIFF', tiffPath)) {
if (await runFileCommand('sips', ['-s', 'format', 'png', tiffPath, '--out', pngPath])) {
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
}
return await readImageFile(tiffPath, 'image/tiff', 'clipboard.tiff');
}
return null;
} finally {
await rm(tempDir, {recursive: true, force: true});
}
}
async function writeMacClipboardClass(classCode: 'PNGf' | 'TIFF', outputPath: string): Promise<boolean> {
const appleClass = `${String.fromCharCode(0xab)}class ${classCode}${String.fromCharCode(0xbb)}`;
const escapedPath = outputPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return runFileCommand('osascript', [
'-e',
`set clipboardData to the clipboard as ${appleClass}`,
'-e',
`set outFile to open for access POSIX file "${escapedPath}" with write permission`,
'-e',
'set eof outFile to 0',
'-e',
'write clipboardData to outFile',
'-e',
'close access outFile',
]);
}
async function readWindowsClipboardImage(): Promise<ClipboardImageRead | null> {
const tempDir = await mkdtemp(join(tmpdir(), 'openharness-clipboard-'));
try {
const pngPath = join(tempDir, 'clipboard.png');
const escapedPath = pngPath.replace(/'/g, "''");
const powershell = join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe',
);
const script = [
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
'if (-not [Windows.Forms.Clipboard]::ContainsImage()) { exit 2 }',
'$image = [Windows.Forms.Clipboard]::GetImage()',
`$image.Save('${escapedPath}', [Drawing.Imaging.ImageFormat]::Png)`,
].join('; ');
if (!(await runFileCommand(powershell, ['-NoProfile', '-STA', '-Command', script]))) {
return null;
}
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
} finally {
await rm(tempDir, {recursive: true, force: true});
}
}
async function readLinuxClipboardImage(): Promise<ClipboardImageRead | null> {
const attempts: Array<[string, string[], string, string]> = [
['wl-paste', ['--no-newline', '--type', 'image/png'], 'image/png', 'clipboard.png'],
['wl-paste', ['--no-newline', '--type', 'image/jpeg'], 'image/jpeg', 'clipboard.jpg'],
['xclip', ['-selection', 'clipboard', '-target', 'image/png', '-out'], 'image/png', 'clipboard.png'],
['xclip', ['-selection', 'clipboard', '-target', 'image/jpeg', '-out'], 'image/jpeg', 'clipboard.jpg'],
['xsel', ['--clipboard', '--output', '--mime-type', 'image/png'], 'image/png', 'clipboard.png'],
['xsel', ['--clipboard', '--output', '--mime-type', 'image/jpeg'], 'image/jpeg', 'clipboard.jpg'],
];
for (const [command, args, mediaType, label] of attempts) {
const data = await runBufferCommand(command, args);
if (data && data.length > 0) {
return {data, mediaType, label};
}
}
return null;
}
async function readImageFile(path: string, mediaType: string, label: string): Promise<ClipboardImageRead | null> {
const fileStat = await stat(path).catch(() => null);
if (!fileStat || fileStat.size <= 0 || fileStat.size > MAX_CLIPBOARD_IMAGE_BYTES) {
return null;
}
const data = await readFile(path);
return {data, mediaType, label};
}
async function runFileCommand(command: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
execFile(command, args, {timeout: EXEC_TIMEOUT_MS, windowsHide: true}, (error) => {
resolve(!error);
});
});
}
async function runBufferCommand(command: string, args: string[]): Promise<Buffer | null> {
return new Promise((resolve) => {
execFile(
command,
args,
{
encoding: 'buffer',
maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES + 1024,
timeout: EXEC_TIMEOUT_MS,
windowsHide: true,
},
(error, stdout) => {
if (error || !Buffer.isBuffer(stdout) || stdout.length > MAX_CLIPBOARD_IMAGE_BYTES) {
resolve(null);
return;
}
resolve(stdout);
},
);
});
}
@@ -1,87 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {PassThrough} from 'node:stream';
import React from 'react';
import {render} from 'ink';
import {ModalHost} from './ModalHost.js';
const stripAnsi = (value: string): string => value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
type InkTestStdout = PassThrough & {
isTTY: boolean;
columns: number;
rows: number;
cursorTo: () => boolean;
clearLine: () => boolean;
moveCursor: () => boolean;
};
function createTestStdout(): InkTestStdout {
return Object.assign(new PassThrough(), {
isTTY: true,
columns: 120,
rows: 40,
cursorTo: () => true,
clearLine: () => true,
moveCursor: () => true,
});
}
async function waitForOutputToStabilize(getOutput: () => string): Promise<string> {
let previous = '';
let sawOutput = false;
for (let i = 0; i < 50; i += 1) {
await nextLoopTurn();
const current = getOutput();
sawOutput ||= current.length > 0;
if (sawOutput && current === previous) {
return current;
}
previous = current;
}
throw new Error(`Ink output did not stabilize: ${JSON.stringify(previous)}`);
}
test('renders edit diff preview with stats and always shortcut', async () => {
const stdout = createTestStdout();
let output = '';
stdout.on('data', (chunk) => {
output += chunk.toString();
});
const instance = render(
<ModalHost
modal={{
kind: 'edit_diff',
path: 'src/demo.txt',
diff: '@@ -1 +1 @@\n-old line\n+new line',
added: 1,
removed: 1,
}}
modalInput=""
setModalInput={() => undefined}
onSubmit={() => undefined}
/>,
{stdout: stdout as unknown as NodeJS.WriteStream, debug: true, patchConsole: false},
);
const exitPromise = instance.waitUntilExit();
const stableOutput = await waitForOutputToStabilize(() => output);
instance.unmount();
await exitPromise;
instance.cleanup();
stdout.destroy();
const rendered = stripAnsi(stableOutput);
assert.match(rendered, /Edit src\/demo\.txt/);
assert.match(rendered, /\+1/);
assert.match(rendered, /-1/);
assert.match(rendered, /\+new line/);
assert.match(rendered, /-old line/);
assert.match(rendered, /\[a\] Always/);
});
@@ -8,7 +8,6 @@ const WAIT_FRAMES = [
'Agent is waiting for your input.. ',
'Agent is waiting for your input...',
];
const MAX_DIFF_LINES = 40;
function WaitingAnimation(): React.JSX.Element {
const [frame, setFrame] = useState(0);
@@ -86,104 +85,6 @@ function QuestionModal({
);
}
type DiffLineKind = 'add' | 'del' | 'hunk' | 'context';
type ParsedDiffLine = {
kind: DiffLineKind;
content: string;
};
function parseDiffLines(diffText: string): ParsedDiffLine[] {
return diffText
.split('\n')
.flatMap((raw): ParsedDiffLine[] => {
if (!raw || raw.startsWith('+++') || raw.startsWith('---')) {
return [];
}
if (raw.startsWith('@@')) {
return [{kind: 'hunk', content: raw}];
}
if (raw.startsWith('+')) {
return [{kind: 'add', content: raw.slice(1)}];
}
if (raw.startsWith('-')) {
return [{kind: 'del', content: raw.slice(1)}];
}
return [{kind: 'context', content: raw.startsWith(' ') ? raw.slice(1) : raw}];
});
}
function EditDiffModal({modal}: {modal: Record<string, unknown>}): React.JSX.Element {
const path = String(modal.path ?? '');
const added = Number(modal.added ?? 0);
const removed = Number(modal.removed ?? 0);
const lines = parseDiffLines(String(modal.diff ?? ''));
const visibleLines = lines.slice(0, MAX_DIFF_LINES);
const hiddenCount = lines.length - visibleLines.length;
return (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color="yellow" bold>{'\u250C '}</Text>
<Text bold>Edit </Text>
<Text color="cyan" bold>{path}</Text>
<Text>{' '}</Text>
<Text color="green">{`+${added}`}</Text>
<Text>{' '}</Text>
<Text color="red">{`-${removed}`}</Text>
</Text>
{visibleLines.map((line, index) => {
if (line.kind === 'hunk') {
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text color="cyan" dimColor>{line.content}</Text>
</Text>
);
}
if (line.kind === 'add') {
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text color="green">+</Text>
<Text color="green">{line.content}</Text>
</Text>
);
}
if (line.kind === 'del') {
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text color="red">-</Text>
<Text color="red">{line.content}</Text>
</Text>
);
}
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text dimColor>{' '}{line.content}</Text>
</Text>
);
})}
{hiddenCount > 0 ? (
<Text>
<Text color="yellow">{'\u2502 '}</Text>
<Text dimColor>... {hiddenCount} more lines hidden</Text>
</Text>
) : null}
<Text>
<Text color="yellow">{'\u2514 '}</Text>
<Text color="green">[y] Once</Text>
<Text>{' '}</Text>
<Text color="green">[a] Always</Text>
<Text>{' '}</Text>
<Text color="red">[n] Deny</Text>
</Text>
</Box>
);
}
function ModalHostInner({
modal,
modalInput,
@@ -219,9 +120,6 @@ function ModalHostInner({
</Box>
);
}
if (modal?.kind === 'edit_diff') {
return <EditDiffModal modal={modal} />;
}
if (modal?.kind === 'question') {
return (
<QuestionModal
@@ -173,39 +173,6 @@ test('keeps forward delete behavior when cursor is inside the line', async () =>
}
});
test('ignores ctrl+v so the app can attach clipboard images', async () => {
const stdin = createTestStdin();
const stdout = createTestStdout();
let currentValue = '';
const instance = render(<PromptHarness onInputChange={(value) => {
currentValue = value;
}} />, {
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
stdout: stdout as unknown as NodeJS.WriteStream,
debug: true,
patchConsole: false,
});
const exitPromise = instance.waitUntilExit();
try {
await nextLoopTurn();
await sendKey(stdin, Buffer.from([0x16]));
await nextLoopTurn();
assert.equal(currentValue, '');
await sendKey(stdin, 'v');
await waitForValue(() => currentValue, 'v');
} finally {
instance.unmount();
await exitPromise;
instance.cleanup();
stdin.destroy();
stdout.destroy();
}
});
test('accepts explicit /stop submission while busy', async () => {
const stdin = createTestStdin();
const stdout = createTestStdout();
@@ -78,14 +78,7 @@ function MultilineTextInput({
return;
}
if (
key.upArrow ||
key.downArrow ||
key.tab ||
(key.shift && key.tab) ||
key.escape ||
(key.ctrl && (input === 'c' || input === 'v'))
) {
if (key.upArrow || key.downArrow || key.tab || (key.shift && key.tab) || key.escape || (key.ctrl && input === 'c')) {
return;
}
@@ -204,8 +197,6 @@ export function PromptInput({
toolName,
suppressSubmit,
statusLabel,
imageAttachmentLabels = [],
clipboardStatus,
}: {
busy: boolean;
input: string;
@@ -214,8 +205,6 @@ export function PromptInput({
toolName?: string;
suppressSubmit?: boolean;
statusLabel?: string;
imageAttachmentLabels?: string[];
clipboardStatus?: string | null;
}): React.JSX.Element {
const {theme} = useTheme();
const promptPrefix = busy ? '… ' : '> ';
@@ -229,18 +218,6 @@ export function PromptInput({
</Box>
</Box>
) : null}
{imageAttachmentLabels.length > 0 ? (
<Box>
<Text color={theme.colors.accent}>
{imageAttachmentLabels.map((label, index) => `[image ${index + 1}: ${label}]`).join(' ')}
</Text>
</Box>
) : null}
{clipboardStatus ? (
<Box>
<Text color={theme.colors.muted}>{clipboardStatus}</Text>
</Box>
) : null}
<MultilineTextInput
value={input}
onChange={setInput}
-6
View File
@@ -11,12 +11,6 @@ export type TranscriptItem = {
is_error?: boolean;
};
export type ImageAttachmentPayload = {
media_type: string;
data: string;
source_path?: string;
};
export type TaskSnapshot = {
id: string;
type: string;
+2 -37
View File
@@ -25,7 +25,6 @@ from ohmo.runtime import launch_ohmo_react_tui, run_ohmo_backend, run_ohmo_print
from ohmo.session_storage import OhmoSessionBackend
from ohmo.workspace import (
get_gateway_config_path,
get_logs_dir,
get_workspace_root,
get_soul_path,
get_state_path,
@@ -270,14 +269,6 @@ def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict
default_value=str(prior.get("group_policy", "mention")),
)
elif channel == "feishu":
config["domain"] = _select_from_menu(
"Feishu domain:",
[
("https://open.feishu.cn", "Feishu (China)"),
("https://open.larksuite.com", "Lark (International)"),
],
default_value=str(prior.get("domain", "https://open.feishu.cn")),
)
config["app_id"] = _text_prompt(
"Feishu app id",
default=str(prior.get("app_id", "")),
@@ -408,42 +399,18 @@ def _maybe_restart_gateway(*, cwd: str | Path, workspace: str | Path) -> None:
print(f"ohmo gateway restarted (pid={pid})")
def _configure_gateway_logging(
workspace: str | Path | None = None,
*,
console: bool = True,
log_file: bool = True,
) -> None:
def _configure_gateway_logging(workspace: str | Path | None = None) -> None:
"""Configure foreground gateway logging."""
config = load_gateway_config(workspace)
level_name = str(config.log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
handlers = _build_gateway_logging_handlers(workspace, console=console, log_file=log_file)
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
handlers=handlers,
force=True,
)
def _build_gateway_logging_handlers(
workspace: str | Path | None = None,
*,
console: bool,
log_file: bool,
) -> list[logging.Handler]:
"""Build gateway log handlers for foreground and daemon modes."""
handlers: list[logging.Handler] = []
if console:
handlers.append(logging.StreamHandler())
if log_file:
log_path = get_logs_dir(workspace) / "gateway.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
handlers.append(logging.FileHandler(log_path, encoding="utf-8", delay=True))
return handlers
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
@@ -662,11 +629,9 @@ def user_edit_cmd(
def gateway_run_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
console_log: bool = typer.Option(True, "--console-log/--no-console-log", hidden=True),
log_file: bool = typer.Option(True, "--log-file/--no-log-file", hidden=True),
) -> None:
"""Run the ohmo gateway in the foreground."""
_configure_gateway_logging(workspace, console=console_log, log_file=log_file)
_configure_gateway_logging(workspace)
service = OhmoGatewayService(cwd, workspace)
raise SystemExit(asyncio.run(service.run_foreground()))
+1 -7
View File
@@ -257,13 +257,9 @@ class OhmoGatewayBridge:
inbound_meta["message_id"] = message.metadata["message_id"]
try:
reply = ""
final_media: list[str] = []
final_metadata: dict[str, object] = {}
async for update in self._runtime_pool.stream_message(message, session_key):
if update.kind == "final":
reply = update.text
final_media = list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or [])
final_metadata = dict(update.metadata or {})
continue
if not update.text:
continue
@@ -280,7 +276,6 @@ class OhmoGatewayBridge:
channel=message.channel,
chat_id=message.chat_id,
content=update.text,
media=list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or []),
metadata={**inbound_meta, **(update.metadata or {})},
)
)
@@ -323,8 +318,7 @@ class OhmoGatewayBridge:
channel=message.channel,
chat_id=message.chat_id,
content=reply,
media=final_media,
metadata={**inbound_meta, **final_metadata, "_session_key": session_key},
metadata={**inbound_meta, "_session_key": session_key},
)
)
-78
View File
@@ -1,78 +0,0 @@
"""Proactive notification helpers for ohmo gateway channels."""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from ohmo.gateway.config import load_gateway_config
logger = logging.getLogger(__name__)
class OhmoNotificationError(RuntimeError):
"""Raised when a proactive notification cannot be delivered."""
def _chunk_text(text: str, *, max_chars: int = 1800) -> list[str]:
"""Split text into message-sized chunks without losing content."""
stripped = text.strip()
if not stripped:
return []
chunks: list[str] = []
remaining = stripped
while len(remaining) > max_chars:
split_at = remaining.rfind("\n", 0, max_chars)
if split_at < max_chars // 2:
split_at = max_chars
chunks.append(remaining[:split_at].strip())
remaining = remaining[split_at:].strip()
if remaining:
chunks.append(remaining)
return chunks
def _send_feishu_text_sync(*, user_open_id: str, content: str, workspace: str | Path | None = None) -> None:
"""Send a Feishu direct message using ohmo gateway Feishu credentials."""
try:
import lark_oapi as lark
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
except ImportError as exc: # pragma: no cover - depends on optional extra
raise OhmoNotificationError("Feishu SDK is not installed. Run: pip install lark-oapi") from exc
config = load_gateway_config(workspace)
feishu_config: dict[str, Any] = config.channel_configs.get("feishu", {})
app_id = str(feishu_config.get("app_id") or "").strip()
app_secret = str(feishu_config.get("app_secret") or "").strip()
if not app_id or not app_secret:
raise OhmoNotificationError("Feishu app_id/app_secret are not configured in ohmo gateway config.")
client = lark.Client.builder().app_id(app_id).app_secret(app_secret).log_level(lark.LogLevel.INFO).build()
for chunk in _chunk_text(content):
request = (
CreateMessageRequest.builder()
.receive_id_type("open_id")
.request_body(
CreateMessageRequestBody.builder()
.receive_id(user_open_id)
.msg_type("text")
.content(json.dumps({"text": chunk}, ensure_ascii=False))
.build()
)
.build()
)
response = client.im.v1.message.create(request)
if not response.success():
log_id = response.get_log_id() if hasattr(response, "get_log_id") else ""
raise OhmoNotificationError(
f"send Feishu DM failed: code={response.code}, msg={response.msg}, log_id={log_id}"
)
async def send_feishu_dm(*, user_open_id: str, content: str, workspace: str | Path | None = None) -> None:
"""Send a proactive Feishu direct message to a user open_id."""
await asyncio.to_thread(_send_feishu_text_sync, user_open_id=user_open_id, content=content, workspace=workspace)
logger.info("Sent proactive Feishu DM to open_id=%s", user_open_id)
+14 -115
View File
@@ -9,7 +9,6 @@ import mimetypes
from pathlib import Path
import json
import os
import re
import string
from openharness.channels.bus.events import InboundMessage
@@ -63,10 +62,6 @@ _CHANNEL_THINKING_PHRASES_EN = (
_TEXT_PREVIEW_BYTES = 4096
_TEXT_PREVIEW_CHARS = 900
_BINARY_HEAD_BYTES = 32
_FINAL_REPLY_IMAGE_PATH_RE = re.compile(
r"(?P<path>(?:[A-Za-z]:[\\/]|/)[^\r\n`\"'<>|?*\x00]+?\.(?:png|jpe?g|webp|gif|bmp))",
re.IGNORECASE,
)
_IMAGE_FALLBACK_NOTE = (
"[Image attachment omitted because the active model does not support image input. "
"Use the attachment paths and summaries above if needed.]"
@@ -91,7 +86,6 @@ class GatewayStreamUpdate:
kind: str
text: str
metadata: dict[str, object]
media: list[str] | None = None
class OhmoSessionRuntimePool:
@@ -265,6 +259,19 @@ class OhmoSessionRuntimePool:
if parsed is not None and not message.media:
command, args = parsed
command_name = str(getattr(command, "name", "") or "")
gateway_result = self._handle_gateway_scoped_command(command_name, args)
if gateway_result is not None:
message_text, refresh_runtime = gateway_result
result = CommandResult(message=message_text, refresh_runtime=refresh_runtime)
async for update in self._stream_command_result(
bundle=bundle,
message=message,
session_key=session_key,
user_prompt=user_prompt,
result=result,
):
yield update
return
remote_allowed = getattr(command, "remote_invocable", True)
if not remote_allowed and self._remote_admin_allowed(command):
remote_allowed = True
@@ -288,19 +295,6 @@ class OhmoSessionRuntimePool:
):
yield update
return
gateway_result = self._handle_gateway_scoped_command(command_name, args)
if gateway_result is not None:
message_text, refresh_runtime = gateway_result
result = CommandResult(message=message_text, refresh_runtime=refresh_runtime)
async for update in self._stream_command_result(
bundle=bundle,
message=message,
session_key=session_key,
user_prompt=user_prompt,
result=result,
):
yield update
return
result = await command.handler(
args,
get_command_context(),
@@ -410,7 +404,6 @@ class OhmoSessionRuntimePool:
):
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, user_prompt))
reply_parts: list[str] = []
emitted_media: set[str] = set()
yield GatewayStreamUpdate(
kind="progress",
text=_format_channel_progress(
@@ -456,7 +449,6 @@ class OhmoSessionRuntimePool:
content=user_prompt,
reply_parts=reply_parts,
):
_remember_update_media(emitted_media, update)
yield update
break
async for update in self._convert_stream_event(
@@ -467,7 +459,6 @@ class OhmoSessionRuntimePool:
content=user_prompt,
reply_parts=reply_parts,
):
_remember_update_media(emitted_media, update)
yield update
except MaxTurnsExceeded as exc:
yield GatewayStreamUpdate(
@@ -491,15 +482,10 @@ class OhmoSessionRuntimePool:
bundle.session_id,
_content_snippet(reply),
)
final_media = _extract_final_reply_media(reply, emitted_media)
metadata: dict[str, object] = {"_session_key": session_key}
if final_media:
metadata.update({"_media": final_media, "_final_media_fallback": True})
yield GatewayStreamUpdate(
kind="final",
text=reply,
metadata=metadata,
media=final_media or None,
metadata={"_session_key": session_key},
)
async def _convert_stream_event(
@@ -595,14 +581,6 @@ class OhmoSessionRuntimePool:
bundle.session_id,
event.tool_name,
)
media = _extract_tool_media(event)
if media:
yield GatewayStreamUpdate(
kind="media",
text=_format_tool_media_caption(event, media),
metadata={"_session_key": session_key, "_media": media, "_tool_media": True},
media=media,
)
return
if isinstance(event, ErrorEvent):
logger.error(
@@ -807,85 +785,6 @@ def _sanitize_snapshot_messages(raw_messages: object) -> list[dict[str, object]]
return [message.model_dump(mode="json") for message in _sanitize_group_command_prompts(messages)]
def _extract_tool_media(event: ToolExecutionCompleted) -> list[str]:
"""Return local media paths produced by a tool completion event."""
if event.is_error or not isinstance(event.metadata, dict):
return []
raw_paths = event.metadata.get("paths") or event.metadata.get("media")
if isinstance(raw_paths, str):
candidates = [raw_paths]
elif isinstance(raw_paths, list):
candidates = [str(item) for item in raw_paths if isinstance(item, str) and item.strip()]
else:
candidates = []
media: list[str] = []
seen: set[str] = set()
for raw in candidates:
path = Path(raw).expanduser()
if not path.is_absolute():
path = path.resolve()
if not path.is_file():
continue
resolved = str(path)
if resolved not in seen:
seen.add(resolved)
media.append(resolved)
return media
def _remember_update_media(seen: set[str], update: GatewayStreamUpdate) -> None:
"""Track media already emitted during this gateway turn."""
raw_media = update.media or (update.metadata or {}).get("_media") or []
if isinstance(raw_media, str):
candidates = [raw_media]
elif isinstance(raw_media, list):
candidates = [str(item) for item in raw_media if isinstance(item, str) and item.strip()]
else:
candidates = []
for raw in candidates:
try:
path = Path(raw).expanduser()
if not path.is_absolute():
path = path.resolve()
seen.add(str(path))
except Exception:
continue
def _extract_final_reply_media(reply: str, emitted_media: set[str]) -> list[str]:
"""Return local image paths mentioned in final text that were not already emitted."""
media: list[str] = []
seen = set(emitted_media)
for match in _FINAL_REPLY_IMAGE_PATH_RE.finditer(reply or ""):
raw = match.group("path").strip(" \t\r\n\"'.,;:,。;:、)]}")
if not raw:
continue
path = Path(raw).expanduser()
if not path.is_absolute():
continue
if not path.is_file():
continue
resolved = str(path)
if resolved in seen:
continue
seen.add(resolved)
media.append(resolved)
return media
def _format_tool_media_caption(event: ToolExecutionCompleted, media: list[str]) -> str:
"""Return a short caption for media generated by tools."""
if event.tool_name == "image_generation":
provider = ""
if isinstance(event.metadata, dict):
provider = str(event.metadata.get("provider") or "").strip()
suffix = f" via {provider}" if provider else ""
names = ", ".join(Path(path).name for path in media)
return f"已生成图片{suffix}{names}"
names = ", ".join(Path(path).name for path in media)
return f"已生成文件:{names}"
def _sanitize_group_command_prompts(messages: list[ConversationMessage]) -> list[ConversationMessage]:
"""Replace internal /group tool-driving prompts with durable user-facing history."""
return [_sanitize_group_command_prompt(message) for message in messages]
+1 -23
View File
@@ -84,13 +84,6 @@ class OhmoGatewayService:
def state_file(self) -> Path:
return get_state_path(self._workspace)
def _channel_last_error(self) -> str | None:
for name, channel in self._manager.channels.items():
error = getattr(channel, "last_error", None)
if error:
return f"{name}: {error}"
return None
def write_state(self, *, running: bool, last_error: str | None = None) -> None:
state = GatewayState(
running=running,
@@ -98,7 +91,7 @@ class OhmoGatewayService:
active_sessions=self._runtime_pool.active_sessions,
provider_profile=self._config.provider_profile,
enabled_channels=self._config.enabled_channels,
last_error=last_error or self._channel_last_error(),
last_error=last_error,
)
self.state_file.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
@@ -216,18 +209,8 @@ class OhmoGatewayService:
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, _stop)
async def _state_heartbeat() -> None:
while not stop_event.is_set():
self.write_state(running=True)
await asyncio.sleep(5.0)
state_task = asyncio.create_task(_state_heartbeat(), name="ohmo-gateway-state")
try:
await stop_event.wait()
except Exception as exc:
self.write_state(running=False, last_error=str(exc))
raise
finally:
self._bridge.stop()
bridge_task.cancel()
@@ -236,10 +219,6 @@ class OhmoGatewayService:
await bridge_task
with contextlib.suppress(asyncio.CancelledError):
await manager_task
if not state_task.done():
state_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await state_task
if not restart_notice_task.done():
restart_notice_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
@@ -290,7 +269,6 @@ def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path |
service._cwd,
"--workspace",
str(get_workspace_root(workspace)),
"--no-console-log",
],
**popen_kwargs,
)
+16 -151
View File
@@ -6,21 +6,6 @@ from pathlib import Path
from re import sub
from openharness.commands import MemoryCommandBackend
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import (
SCHEMA_VERSION,
coerce_int,
compute_memory_signature,
first_content_line,
format_datetime,
generate_memory_id,
memory_metadata_from_path,
render_memory_file,
split_memory_file,
utc_now,
)
from openharness.utils.file_lock import exclusive_file_lock
from openharness.utils.fs import atomic_write_text
from ohmo.workspace import get_memory_dir, get_memory_index_path
@@ -28,14 +13,7 @@ from ohmo.workspace import get_memory_dir, get_memory_index_path
def list_memory_files(workspace: str | Path | None = None) -> list[Path]:
"""List ``.ohmo`` memory markdown files."""
memory_dir = get_memory_dir(workspace)
return sorted(
header.path
for header in scan_memory_files(
_scan_cwd(workspace, memory_dir),
max_files=None,
memory_dir=memory_dir,
)
)
return sorted(path for path in memory_dir.glob("*.md") if path.name != "MEMORY.md")
def add_memory_entry(workspace: str | Path | None, title: str, content: str) -> Path:
@@ -43,113 +21,30 @@ def add_memory_entry(workspace: str | Path | None, title: str, content: str) ->
memory_dir = get_memory_dir(workspace)
memory_dir.mkdir(parents=True, exist_ok=True)
slug = sub(r"[^a-zA-Z0-9]+", "_", title.strip().lower()).strip("_") or "memory"
with exclusive_file_lock(memory_dir / ".memory.lock"):
memory_type = "personal"
category = "preference"
body = content.strip() + "\n"
signature = compute_memory_signature(body, memory_type, category)
existing = scan_memory_files(
_scan_cwd(workspace, memory_dir),
max_files=None,
include_disabled=True,
include_expired=True,
memory_dir=memory_dir,
)
duplicate = next(
(header for header in existing if _effective_signature(header.path, header.signature) == signature),
None,
)
path = duplicate.path if duplicate is not None else _next_memory_path(memory_dir, slug)
now = utc_now()
now_text = format_datetime(now)
if path.exists():
metadata, old_body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
metadata = memory_metadata_from_path(
path,
metadata,
old_body,
now=now,
source=str(metadata.get("source") or "manual"),
default_type=memory_type,
default_category=category,
)
created_at = str(metadata.get("created_at") or now_text)
memory_id = str(metadata.get("id") or generate_memory_id(now))
else:
metadata = {}
created_at = now_text
memory_id = generate_memory_id(now)
metadata.update(
{
"schema_version": SCHEMA_VERSION,
"id": memory_id,
"name": title.strip(),
"description": first_content_line(body) or title.strip(),
"type": str(metadata.get("type") or memory_type),
"category": str(metadata.get("category") or category),
"importance": max(coerce_int(metadata.get("importance"), default=0), 1),
"source": "manual",
"signature": signature,
"created_at": created_at,
"updated_at": now_text,
"ttl_days": metadata.get("ttl_days"),
"disabled": False,
"supersedes": metadata.get("supersedes") or [],
}
)
atomic_write_text(path, render_memory_file(metadata, body))
path = memory_dir / f"{slug}.md"
path.write_text(content.strip() + "\n", encoding="utf-8")
index_path = get_memory_index_path(workspace)
existing_index = index_path.read_text(encoding="utf-8") if index_path.exists() else "# Memory Index\n"
if path.name not in existing_index:
existing_index = existing_index.rstrip() + f"\n- [{title}]({path.name})\n"
atomic_write_text(index_path, existing_index)
index_path = get_memory_index_path(workspace)
existing = index_path.read_text(encoding="utf-8") if index_path.exists() else "# Memory Index\n"
if path.name not in existing:
existing = existing.rstrip() + f"\n- [{title}]({path.name})\n"
index_path.write_text(existing, encoding="utf-8")
return path
def remove_memory_entry(workspace: str | Path | None, name: str) -> bool:
"""Soft-delete a memory file and remove its index entry."""
"""Delete a memory file and remove its index entry."""
memory_dir = get_memory_dir(workspace)
matches = [
header
for header in scan_memory_files(
_scan_cwd(workspace, memory_dir),
max_files=None,
include_disabled=True,
include_expired=True,
memory_dir=memory_dir,
)
if name in {header.path.stem, header.path.name, header.title, header.id}
]
matches = [path for path in memory_dir.glob("*.md") if path.stem == name or path.name == name]
if not matches:
return False
header = matches[0]
if header.disabled:
return False
path = header.path
with exclusive_file_lock(memory_dir / ".memory.lock"):
content = path.read_text(encoding="utf-8")
metadata, body, _, _ = split_memory_file(content)
metadata = memory_metadata_from_path(
path,
metadata,
body,
source="manual",
default_type="personal",
default_category="preference",
)
metadata["disabled"] = True
metadata["updated_at"] = format_datetime(utc_now())
atomic_write_text(path, render_memory_file(metadata, body))
path = matches[0]
path.unlink(missing_ok=True)
index_path = get_memory_index_path(workspace)
if index_path.exists():
lines = [
line
for line in index_path.read_text(encoding="utf-8").splitlines()
if path.name not in line
]
atomic_write_text(index_path, "\n".join(lines).rstrip() + "\n")
index_path = get_memory_index_path(workspace)
if index_path.exists():
lines = [line for line in index_path.read_text(encoding="utf-8").splitlines() if path.name not in line]
index_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
return True
@@ -181,39 +76,9 @@ def create_memory_command_backend(workspace: str | Path | None = None) -> Memory
return MemoryCommandBackend(
label="ohmo personal memory",
default_type="personal",
default_category="preference",
get_memory_dir=lambda: get_memory_dir(workspace),
get_entrypoint=lambda: get_memory_index_path(workspace),
list_files=lambda: list_memory_files(workspace),
add_entry=lambda title, content: add_memory_entry(workspace, title, content),
remove_entry=lambda name: remove_memory_entry(workspace, name),
)
def _scan_cwd(workspace: str | Path | None, memory_dir: Path) -> Path:
return Path(workspace) if workspace is not None else memory_dir.parent
def _next_memory_path(memory_dir: Path, slug: str) -> Path:
path = memory_dir / f"{slug}.md"
if not path.exists():
return path
index = 2
while True:
candidate = memory_dir / f"{slug}_{index}.md"
if not candidate.exists():
return candidate
index += 1
def _effective_signature(path: Path, existing_signature: str) -> str:
if existing_signature:
return existing_signature
try:
metadata, body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
except OSError:
return ""
memory_type = str(metadata.get("type") or "personal")
category = str(metadata.get("category") or "preference")
return compute_memory_signature(body, memory_type, category)
+1 -5
View File
@@ -183,10 +183,7 @@ async def run_ohmo_print_mode(
async def _print_system(message: str) -> None:
print(message, file=sys.stderr)
saw_error = False
async def _render_event(event) -> None:
nonlocal saw_error
if isinstance(event, AssistantTextDelta):
sys.stdout.write(event.text)
sys.stdout.flush()
@@ -194,7 +191,6 @@ async def run_ohmo_print_mode(
sys.stdout.write("\n")
sys.stdout.flush()
elif isinstance(event, ErrorEvent):
saw_error = True
print(event.message, file=sys.stderr)
elif isinstance(event, CompactProgressEvent):
if event.message:
@@ -213,6 +209,6 @@ async def run_ohmo_print_mode(
clear_output=_clear_output,
)
await close_runtime(bundle)
return 1 if saw_error else 0
return 0
finally:
os.chdir(previous_cwd)
-14
View File
@@ -1,14 +0,0 @@
"""Run the OpenHarness memory schema migration from a source checkout."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from openharness.memory.migrate import main
if __name__ == "__main__":
raise SystemExit(main())
-5
View File
@@ -45,7 +45,6 @@ class ApiMessageRequest:
system_prompt: str | None = None
max_tokens: int = 4096
tools: list[dict[str, Any]] = field(default_factory=list)
effort: str | None = None
@dataclass(frozen=True)
@@ -150,10 +149,6 @@ class AnthropicApiClient:
kwargs["base_url"] = self._base_url
return AsyncAnthropic(**kwargs)
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._client.close()
def _refresh_client_auth(self) -> None:
if not self._claude_oauth or self._auth_token_resolver is None:
return
+7 -23
View File
@@ -78,17 +78,6 @@ def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict
result: list[dict[str, Any]] = []
for msg in messages:
if msg.role == "user":
# Responses API requires function_call_output items to appear before
# any following user input. A ConversationMessage can contain both
# tool results and user text, so emit the tool outputs first to keep
# every prior function_call immediately satisfied.
for block in msg.content:
if isinstance(block, ToolResultBlock):
result.append({
"type": "function_call_output",
"call_id": block.tool_use_id,
"output": block.content,
})
user_content: list[dict[str, Any]] = []
for block in msg.content:
if isinstance(block, TextBlock) and block.text.strip():
@@ -100,6 +89,13 @@ def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict
})
if user_content:
result.append({"role": "user", "content": user_content})
for block in msg.content:
if isinstance(block, ToolResultBlock):
result.append({
"type": "function_call_output",
"call_id": block.tool_use_id,
"output": block.content,
})
continue
assistant_text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
@@ -133,15 +129,6 @@ def _convert_tools_to_codex(tools: list[dict[str, Any]]) -> list[dict[str, Any]]
]
def _normalize_reasoning_effort(effort: str | None) -> str | None:
normalized = (effort or "").strip().lower()
if normalized == "max":
return "xhigh"
if normalized in {"low", "medium", "high", "xhigh"}:
return normalized
return None
def _usage_from_response(response: dict[str, Any]) -> UsageSnapshot:
usage = response.get("usage")
if not isinstance(usage, dict):
@@ -264,9 +251,6 @@ class CodexApiClient:
}
if request.tools:
body["tools"] = _convert_tools_to_codex(request.tools)
effort = _normalize_reasoning_effort(request.effort)
if effort:
body["reasoning"] = {"effort": effort}
content: list[TextBlock | ToolUseBlock] = []
current_text_parts: list[str] = []
-4
View File
@@ -128,7 +128,3 @@ class CopilotClient:
)
async for event in self._inner.stream_message(patched):
yield event
async def close(self) -> None:
"""Close the underlying OpenAI-compatible client."""
await self._inner.close()
+6 -42
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from typing import Any, AsyncIterator
from urllib.parse import urlsplit, urlunsplit
@@ -146,43 +145,13 @@ def _convert_user_content_to_openai(blocks: list[ContentBlock]) -> str | list[di
return content
_EMPTY_REASONING_ENV = "OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT"
def _empty_reasoning_required() -> bool:
"""True when the operator's provider requires an empty
``reasoning_content`` field on tool-using assistant messages
(Kimi-on-Anthropic style). Default off — strict-OpenAI providers
reject the field outright.
"""
raw = os.environ.get(_EMPTY_REASONING_ENV, "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
"""Convert an assistant ConversationMessage to OpenAI format.
``reasoning_content`` is a non-standard field used by thinking models
(e.g. Kimi k2.5) to carry the model's internal chain-of-thought across
turns. Some thinking-model providers require it on every assistant
message with tool calls — even when empty — or they reject the request.
Other OpenAI-compatible providers (Cerebras, OpenAI's own
endpoint, etc.) reject the field outright with a 400
``wrong_api_format`` error.
Behaviour:
- When the streaming parser captured non-empty reasoning on
``msg._reasoning``, we always replay it. Models that emit reasoning
tokens are by definition thinking models that round-trip them.
- When there is no captured reasoning but the message has tool calls,
we emit ``reasoning_content: ""`` only if the operator opts in via
``OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT=1``. The default is
omit, which matches strict-OpenAI providers.
The opt-in default keeps strict-OpenAI providers (Cerebras, NVIDIA NIM,
OpenAI direct, etc.) working out-of-the-box; Kimi-on-Anthropic users
set the env var in their dotfiles or settings.
Providers with thinking models (e.g. Kimi k2.5) require a
``reasoning_content`` field on every assistant message that contains
tool calls. We stash the raw reasoning text on ``msg._reasoning``
during parsing and replay it here.
"""
text_parts = [b.text for b in msg.content if isinstance(b, TextBlock)]
tool_uses = [b for b in msg.content if isinstance(b, ToolUseBlock)]
@@ -196,9 +165,8 @@ def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
reasoning = getattr(msg, "_reasoning", None)
if reasoning:
openai_msg["reasoning_content"] = reasoning
elif tool_uses and _empty_reasoning_required():
# Kimi-style providers reject tool_use messages without this field
# even when there's nothing to put in it. Opt-in via env var.
elif tool_uses:
# Thinking models require this field even if empty
openai_msg["reasoning_content"] = ""
if tool_uses:
@@ -276,10 +244,6 @@ class OpenAICompatibleClient:
kwargs["timeout"] = timeout
self._client = AsyncOpenAI(**kwargs)
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._client.close()
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
"""Yield text deltas and the final message, matching the Anthropic client interface."""
last_error: Exception | None = None
+9 -50
View File
@@ -432,26 +432,6 @@ class FeishuChannel(BaseChannel):
self._sender_cache: OrderedDict[str, _FeishuSenderInfo] = OrderedDict()
self._loop: asyncio.AbstractEventLoop | None = None
def _ensure_rest_client(self) -> bool:
"""Initialize the Feishu REST client without starting the WebSocket receiver."""
if self._client is not None:
return True
if not FEISHU_AVAILABLE:
logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
return False
if not self.config.app_id or not self.config.app_secret:
logger.error("Feishu app_id and app_secret not configured")
return False
import lark_oapi as lark
self._client = lark.Client.builder() \
.app_id(self.config.app_id) \
.app_secret(self.config.app_secret) \
.domain(self.config.domain) \
.log_level(lark.LogLevel.INFO) \
.build()
return True
async def start(self) -> None:
"""Start the Feishu bot with WebSocket long connection."""
if not FEISHU_AVAILABLE:
@@ -466,8 +446,12 @@ class FeishuChannel(BaseChannel):
self._running = True
self._loop = asyncio.get_running_loop()
if not self._ensure_rest_client():
return
# Create Lark client for sending messages
self._client = lark.Client.builder() \
.app_id(self.config.app_id) \
.app_secret(self.config.app_secret) \
.log_level(lark.LogLevel.INFO) \
.build()
# Create event handler (only register message receive, ignore other events)
event_handler = lark.EventDispatcherHandler.builder(
@@ -482,7 +466,6 @@ class FeishuChannel(BaseChannel):
self.config.app_id,
self.config.app_secret,
event_handler=event_handler,
domain=self.config.domain,
log_level=lark.LogLevel.INFO
)
@@ -1063,7 +1046,7 @@ class FeishuChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu, including media (images/files) if present."""
if not self._ensure_rest_client():
if not self._client:
logger.warning("Feishu client not initialized")
return
@@ -1077,28 +1060,19 @@ class FeishuChannel(BaseChannel):
else None
)
failed_media: list[str] = []
sent_media: list[str] = []
for file_path in msg.media:
if not os.path.isfile(file_path):
logger.warning("Media file not found: %s", file_path)
failed_media.append(file_path)
continue
ext = os.path.splitext(file_path)[1].lower()
if ext in self._IMAGE_EXTS:
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
if key:
ok = await loop.run_in_executor(
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False),
reply_mid,
)
if ok:
sent_media.append(file_path)
else:
failed_media.append(file_path)
else:
failed_media.append(file_path)
else:
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
if key:
@@ -1108,26 +1082,11 @@ class FeishuChannel(BaseChannel):
media_type = "media"
else:
media_type = "file"
ok = await loop.run_in_executor(
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
reply_mid,
)
if ok:
sent_media.append(file_path)
else:
failed_media.append(file_path)
else:
failed_media.append(file_path)
if failed_media:
names = ", ".join(os.path.basename(path) for path in failed_media)
failure_body = json.dumps({"text": f"文件发送失败:{names}"}, ensure_ascii=False)
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "text", failure_body,
reply_mid,
)
if msg.content and msg.content.strip():
fmt = self._detect_msg_format(msg.content)
+1 -2
View File
@@ -165,8 +165,7 @@ class ChannelManager:
try:
await channel.start()
except Exception as e:
setattr(channel, "last_error", str(e))
logger.exception("Failed to start channel %s", name)
logger.error("Failed to start channel %s: %s", name, e)
async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher."""
+3 -7
View File
@@ -179,11 +179,8 @@ class SlackChannel(BaseChannel):
except Exception as e:
logger.debug("Slack reactions_add failed: %s", e)
# Preserve Slack thread metadata for reply routing, but let the ohmo
# gateway router derive the session key. The router includes sender
# identity for shared chats; passing a senderless Slack override here
# would make different people in the same thread share one session.
chat_type = "p2p" if channel_type == "im" else "group"
# Thread-scoped session key for channel/group messages
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
try:
await self._handle_message(
@@ -191,14 +188,13 @@ class SlackChannel(BaseChannel):
chat_id=chat_id,
content=text,
metadata={
"thread_ts": thread_ts,
"chat_type": chat_type,
"slack": {
"event": event,
"thread_ts": thread_ts,
"channel_type": channel_type,
},
},
session_key=session_key,
)
except Exception:
logger.exception("Error handling Slack message from %s", sender_id)
+3 -20
View File
@@ -19,14 +19,6 @@ from openharness.utils.helpers import split_message
logger = logging.getLogger(__name__)
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
_TELEGRAM_URL_LOGGERS = ("httpx", "httpcore", "telegram.ext")
def silence_telegram_token_url_loggers() -> None:
"""Prevent Telegram bot tokens from appearing in dependency INFO logs."""
for name in _TELEGRAM_URL_LOGGERS:
logging.getLogger(name).setLevel(logging.WARNING)
def _markdown_to_telegram_html(text: str) -> str:
@@ -119,8 +111,6 @@ class TelegramChannel(BaseChannel):
self.config: TelegramConfig = config
self.groq_api_key = groq_api_key
self._app: Application | None = None
self.last_error: str | None = None
self.polling_started = False
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
self._media_group_buffers: dict[str, dict] = {}
@@ -133,9 +123,6 @@ class TelegramChannel(BaseChannel):
return
self._running = True
self.last_error = None
self.polling_started = False
silence_telegram_token_url_loggers()
# Build the application with larger connection pool to avoid pool-timeout on long runs
req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0)
@@ -178,10 +165,8 @@ class TelegramChannel(BaseChannel):
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
allowed_updates=["message"],
drop_pending_updates=False,
drop_pending_updates=True # Ignore old messages on startup
)
self.polling_started = True
logger.info("Telegram polling started")
# Keep running until stopped
while self._running:
@@ -190,7 +175,6 @@ class TelegramChannel(BaseChannel):
async def stop(self) -> None:
"""Stop the Telegram bot."""
self._running = False
self.polling_started = False
# Cancel all typing indicators
for chat_id in list(self._typing_tasks):
@@ -317,7 +301,7 @@ class TelegramChannel(BaseChannel):
user = update.effective_user
await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm {self.config.bot_name}.\n\n"
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
"Send me a message and I'll respond!\n"
"Type /help to see available commands."
)
@@ -327,7 +311,7 @@ class TelegramChannel(BaseChannel):
if not update.message:
return
await update.message.reply_text(
f"🐈 {self.config.bot_name} commands:\n"
"🐈 nanobot commands:\n"
"/new — Start a new conversation\n"
"/stop — Stop the current task\n"
"/help — Show available commands"
@@ -508,7 +492,6 @@ class TelegramChannel(BaseChannel):
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Log polling / handler errors instead of silently swallowing them."""
self.last_error = str(context.error)
logger.error("Telegram error: %s", context.error)
def _get_extension(self, media_type: str, mime_type: str | None) -> str:
+3 -90
View File
@@ -405,7 +405,6 @@ def _build_dry_run_preview(
api_key: str | None,
api_format: str | None,
permission_mode: str | None,
effort: str | None = None,
) -> dict[str, object]:
from openharness.api.provider import auth_status, detect_provider
from openharness.commands import create_default_command_registry
@@ -426,7 +425,6 @@ def _build_dry_run_preview(
api_key=api_key,
api_format=api_format,
permission_mode=permission_mode,
effort=effort,
)
provider = detect_provider(settings)
auth = auth_status(settings)
@@ -768,7 +766,6 @@ mcp_app = typer.Typer(name="mcp", help="Manage MCP servers")
plugin_app = typer.Typer(name="plugin", help="Manage plugins")
auth_app = typer.Typer(name="auth", help="Manage authentication")
provider_app = typer.Typer(name="provider", help="Manage provider profiles")
config_app = typer.Typer(name="config", help="Show or update settings")
cron_app = typer.Typer(name="cron", help="Manage cron scheduler and jobs")
autopilot_app = typer.Typer(name="autopilot", help="Manage repo autopilot")
@@ -776,7 +773,6 @@ app.add_typer(mcp_app)
app.add_typer(plugin_app)
app.add_typer(auth_app)
app.add_typer(provider_app)
app.add_typer(config_app)
app.add_typer(cron_app)
app.add_typer(autopilot_app)
@@ -934,20 +930,8 @@ def cron_list_cmd() -> None:
last = last[:19] # trim to readable datetime
last_status = job.get("last_status", "")
status_indicator = f" [{last_status}]" if last_status else ""
timezone = f" ({job['timezone']})" if job.get("timezone") else ""
print(f" [{enabled}] {job['name']} {job.get('schedule', '?')}{timezone}")
print(f" cmd: {job.get('command') or '(agent_turn)'}")
payload = job.get("payload")
if isinstance(payload, dict):
print(
f" payload: {payload.get('kind', 'agent_turn')} -> "
f"{payload.get('channel', '?')}:{payload.get('to', '?')}"
)
notify = job.get("notify")
if isinstance(notify, dict):
notify_type = notify.get("type", "?")
target = notify.get("user_open_id") or notify.get("open_id") or notify.get("chat_id") or "?"
print(f" notify: {notify_type} -> {target}")
print(f" [{enabled}] {job['name']} {job.get('schedule', '?')}")
print(f" cmd: {job['command']}")
print(f" last: {last}{status_indicator} next: {job.get('next_run', 'n/a')[:19]}")
@@ -1969,72 +1953,6 @@ def auth_copilot_logout() -> None:
print("Copilot authentication cleared.")
# ---- config subcommands ----
def _config_resolve_target(settings: object, key: str) -> tuple[object, str]:
target = settings
parts = key.split(".")
for part in parts[:-1]:
if not hasattr(target, part):
raise KeyError(key)
target = getattr(target, part)
leaf = parts[-1]
if not hasattr(target, leaf):
raise KeyError(key)
return target, leaf
def _config_coerce_value(current: object, raw: str) -> object:
if isinstance(current, bool):
lowered = raw.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
raise ValueError(f"Invalid boolean value: {raw}")
if isinstance(current, int) and not isinstance(current, bool):
return int(raw)
if isinstance(current, float):
return float(raw)
if isinstance(current, list):
return [entry.strip() for entry in raw.split(",") if entry.strip()]
return raw
@config_app.command("show")
def config_show() -> None:
"""Print the resolved settings JSON."""
from openharness.commands.registry import _settings_json_for_display
from openharness.config.settings import load_settings
print(_settings_json_for_display(load_settings()), flush=True)
@config_app.command("set")
def config_set(
key: str = typer.Argument(..., help="Setting key, including dotted nested keys"),
value: str = typer.Argument(..., help="Value to store"),
) -> None:
"""Persist one setting in ~/.openharness/settings.json."""
from openharness.config.settings import load_settings, save_settings
settings = load_settings()
try:
target, leaf = _config_resolve_target(settings, key)
except KeyError:
print(f"Unknown config key: {key}", file=sys.stderr)
raise typer.Exit(1)
try:
coerced = _config_coerce_value(getattr(target, leaf), value)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise typer.Exit(1)
setattr(target, leaf, coerced)
save_settings(settings)
print(f"Updated {key}", flush=True)
# ---- provider subcommands ----
@@ -2221,7 +2139,7 @@ def main(
effort: str | None = typer.Option(
None,
"--effort",
help="Effort level for the session (low, medium, high, xhigh/max)",
help="Effort level for the session (low, medium, high, max)",
rich_help_panel="Model & Effort",
),
verbose: bool = typer.Option(
@@ -2416,7 +2334,6 @@ def main(
api_key=api_key,
api_format=api_format,
permission_mode=permission_mode,
effort=effort,
)
effective_output_format = output_format or "text"
if effective_output_format == "text":
@@ -2490,7 +2407,6 @@ def main(
restore_tool_metadata=session_data.get("tool_metadata"),
permission_mode=permission_mode,
api_format=api_format,
effort=effort,
)
)
return
@@ -2513,7 +2429,6 @@ def main(
api_format=api_format,
permission_mode=permission_mode,
max_turns=max_turns,
effort=effort,
)
)
return
@@ -2529,7 +2444,6 @@ def main(
api_key=api_key,
api_format=api_format,
permission_mode=permission_mode,
effort=effort,
)
)
return
@@ -2546,6 +2460,5 @@ def main(
api_key=api_key,
api_format=api_format,
permission_mode=permission_mode,
effort=effort,
)
)
+16 -349
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import importlib.metadata
import json
import os
import re
import shutil
import subprocess
@@ -37,29 +36,7 @@ from openharness.memory import (
get_memory_entrypoint,
get_project_memory_dir,
list_memory_files,
migrate_memory,
remove_memory_entry,
scan_memory_files,
)
from openharness.memory.agent import (
ensure_agent_memory_vault,
get_agent_memory_entrypoint,
initialize_agent_memory_from_snapshot,
)
from openharness.memory.schema import (
DEFAULT_MEMORY_SCOPE,
DEFAULT_MEMORY_TYPE,
MEMORY_TYPES,
is_disabled_metadata,
is_memory_expired,
parse_memory_scope,
parse_memory_type,
split_memory_file,
)
from openharness.memory.team import (
check_team_memory_secrets,
ensure_team_memory_vault,
get_team_memory_dir,
)
from openharness.output_styles import load_output_styles
from openharness.permissions import PermissionChecker, PermissionMode
@@ -81,12 +58,6 @@ from openharness.services.autodream import (
restore_memory_backup,
start_dream_now,
)
from openharness.services.memory_extract import extract_memories_from_turn
from openharness.services.session_memory import (
get_session_memory_content,
get_session_memory_path,
update_session_memory_file,
)
from openharness.services.session_backend import DEFAULT_SESSION_BACKEND, SessionBackend
from openharness.skills import load_skill_registry
from openharness.skills.types import SkillDefinition
@@ -119,8 +90,6 @@ class MemoryCommandBackend:
"""Storage backend used by the generic ``/memory`` slash command."""
label: str
default_type: str
default_category: str
get_memory_dir: Callable[[], Path]
get_entrypoint: Callable[[], Path]
list_files: Callable[[], list[Path]]
@@ -681,10 +650,7 @@ def create_default_command_registry(
message=(
f"Memory store: {backend.label}\n"
f"Memory directory: {backend.get_memory_dir()}\n"
f"Entrypoint: {backend.get_entrypoint()}\n"
"Commands: list, show NAME, add TITLE :: CONTENT, remove NAME, "
"edit [NAME], validate, extract, session, team, agent, "
"migrate --dry-run, migrate --apply"
f"Entrypoint: {backend.get_entrypoint()}"
)
)
action = tokens[0]
@@ -694,37 +660,6 @@ def create_default_command_registry(
if not memory_files:
return CommandResult(message="No memory files.")
return CommandResult(message="\n".join(path.name for path in memory_files))
if action == "migrate":
if rest not in {"--dry-run", "--apply"}:
return CommandResult(
message=(
"Usage: /memory "
"[list|show NAME|add TITLE :: CONTENT|remove NAME|"
"migrate --dry-run|migrate --apply]"
)
)
summary = migrate_memory(
context.cwd,
memory_dir=backend.get_memory_dir(),
default_type=backend.default_type,
default_category=backend.default_category,
apply=rest == "--apply",
)
mode = "dry run" if summary.dry_run else "applied"
lines = [
f"Memory migration {mode}.",
f"Scanned: {summary.scanned}",
f"Changed: {summary.changed}",
f"Unchanged: {summary.unchanged}",
f"Failed: {summary.failed}",
]
if summary.backup_dir:
lines.append(f"Backup: {summary.backup_dir}")
if summary.changed_files:
lines.append("Changed files: " + ", ".join(summary.changed_files))
if summary.failed_files:
lines.append("Failed files: " + ", ".join(summary.failed_files))
return CommandResult(message="\n".join(lines))
if action == "show" and rest:
memory_dir = backend.get_memory_dir()
path, invalid = _resolve_memory_entry_path(memory_dir, rest)
@@ -734,64 +669,18 @@ def create_default_command_registry(
return CommandResult(message=f"Memory entry not found: {rest}")
if not path.exists():
return CommandResult(message=f"Memory entry not found: {rest}")
content = path.read_text(encoding="utf-8")
metadata, _, _, _ = split_memory_file(content)
if is_disabled_metadata(metadata) or is_memory_expired(metadata):
return CommandResult(message=f"Memory entry not found: {rest}")
return CommandResult(message=content)
return CommandResult(message=path.read_text(encoding="utf-8"))
if action == "add" and rest:
memory_type, scope, cleaned_rest = _parse_memory_add_flags(rest)
title, separator, content = cleaned_rest.partition("::")
title, separator, content = rest.partition("::")
if not separator or not title.strip() or not content.strip():
return CommandResult(message="Usage: /memory add [--type TYPE] [--scope SCOPE] TITLE :: CONTENT")
if context.memory_backend is None:
path = add_memory_entry(
context.cwd,
title.strip(),
content.strip(),
memory_type=memory_type,
scope=scope,
)
else:
path = backend.add_entry(title.strip(), content.strip())
return CommandResult(message="Usage: /memory add TITLE :: CONTENT")
path = backend.add_entry(title.strip(), content.strip())
return CommandResult(message=f"Added memory entry {path.name}")
if action == "remove" and rest:
if backend.remove_entry(rest.strip()):
return CommandResult(message=f"Removed memory entry {rest.strip()}")
return CommandResult(message=f"Memory entry not found: {rest.strip()}")
if action == "edit":
return _handle_memory_edit_command(rest, context, backend)
if action == "validate":
return _handle_memory_validate_command(context)
if action == "extract":
if context.memory_backend is not None:
return CommandResult(message="Memory extraction is only supported for OpenHarness project memory.")
result = await extract_memories_from_turn(
cwd=context.cwd,
api_client=context.engine.api_client,
model=context.engine.model,
messages=context.engine.messages,
max_records=load_settings().memory.auto_extract_max_records,
)
if result.skipped:
return CommandResult(message=f"Memory extraction skipped: {result.reason}")
return CommandResult(
message="Memory extraction wrote:\n" + "\n".join(f"- {path}" for path in result.written_paths)
)
if action == "session":
return _handle_memory_session_command(rest, context)
if action == "team":
return _handle_memory_team_command(rest, context)
if action == "agent":
return _handle_memory_agent_command(rest, context)
return CommandResult(
message=(
"Usage: /memory "
"[list|show NAME|add TITLE :: CONTENT|remove NAME|edit [NAME]|"
"validate|extract|session|team|agent|"
"migrate --dry-run|migrate --apply]"
)
)
return CommandResult(message="Usage: /memory [list|show NAME|add TITLE :: CONTENT|remove NAME]")
async def _hooks_handler(_: str, context: CommandContext) -> CommandResult:
return CommandResult(message=context.hooks_summary or "No hooks configured.")
@@ -867,7 +756,6 @@ def create_default_command_registry(
latest = session_dir / "latest.json"
transcript = session_dir / "transcript.md"
lines = [
f"Session ID: {context.session_id or '(none)'}",
f"Session directory: {session_dir}",
f"Latest snapshot: {'present' if latest.exists() else 'missing'}",
f"Transcript export: {'present' if transcript.exists() else 'missing'}",
@@ -1235,13 +1123,10 @@ def create_default_command_registry(
value = args.strip() or "show"
if value == "show":
return CommandResult(message=f"Reasoning effort: {current}")
if value == "max":
value = "xhigh"
if value not in {"low", "medium", "high", "xhigh"}:
return CommandResult(message="Usage: /effort [show|low|medium|high|xhigh]")
if value not in {"low", "medium", "high"}:
return CommandResult(message="Usage: /effort [show|low|medium|high]")
settings.effort = value
save_settings(settings)
context.engine.set_effort(value)
context.engine.set_system_prompt(
build_runtime_system_prompt(
settings,
@@ -2326,15 +2211,7 @@ def create_default_command_registry(
registry.register(SlashCommand("version", "Show the installed OpenHarness version", _version_handler))
registry.register(SlashCommand("status", "Show session status", _status_handler))
registry.register(SlashCommand("context", "Show the active runtime system prompt", _context_handler))
registry.register(
SlashCommand(
"summary",
"Summarize conversation history",
_summary_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(SlashCommand("summary", "Summarize conversation history", _summary_handler))
registry.register(SlashCommand("compact", "Compact older conversation history", _compact_handler))
registry.register(SlashCommand("cost", "Show token usage and estimated cost", _cost_handler))
registry.register(SlashCommand("usage", "Show usage and token estimates", _usage_handler))
@@ -2342,15 +2219,7 @@ def create_default_command_registry(
registry.register(SlashCommand("dream", "Consolidate memory", _dream_handler))
registry.register(SlashCommand("memory", "Inspect and manage project memory", _memory_handler))
registry.register(SlashCommand("hooks", "Show configured hooks", _hooks_handler))
registry.register(
SlashCommand(
"resume",
"Restore the latest saved session",
_resume_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(SlashCommand("resume", "Restore the latest saved session", _resume_handler))
registry.register(SlashCommand("session", "Inspect the current session storage", _session_handler))
registry.register(SlashCommand("export", "Export the current transcript", _export_handler))
registry.register(SlashCommand("share", "Create a shareable transcript snapshot", _share_handler))
@@ -2474,67 +2343,19 @@ def create_default_command_registry(
registry.register(SlashCommand("vim", "Show or update Vim mode", _vim_handler))
registry.register(SlashCommand("voice", "Show or update voice mode", _voice_handler))
registry.register(SlashCommand("doctor", "Show environment diagnostics", _doctor_handler))
registry.register(
SlashCommand(
"diff",
"Show git diff output",
_diff_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(SlashCommand("diff", "Show git diff output", _diff_handler))
registry.register(SlashCommand("branch", "Show git branch information", _branch_handler))
registry.register(
SlashCommand(
"commit",
"Show status or create a git commit",
_commit_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(
SlashCommand(
"issue",
"Show or update project issue context",
_issue_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(
SlashCommand(
"pr_comments",
"Show or update project PR comments context",
_pr_comments_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(SlashCommand("commit", "Show status or create a git commit", _commit_handler))
registry.register(SlashCommand("issue", "Show or update project issue context", _issue_handler))
registry.register(SlashCommand("pr_comments", "Show or update project PR comments context", _pr_comments_handler))
registry.register(SlashCommand("privacy-settings", "Show local privacy and storage settings", _privacy_settings_handler))
registry.register(SlashCommand("rate-limit-options", "Show ways to reduce provider rate pressure", _rate_limit_options_handler))
registry.register(SlashCommand("release-notes", "Show recent OpenHarness release notes", _release_notes_handler))
registry.register(SlashCommand("upgrade", "Show upgrade instructions", _upgrade_handler))
registry.register(SlashCommand("agents", "List or inspect agent and teammate tasks", _agents_handler))
registry.register(SlashCommand("subagents", "Show subagent usage and inspect worker tasks", _agents_handler))
registry.register(
SlashCommand(
"tasks",
"Manage background tasks",
_tasks_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(
SlashCommand(
"autopilot",
"Manage repo autopilot intake and context",
_autopilot_handler,
remote_invocable=False,
remote_admin_opt_in=True,
)
)
registry.register(SlashCommand("tasks", "Manage background tasks", _tasks_handler))
registry.register(SlashCommand("autopilot", "Manage repo autopilot intake and context", _autopilot_handler))
registry.register(
SlashCommand(
"ship",
@@ -2577,158 +2398,6 @@ def create_default_command_registry(
return registry
def _handle_memory_edit_command(
args: str,
context: CommandContext,
backend: MemoryCommandBackend,
) -> CommandResult:
memory_dir = backend.get_memory_dir()
target = backend.get_entrypoint()
if args.strip():
path, invalid = _resolve_memory_entry_path(memory_dir, args.strip())
if invalid:
return CommandResult(message="Memory entry path must stay within the configured memory directory.")
if path is None:
return CommandResult(message=f"Memory entry not found: {args.strip()}")
target = path
target.parent.mkdir(parents=True, exist_ok=True)
target.touch(exist_ok=True)
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
if not editor:
return CommandResult(message=f"Memory file ready: {target}\nSet $VISUAL or $EDITOR to open it from /memory edit.")
result = subprocess.run([editor, str(target)], cwd=context.cwd, check=False)
if result.returncode != 0:
return CommandResult(message=f"Editor exited with status {result.returncode}: {editor}")
return CommandResult(message=f"Edited memory file: {target}")
def _parse_memory_add_flags(args: str):
"""Parse optional ``/memory add`` type/scope flags."""
memory_type = DEFAULT_MEMORY_TYPE
scope = DEFAULT_MEMORY_SCOPE
rest = args.strip()
changed = True
while changed:
changed = False
if rest.startswith("--type "):
_, _, tail = rest.partition(" ")
raw, _, rest = tail.partition(" ")
parsed = parse_memory_type(raw, default=DEFAULT_MEMORY_TYPE)
if parsed is not None:
memory_type = parsed
changed = True
if rest.startswith("--scope "):
_, _, tail = rest.partition(" ")
raw, _, rest = tail.partition(" ")
parsed_scope = parse_memory_scope(raw, default=DEFAULT_MEMORY_SCOPE)
if parsed_scope is not None:
scope = parsed_scope
changed = True
return memory_type, scope, rest
def _handle_memory_validate_command(context: CommandContext) -> CommandResult:
memory_dir = get_project_memory_dir(context.cwd)
headers = scan_memory_files(context.cwd, max_files=500)
issues: list[str] = []
for header in headers:
raw_type = header.frontmatter.get("type") or header.frontmatter.get("memory_type")
if parse_memory_type(raw_type) is None:
issues.append(
f"- {header.relative_path}: invalid or missing type {raw_type!r}; expected {', '.join(MEMORY_TYPES)}"
)
if "team" in Path(header.relative_path).parts:
try:
text = header.path.read_text(encoding="utf-8", errors="replace")
except OSError:
text = ""
secret_error = check_team_memory_secrets(text)
if secret_error:
issues.append(f"- {header.relative_path}: {secret_error}")
if not issues:
return CommandResult(
message=(
"Memory validation passed.\n"
f"- files: {len(headers)}\n"
f"- memory_dir: {memory_dir}"
)
)
return CommandResult(message="Memory validation issues:\n" + "\n".join(issues))
def _handle_memory_session_command(args: str, context: CommandContext) -> CommandResult:
action = args.split(maxsplit=1)[0] if args.strip() else "status"
path = get_session_memory_path(context.cwd, context.session_id or "default")
if action == "update":
path = update_session_memory_file(
context.cwd,
context.engine.messages,
tool_metadata=context.engine.tool_metadata,
session_id=context.session_id or "default",
)
return CommandResult(message=f"Updated session memory: {path}")
if action == "show":
content = get_session_memory_content(path)
return CommandResult(message=content or f"No session memory at {path}")
return CommandResult(
message=(
"Session memory:\n"
f"- path: {path}\n"
f"- exists: {path.exists()}\n"
"Commands: /memory session [status|show|update]"
)
)
def _handle_memory_team_command(args: str, context: CommandContext) -> CommandResult:
action = args.split(maxsplit=1)[0] if args.strip() else "status"
team_dir = ensure_team_memory_vault(context.cwd)
if action == "list":
files = sorted(path for path in team_dir.rglob("*.md") if path.name != "MEMORY.md")
return CommandResult(message="\n".join(str(path.relative_to(team_dir)) for path in files) or "No team memory files.")
if action == "validate":
issues: list[str] = []
for path in sorted(team_dir.rglob("*.md")):
if path.name == "MEMORY.md":
continue
text = path.read_text(encoding="utf-8", errors="replace")
secret_error = check_team_memory_secrets(text)
if secret_error:
issues.append(f"- {path.relative_to(team_dir)}: {secret_error}")
return CommandResult(message="Team memory validation passed." if not issues else "\n".join(issues))
return CommandResult(
message=(
"Team memory:\n"
f"- directory: {get_team_memory_dir(context.cwd)}\n"
f"- exists: {team_dir.exists()}\n"
"Commands: /memory team [status|list|validate]"
)
)
def _handle_memory_agent_command(args: str, context: CommandContext) -> CommandResult:
parts = args.split()
action = parts[0] if parts else "status"
agent_type = parts[1] if len(parts) > 1 else "default"
scope = parts[2] if len(parts) > 2 else "project"
if scope not in {"user", "project", "local"}:
return CommandResult(message="Agent memory scope must be one of: user, project, local")
if action == "snapshot":
target = initialize_agent_memory_from_snapshot(context.cwd, agent_type, scope) # type: ignore[arg-type]
return CommandResult(message=f"Initialized agent memory from snapshot: {target}" if target else "No snapshot found.")
vault = ensure_agent_memory_vault(context.cwd, agent_type, scope) # type: ignore[arg-type]
return CommandResult(
message=(
"Agent memory:\n"
f"- agent_type: {agent_type}\n"
f"- scope: {scope}\n"
f"- directory: {vault}\n"
f"- entrypoint: {get_agent_memory_entrypoint(context.cwd, agent_type, scope)}"
)
)
def _resolve_memory_entry_path(memory_dir: Path, candidate: str) -> tuple[Path | None, bool]:
"""Resolve a memory entry path while enforcing containment under ``memory_dir``."""
@@ -2761,8 +2430,6 @@ def _memory_backend_for_context(context: CommandContext) -> MemoryCommandBackend
cwd = context.cwd
return MemoryCommandBackend(
label="OpenHarness project memory",
default_type="project",
default_category="knowledge",
get_memory_dir=lambda: get_project_memory_dir(cwd),
get_entrypoint=lambda: get_memory_entrypoint(cwd),
list_files=lambda: list_memory_files(cwd),
-3
View File
@@ -35,8 +35,6 @@ class TelegramConfig(BaseChannelConfig):
token: str = ""
chat_id: str | None = None
proxy: str | None = None
reply_to_message: bool = True
bot_name: str = "ohmo"
class SlackConfig(BaseChannelConfig):
@@ -59,7 +57,6 @@ class FeishuConfig(BaseChannelConfig):
group_policy: str = "managed_or_mention"
bot_open_id: str = ""
bot_names: list[str] = Field(default_factory=lambda: ["ohmo", "openclaw", "openharness"])
domain: str = "https://open.feishu.cn" # use https://open.larksuite.com for Lark international
class DingTalkConfig(BaseChannelConfig):
+37 -152
View File
@@ -63,12 +63,8 @@ class MemorySettings(BaseModel):
enabled: bool = True
max_files: int = 5
max_entrypoint_lines: int = 200
max_entrypoint_bytes: int = 25_000
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
auto_extract_enabled: bool = False
auto_extract_max_records: int = 3
session_memory_enabled: bool = True
auto_dream_enabled: bool = False
auto_dream_min_hours: float = 24.0
auto_dream_min_sessions: int = 5
@@ -113,14 +109,6 @@ class SandboxSettings(BaseModel):
docker: DockerSandboxSettings = Field(default_factory=DockerSandboxSettings)
class WebSettings(BaseModel):
"""Outbound web tool configuration."""
proxy: str | None = None
resolution_mode: str = "auto"
synthetic_dns_cidrs: list[str] = Field(default_factory=list)
class ProviderProfile(BaseModel):
"""Named provider workflow configuration."""
@@ -378,30 +366,6 @@ def auth_source_uses_api_key(auth_source: str) -> bool:
return auth_source.endswith("_api_key")
def auth_source_env_var_candidates(auth_source: str) -> tuple[str, ...]:
"""Return env vars to probe for an auth source in precedence order."""
mapping = {
"anthropic_api_key": ("OPENHARNESS_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"),
"openai_api_key": ("OPENHARNESS_OPENAI_API_KEY", "OPENAI_API_KEY"),
"dashscope_api_key": ("OPENHARNESS_DASHSCOPE_API_KEY", "DASHSCOPE_API_KEY"),
"moonshot_api_key": ("OPENHARNESS_MOONSHOT_API_KEY", "MOONSHOT_API_KEY"),
"gemini_api_key": ("OPENHARNESS_GEMINI_API_KEY", "GEMINI_API_KEY"),
"minimax_api_key": ("OPENHARNESS_MINIMAX_API_KEY", "MINIMAX_API_KEY"),
"nvidia_api_key": ("OPENHARNESS_NVIDIA_API_KEY", "NVIDIA_API_KEY"),
"modelscope_api_key": ("OPENHARNESS_MODELSCOPE_API_KEY", "MODELSCOPE_API_KEY"),
}
return mapping.get(auth_source, ())
def resolve_auth_env_value(auth_source: str) -> tuple[str, str] | None:
"""Return the first configured env var/value pair for an auth source."""
for env_var in auth_source_env_var_candidates(auth_source):
env_value = os.environ.get(env_var, "")
if env_value:
return env_var, env_value
return None
def credential_storage_provider_name(profile_name: str, profile: ProviderProfile) -> str:
"""Return the storage namespace used for this profile's credential.
@@ -506,37 +470,6 @@ def _profile_from_flat_settings(settings: "Settings") -> tuple[str, ProviderProf
return name, profile
class ImageGenerationConfig(BaseModel):
"""Configuration for the image_generation tool."""
provider: str = "auto"
model: str = "gpt-image-2"
api_key: str = ""
base_url: str = ""
codex_model: str = "gpt-5.4"
codex_base_url: str = ""
@classmethod
def from_env(cls) -> "ImageGenerationConfig":
"""Load image generation config from environment variables."""
return cls(
provider=os.environ.get("OPENHARNESS_IMAGE_GENERATION_PROVIDER", "auto").strip()
or "auto",
model=os.environ.get("OPENHARNESS_IMAGE_GENERATION_MODEL", "gpt-image-2").strip()
or "gpt-image-2",
api_key=os.environ.get("OPENHARNESS_IMAGE_GENERATION_API_KEY", "").strip(),
base_url=os.environ.get("OPENHARNESS_IMAGE_GENERATION_BASE_URL", "").strip(),
codex_model=os.environ.get("OPENHARNESS_IMAGE_GENERATION_CODEX_MODEL", "gpt-5.4").strip()
or "gpt-5.4",
codex_base_url=os.environ.get("OPENHARNESS_IMAGE_GENERATION_CODEX_BASE_URL", "").strip(),
)
@property
def is_configured(self) -> bool:
"""Return True when either a key provider or Codex provider is selected."""
return bool(self.api_key or self.provider in {"auto", "codex"})
class VisionModelConfig(BaseModel):
"""Configuration for the vision model used by the image_to_text tool.
@@ -586,7 +519,6 @@ class Settings(BaseModel):
hooks: dict[str, list[HookDefinition]] = Field(default_factory=dict)
memory: MemorySettings = Field(default_factory=MemorySettings)
sandbox: SandboxSettings = Field(default_factory=SandboxSettings)
web: WebSettings = Field(default_factory=WebSettings)
enabled_plugins: dict[str, bool] = Field(default_factory=dict)
allow_project_plugins: bool = False
allow_project_skills: bool = True
@@ -608,9 +540,6 @@ class Settings(BaseModel):
# Vision model (image-to-text fallback)
vision: VisionModelConfig = Field(default_factory=VisionModelConfig)
# Image generation model
image_generation: ImageGenerationConfig = Field(default_factory=ImageGenerationConfig)
def merged_profiles(self) -> dict[str, ProviderProfile]:
"""Return the saved profiles merged over the built-in catalog."""
merged = default_provider_profiles()
@@ -745,15 +674,19 @@ class Settings(BaseModel):
if self.api_key:
return self.api_key
env_resolved = resolve_auth_env_value(profile.auth_source)
if env_resolved:
_, env_value = env_resolved
return env_value
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
if env_key:
return env_key
# Also check OPENAI_API_KEY for openai-format providers
openai_key = os.environ.get("OPENAI_API_KEY", "")
if openai_key:
return openai_key
raise ValueError(
"No API key found. Set an OPENHARNESS_* provider API key "
"(preferred) or the matching native provider environment variable, "
"or configure api_key in ~/.openharness/settings.json"
"No API key found. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY for openai-format "
"providers) environment variable, or configure api_key in "
"~/.openharness/settings.json"
)
def resolve_auth(self) -> ResolvedAuth:
@@ -829,16 +762,25 @@ class Settings(BaseModel):
storage_provider = credential_storage_provider_name(profile_name, profile)
env_resolved = resolve_auth_env_value(auth_source)
if env_resolved:
env_var, env_value = env_resolved
return ResolvedAuth(
provider=provider or storage_provider,
auth_kind="api_key",
value=env_value,
source=f"env:{env_var}",
state="configured",
)
env_var = {
"anthropic_api_key": "ANTHROPIC_API_KEY",
"openai_api_key": "OPENAI_API_KEY",
"dashscope_api_key": "DASHSCOPE_API_KEY",
"moonshot_api_key": "MOONSHOT_API_KEY",
"minimax_api_key": "MINIMAX_API_KEY",
"nvidia_api_key": "NVIDIA_API_KEY",
"modelscope_api_key": "MODELSCOPE_API_KEY",
}.get(auth_source)
if env_var:
env_value = os.environ.get(env_var, "")
if env_value:
return ResolvedAuth(
provider=provider or storage_provider,
auth_kind="api_key",
value=env_value,
source=f"env:{env_var}",
state="configured",
)
explicit_key = "" if profile.credential_slot else self.api_key
if explicit_key:
@@ -868,25 +810,10 @@ class Settings(BaseModel):
def merge_cli_overrides(self, **overrides: Any) -> Settings:
"""Return a new Settings with CLI overrides applied (non-None values only)."""
updates = {k: v for k, v in overrides.items() if v is not None}
permission_mode = updates.pop("permission_mode", None)
def apply_permission_mode(settings: Settings) -> Settings:
if permission_mode is None:
return settings
return settings.model_copy(
update={
"permission": settings.permission.model_copy(
update={"mode": PermissionMode(str(permission_mode))}
)
}
)
# Strip ANSI escape sequences from model name if present
if "model" in updates and isinstance(updates["model"], str):
updates["model"] = strip_ansi_escape_sequences(updates["model"])
if "effort" in updates and isinstance(updates["effort"], str):
updates["effort"] = "xhigh" if updates["effort"].strip().lower() == "max" else updates["effort"].strip().lower()
merged = apply_permission_mode(self.model_copy(update=updates))
merged = self.model_copy(update=updates)
if not updates:
return merged
profile_keys = {
@@ -903,25 +830,8 @@ class Settings(BaseModel):
profile_updates = profile_keys.intersection(updates)
if not profile_updates:
return merged
if "active_profile" in profile_updates:
switch_updates = {
key: value
for key, value in updates.items()
if key not in profile_keys or key in {"active_profile", "profiles"}
}
switched = apply_permission_mode(self.model_copy(update=switch_updates)).materialize_active_profile()
remaining_profile_updates = {
key: value
for key, value in updates.items()
if key in profile_keys and key not in {"active_profile", "profiles"}
}
if not remaining_profile_updates:
return switched
return (
switched.model_copy(update=remaining_profile_updates)
.sync_active_profile_from_flat_fields()
.materialize_active_profile()
)
if profile_updates.issubset({"active_profile"}):
return merged.materialize_active_profile()
return merged.sync_active_profile_from_flat_fields().materialize_active_profile()
@@ -979,23 +889,15 @@ def _apply_env_overrides(settings: Settings) -> Settings:
if auto_compact_threshold_tokens:
updates["auto_compact_threshold_tokens"] = int(auto_compact_threshold_tokens)
provider = os.environ.get("OPENHARNESS_PROVIDER")
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
env_auth_source = active_profile.auth_source
if provider or api_format:
env_auth_source = default_auth_source_for_provider(
provider or active_profile.provider,
api_format or active_profile.api_format,
)
env_resolved = resolve_auth_env_value(env_auth_source)
if env_resolved:
_, api_key = env_resolved
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")
if api_key:
updates["api_key"] = api_key
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
if api_format:
updates["api_format"] = api_format
provider = os.environ.get("OPENHARNESS_PROVIDER")
if provider:
updates["provider"] = provider
@@ -1017,23 +919,6 @@ def _apply_env_overrides(settings: Settings) -> Settings:
if sandbox_updates:
updates["sandbox"] = settings.sandbox.model_copy(update=sandbox_updates)
web_updates: dict[str, Any] = {}
web_proxy = os.environ.get("OPENHARNESS_WEB_PROXY")
if web_proxy:
web_updates["proxy"] = web_proxy
web_resolution_mode = os.environ.get("OPENHARNESS_WEB_RESOLUTION_MODE")
if web_resolution_mode:
web_updates["resolution_mode"] = web_resolution_mode
web_synthetic_dns_cidrs = os.environ.get("OPENHARNESS_WEB_SYNTHETIC_DNS_CIDRS")
if web_synthetic_dns_cidrs:
web_updates["synthetic_dns_cidrs"] = [
entry.strip()
for entry in web_synthetic_dns_cidrs.split(",")
if entry.strip()
]
if web_updates:
updates["web"] = settings.web.model_copy(update=web_updates)
if not updates:
return settings
return settings.model_copy(update=updates)
-1
View File
@@ -53,7 +53,6 @@ class ToolResultBlock(BaseModel):
tool_use_id: str
content: str
is_error: bool = False
result_metadata: dict[str, Any] = Field(default_factory=dict)
ContentBlock = Annotated[
+1 -14
View File
@@ -145,7 +145,6 @@ class QueryContext:
model: str
system_prompt: str
max_tokens: int
effort: str | None = None
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
permission_prompt: PermissionPrompt | None = None
@@ -732,7 +731,6 @@ async def run_query(
system_prompt=context.system_prompt,
max_tokens=effective_max_tokens,
tools=context.tool_registry.to_api_schema(),
effort=context.effort,
)
):
if isinstance(event, ApiTextDeltaEvent):
@@ -822,20 +820,11 @@ async def run_query(
# Single tool: sequential (stream events immediately)
tc = tool_calls[0]
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
try:
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
except Exception as exc:
log.exception("tool execution raised: name=%s id=%s", tc.name, tc.id)
result = ToolResultBlock(
tool_use_id=tc.id,
content=f"Tool {tc.name} failed: {type(exc).__name__}: {exc}",
is_error=True,
)
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
yield ToolExecutionCompleted(
tool_name=tc.name,
output=result.content,
is_error=result.is_error,
metadata=result.result_metadata,
), None
tool_results = [result]
else:
@@ -874,7 +863,6 @@ async def run_query(
tool_name=tc.name,
output=result.content,
is_error=result.is_error,
metadata=result.result_metadata,
), None
messages.append(ConversationMessage(role="user", content=tool_results))
@@ -993,7 +981,6 @@ async def _execute_tool_call(
tool_use_id=tool_use_id,
content=inline_output,
is_error=result.is_error,
result_metadata=dict(result.metadata or {}),
)
_record_tool_carryover(
context,
+1 -73
View File
@@ -8,7 +8,7 @@ from typing import AsyncIterator
from openharness.api.client import SupportsStreamingMessages
from openharness.engine.cost_tracker import CostTracker
from openharness.coordinator.coordinator_mode import get_coordinator_user_context
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock, sanitize_conversation_messages
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock
from openharness.engine.query import AskUserPrompt, PermissionPrompt, QueryContext, remember_user_goal, run_query
from openharness.engine.stream_events import AssistantTurnComplete, StreamEvent
from openharness.config.settings import Settings
@@ -47,7 +47,6 @@ class QueryEngine:
self._model = model
self._system_prompt = system_prompt
self._max_tokens = max_tokens
self._effort = settings.effort if settings is not None else None
self._context_window_tokens = context_window_tokens
self._auto_compact_threshold_tokens = auto_compact_threshold_tokens
self._max_turns = max_turns
@@ -107,10 +106,6 @@ class QueryEngine:
"""Update the active model for future turns."""
self._model = model
def set_effort(self, effort: str | None) -> None:
"""Update the active reasoning effort for future turns."""
self._effort = effort
def set_api_client(self, api_client: SupportsStreamingMessages) -> None:
"""Update the active API client for future turns."""
self._api_client = api_client
@@ -152,63 +147,6 @@ class QueryEngine:
**kwargs,
)
def _prepare_session_memory(self) -> None:
"""Expose file-backed session memory to compaction when enabled."""
if self._settings is None or not self._settings.memory.session_memory_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.session_memory import prepare_session_memory_metadata
prepare_session_memory_metadata(
self._cwd,
self._tool_metadata,
session_id=str(self._tool_metadata.get("session_id") or "default"),
)
async def _update_session_memory(self) -> None:
"""Persist a session checkpoint after a user turn."""
if self._settings is None or not self._settings.memory.session_memory_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.session_memory import update_session_memory_file
update_session_memory_file(
self._cwd,
list(self._messages),
tool_metadata=self._tool_metadata,
session_id=str(self._tool_metadata.get("session_id") or "default"),
)
async def _extract_durable_memories(self) -> None:
"""Run the optional durable memory extraction pass."""
if self._settings is None or not self._settings.memory.auto_extract_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.memory_extract import extract_memories_from_turn
try:
result = await extract_memories_from_turn(
cwd=self._cwd,
api_client=self._api_client,
model=self._model,
messages=list(self._messages),
max_records=self._settings.memory.auto_extract_max_records,
)
except Exception as exc:
self._tool_metadata["memory_extract_last_error"] = str(exc)
return
self._tool_metadata["memory_extract_last"] = {
"skipped": result.skipped,
"reason": result.reason,
"written_paths": [str(path) for path in result.written_paths],
}
def has_pending_continuation(self) -> bool:
"""Return True when the conversation ends with tool results awaiting a follow-up model turn."""
if not self._messages:
@@ -233,8 +171,6 @@ class QueryEngine:
)
if user_message.text.strip() and not self._tool_metadata.pop("_suppress_next_user_goal", False):
remember_user_goal(self._tool_metadata, user_message.text)
self._prepare_session_memory()
self._messages = sanitize_conversation_messages(self._messages)
self._messages.append(user_message)
if self._hook_executor is not None:
await self._hook_executor.execute(
@@ -252,7 +188,6 @@ class QueryEngine:
model=self._model,
system_prompt=self._system_prompt,
max_tokens=self._max_tokens,
effort=self._effort,
context_window_tokens=self._context_window_tokens,
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
max_turns=self._max_turns,
@@ -273,14 +208,10 @@ class QueryEngine:
self._cost_tracker.add(usage)
yield event
finally:
await self._update_session_memory()
await self._extract_durable_memories()
self._schedule_auto_dream()
async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterator[StreamEvent]:
"""Continue an interrupted tool loop without appending a new user message."""
self._prepare_session_memory()
self._messages = sanitize_conversation_messages(self._messages)
context = QueryContext(
api_client=self._api_client,
tool_registry=self._tool_registry,
@@ -289,7 +220,6 @@ class QueryEngine:
model=self._model,
system_prompt=self._system_prompt,
max_tokens=self._max_tokens,
effort=self._effort,
context_window_tokens=self._context_window_tokens,
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
max_turns=max_turns if max_turns is not None else self._max_turns,
@@ -302,5 +232,3 @@ class QueryEngine:
if usage is not None:
self._cost_tracker.add(usage)
yield event
await self._update_session_memory()
await self._extract_durable_memories()
-1
View File
@@ -39,7 +39,6 @@ class ToolExecutionCompleted:
tool_name: str
output: str
is_error: bool = False
metadata: dict[str, Any] | None = None
@dataclass(frozen=True)
+2 -10
View File
@@ -18,13 +18,8 @@ class HookRegistry:
self._hooks[event].append(hook)
def get(self, event: HookEvent) -> list[HookDefinition]:
"""Return hooks registered for an event, ordered by priority.
Hooks with a higher ``priority`` run first. ``sorted`` is stable, so
hooks sharing the same priority keep their registration order.
"""
hooks = self._hooks.get(event, [])
return sorted(hooks, key=lambda hook: -getattr(hook, "priority", 0))
"""Return hooks registered for an event."""
return list(self._hooks.get(event, []))
def summary(self) -> str:
"""Return a human-readable hook summary."""
@@ -38,9 +33,6 @@ class HookRegistry:
matcher = getattr(hook, "matcher", None)
detail = getattr(hook, "command", None) or getattr(hook, "prompt", None) or getattr(hook, "url", None) or ""
suffix = f" matcher={matcher}" if matcher else ""
priority = getattr(hook, "priority", 0)
if priority:
suffix += f" priority={priority}"
lines.append(f" - {hook.type}{suffix}: {detail}")
return "\n".join(lines)
-8
View File
@@ -15,8 +15,6 @@ class CommandHookDefinition(BaseModel):
timeout_seconds: int = Field(default=30, ge=1, le=600)
matcher: str | None = None
block_on_failure: bool = False
priority: int = Field(default=0)
"""Higher priority runs first within an event; ties keep registration order."""
class PromptHookDefinition(BaseModel):
@@ -28,8 +26,6 @@ class PromptHookDefinition(BaseModel):
timeout_seconds: int = Field(default=30, ge=1, le=600)
matcher: str | None = None
block_on_failure: bool = True
priority: int = Field(default=0)
"""Higher priority runs first within an event; ties keep registration order."""
class HttpHookDefinition(BaseModel):
@@ -41,8 +37,6 @@ class HttpHookDefinition(BaseModel):
timeout_seconds: int = Field(default=30, ge=1, le=600)
matcher: str | None = None
block_on_failure: bool = False
priority: int = Field(default=0)
"""Higher priority runs first within an event; ties keep registration order."""
class AgentHookDefinition(BaseModel):
@@ -54,8 +48,6 @@ class AgentHookDefinition(BaseModel):
timeout_seconds: int = Field(default=60, ge=1, le=1200)
matcher: str | None = None
block_on_failure: bool = True
priority: int = Field(default=0)
"""Higher priority runs first within an event; ties keep registration order."""
HookDefinition = (
-7
View File
@@ -2,24 +2,17 @@
from openharness.memory.memdir import load_memory_prompt
from openharness.memory.manager import add_memory_entry, list_memory_files, remove_memory_entry
from openharness.memory.migrate import migrate_memory
from openharness.memory.paths import get_memory_entrypoint, get_project_memory_dir
from openharness.memory.scan import scan_memory_files
from openharness.memory.search import find_relevant_memories
from openharness.memory.usage import mark_memory_used
from openharness.memory.relevance import format_relevant_memories, select_relevant_memories
__all__ = [
"add_memory_entry",
"find_relevant_memories",
"format_relevant_memories",
"get_memory_entrypoint",
"get_project_memory_dir",
"list_memory_files",
"load_memory_prompt",
"mark_memory_used",
"migrate_memory",
"remove_memory_entry",
"scan_memory_files",
"select_relevant_memories",
]
-91
View File
@@ -1,91 +0,0 @@
"""Agent-scoped memory paths and snapshots."""
from __future__ import annotations
import re
import shutil
from pathlib import Path
from typing import Literal
from openharness.config.paths import get_data_dir
from openharness.memory.paths import get_project_memory_dir
AgentMemoryScope = Literal["user", "project", "local"]
MEMORY_INDEX = "MEMORY.md"
SNAPSHOT_DIR_NAME = "agent-memory-snapshots"
def sanitize_agent_type(agent_type: str) -> str:
"""Return a path-safe agent type."""
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", agent_type.strip()).strip("._") or "default"
def get_agent_memory_dir(cwd: str | Path, agent_type: str, scope: AgentMemoryScope) -> Path:
"""Return an agent memory vault for the requested scope."""
safe = sanitize_agent_type(agent_type)
if scope == "project":
return get_project_memory_dir(cwd) / "agent" / safe
if scope == "local":
return Path(cwd).resolve() / ".openharness" / "agent-memory-local" / safe
return get_data_dir() / "agent-memory" / safe
def ensure_agent_memory_vault(cwd: str | Path, agent_type: str, scope: AgentMemoryScope) -> Path:
"""Create and return an agent-scoped memory vault."""
memory_dir = get_agent_memory_dir(cwd, agent_type, scope)
memory_dir.mkdir(parents=True, exist_ok=True)
entrypoint = memory_dir / MEMORY_INDEX
if not entrypoint.exists():
entrypoint.write_text("# Memory Index\n", encoding="utf-8")
return memory_dir
def get_agent_memory_entrypoint(cwd: str | Path, agent_type: str, scope: AgentMemoryScope) -> Path:
"""Return an agent memory ``MEMORY.md`` path."""
return ensure_agent_memory_vault(cwd, agent_type, scope) / MEMORY_INDEX
def get_agent_snapshot_dir(cwd: str | Path, agent_type: str) -> Path:
"""Return the project snapshot directory for an agent type."""
return Path(cwd).resolve() / ".openharness" / SNAPSHOT_DIR_NAME / sanitize_agent_type(agent_type)
def initialize_agent_memory_from_snapshot(
cwd: str | Path,
agent_type: str,
scope: AgentMemoryScope,
*,
replace: bool = False,
) -> Path | None:
"""Initialize local agent memory from a project snapshot if present."""
snapshot_dir = get_agent_snapshot_dir(cwd, agent_type)
if not snapshot_dir.exists():
return None
target = ensure_agent_memory_vault(cwd, agent_type, scope)
if replace and target.exists():
shutil.rmtree(target)
target.mkdir(parents=True, exist_ok=True)
for src in snapshot_dir.rglob("*.md"):
rel = src.relative_to(snapshot_dir)
dest = target / rel
dest.parent.mkdir(parents=True, exist_ok=True)
if replace or not dest.exists() or _is_default_agent_index(dest):
shutil.copy2(src, dest)
return target
def _is_default_agent_index(path: Path) -> bool:
if path.name != MEMORY_INDEX or not path.exists():
return False
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
return text.startswith("# Memory Index\n")
+18 -143
View File
@@ -6,23 +6,6 @@ from pathlib import Path
from re import sub
from openharness.memory.paths import get_memory_entrypoint, get_project_memory_dir
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import (
DEFAULT_MEMORY_SCOPE,
DEFAULT_MEMORY_TYPE,
MemoryScope,
MemoryType,
SCHEMA_VERSION,
compute_memory_signature,
first_content_line,
format_datetime,
generate_memory_id,
memory_metadata_from_path,
render_memory_file,
split_memory_file,
coerce_int,
utc_now,
)
from openharness.utils.file_lock import exclusive_file_lock
from openharness.utils.fs import atomic_write_text
@@ -33,120 +16,36 @@ def _memory_lock_path(cwd: str | Path) -> Path:
def list_memory_files(cwd: str | Path) -> list[Path]:
"""List memory markdown files for the project."""
return sorted(header.path for header in scan_memory_files(cwd, max_files=None))
memory_dir = get_project_memory_dir(cwd)
return sorted(path for path in memory_dir.glob("*.md"))
def add_memory_entry(
cwd: str | Path,
title: str,
content: str,
*,
memory_type: MemoryType = DEFAULT_MEMORY_TYPE,
scope: MemoryScope = DEFAULT_MEMORY_SCOPE,
description: str = "",
tags: tuple[str, ...] = (),
) -> Path:
"""Create or refresh a memory file and append it to MEMORY.md."""
if scope == "team":
from openharness.memory.team import check_team_memory_secrets, ensure_team_memory_vault
secret_error = check_team_memory_secrets(content)
if secret_error:
raise ValueError(secret_error)
memory_dir = ensure_team_memory_vault(cwd)
else:
memory_dir = get_project_memory_dir(cwd)
def add_memory_entry(cwd: str | Path, title: str, content: str) -> Path:
"""Create a memory file and append it to MEMORY.md."""
memory_dir = get_project_memory_dir(cwd)
slug = sub(r"[^a-zA-Z0-9]+", "_", title.strip().lower()).strip("_") or "memory"
with exclusive_file_lock(memory_dir / ".memory.lock"):
category = "knowledge"
body = content.strip() + "\n"
signature = compute_memory_signature(body, memory_type, category)
existing = scan_memory_files(
cwd,
max_files=None,
include_disabled=True,
include_expired=True,
memory_dir=memory_dir,
)
duplicate = next(
(header for header in existing if _effective_signature(header.path, header.signature) == signature),
None,
)
path = duplicate.path if duplicate is not None else _next_memory_path(memory_dir, slug)
now = utc_now()
now_text = format_datetime(now)
if path.exists():
metadata, old_body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
metadata = memory_metadata_from_path(
path,
metadata,
old_body,
now=now,
source=str(metadata.get("source") or "manual"),
)
created_at = str(metadata.get("created_at") or now_text)
memory_id = str(metadata.get("id") or generate_memory_id(now))
else:
metadata = {}
created_at = now_text
memory_id = generate_memory_id(now)
path = memory_dir / f"{slug}.md"
with exclusive_file_lock(_memory_lock_path(cwd)):
atomic_write_text(path, content.strip() + "\n")
metadata.update(
{
"schema_version": SCHEMA_VERSION,
"id": memory_id,
"name": title.strip(),
"description": description.strip() or first_content_line(body) or title.strip(),
"type": str(metadata.get("type") or memory_type),
"scope": str(metadata.get("scope") or scope),
"category": str(metadata.get("category") or category),
"importance": max(coerce_int(metadata.get("importance"), default=0), 1),
"source": "manual",
"signature": signature,
"created_at": created_at,
"updated_at": now_text,
"ttl_days": metadata.get("ttl_days"),
"disabled": False,
"supersedes": metadata.get("supersedes") or [],
"tags": list(dict.fromkeys(str(tag).strip() for tag in tags if str(tag).strip())),
}
)
atomic_write_text(path, render_memory_file(metadata, body))
entrypoint = memory_dir / "MEMORY.md"
index_text = entrypoint.read_text(encoding="utf-8") if entrypoint.exists() else "# Memory Index\n"
if path.name not in index_text:
index_text = index_text.rstrip() + f"\n- [{title}]({path.name})\n"
atomic_write_text(entrypoint, index_text)
entrypoint = get_memory_entrypoint(cwd)
existing = entrypoint.read_text(encoding="utf-8") if entrypoint.exists() else "# Memory Index\n"
if path.name not in existing:
existing = existing.rstrip() + f"\n- [{title}]({path.name})\n"
atomic_write_text(entrypoint, existing)
return path
def remove_memory_entry(cwd: str | Path, name: str) -> bool:
"""Soft-delete a memory file and remove its index entry."""
matches = [
header
for header in scan_memory_files(
cwd,
max_files=None,
include_disabled=True,
include_expired=True,
)
if name in {header.path.stem, header.path.name, header.title, header.id}
]
"""Delete a memory file and remove its index entry."""
memory_dir = get_project_memory_dir(cwd)
matches = [path for path in memory_dir.glob("*.md") if path.stem == name or path.name == name]
if not matches:
return False
header = matches[0]
if header.disabled:
return False
path = header.path
path = matches[0]
with exclusive_file_lock(_memory_lock_path(cwd)):
if path.exists():
content = path.read_text(encoding="utf-8")
metadata, body, _, _ = split_memory_file(content)
metadata = memory_metadata_from_path(path, metadata, body, source="manual")
metadata["disabled"] = True
metadata["updated_at"] = format_datetime(utc_now())
atomic_write_text(path, render_memory_file(metadata, body))
path.unlink()
entrypoint = get_memory_entrypoint(cwd)
if entrypoint.exists():
@@ -157,27 +56,3 @@ def remove_memory_entry(cwd: str | Path, name: str) -> bool:
]
atomic_write_text(entrypoint, "\n".join(lines).rstrip() + "\n")
return True
def _next_memory_path(memory_dir: Path, slug: str) -> Path:
path = memory_dir / f"{slug}.md"
if not path.exists():
return path
index = 2
while True:
candidate = memory_dir / f"{slug}_{index}.md"
if not candidate.exists():
return candidate
index += 1
def _effective_signature(path: Path, existing_signature: str) -> str:
if existing_signature:
return existing_signature
try:
metadata, body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
except OSError:
return ""
memory_type = str(metadata.get("type") or "project")
category = str(metadata.get("category") or "knowledge")
return compute_memory_signature(body, memory_type, category)
+5 -23
View File
@@ -5,41 +5,23 @@ from __future__ import annotations
from pathlib import Path
from openharness.memory.paths import get_memory_entrypoint, get_project_memory_dir
from openharness.memory.schema import (
MAX_ENTRYPOINT_BYTES,
MEMORY_POLICY_LINES,
truncate_entrypoint_content,
)
def load_memory_prompt(
cwd: str | Path,
*,
max_entrypoint_lines: int = 200,
max_entrypoint_bytes: int = MAX_ENTRYPOINT_BYTES,
) -> str | None:
def load_memory_prompt(cwd: str | Path, *, max_entrypoint_lines: int = 200) -> str | None:
"""Return the memory prompt section for the current project."""
memory_dir = get_project_memory_dir(cwd)
entrypoint = get_memory_entrypoint(cwd)
lines = [
"# Memory",
f"- Persistent memory directory: {memory_dir}",
"- Use this directory to store durable project and repository context that should survive future sessions.",
"- Use this directory to store durable user or project context that should survive future sessions.",
"- Prefer concise topic files plus an index entry in MEMORY.md.",
"",
*MEMORY_POLICY_LINES,
]
if entrypoint.exists():
raw = entrypoint.read_text(encoding="utf-8", errors="replace")
view = truncate_entrypoint_content(
raw,
max_lines=max_entrypoint_lines,
max_bytes=max_entrypoint_bytes,
)
content = view.content.strip()
if content:
lines.extend(["", "## MEMORY.md", "```md", content, "```"])
content_lines = entrypoint.read_text(encoding="utf-8").splitlines()[:max_entrypoint_lines]
if content_lines:
lines.extend(["", "## MEMORY.md", "```md", *content_lines, "```"])
else:
lines.extend(
[
-137
View File
@@ -1,137 +0,0 @@
"""Migration utilities for schema-v1 memory frontmatter."""
from __future__ import annotations
import argparse
import json
import shutil
from dataclasses import asdict, dataclass
from pathlib import Path
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.schema import (
memory_metadata_from_path,
render_memory_file,
split_memory_file,
utc_now,
)
from openharness.utils.fs import atomic_write_text
@dataclass(frozen=True)
class MigrationSummary:
"""Summary returned by a memory schema migration run."""
scanned: int
changed: int
unchanged: int
failed: int
dry_run: bool
backup_dir: str
changed_files: tuple[str, ...]
failed_files: tuple[str, ...]
def as_dict(self) -> dict[str, object]:
return asdict(self)
def migrate_memory(
cwd: str | Path,
*,
memory_dir: str | Path | None = None,
default_type: str = "project",
default_category: str = "knowledge",
apply: bool = False,
) -> MigrationSummary:
"""Backfill schema-v1 frontmatter for top-level memory markdown files."""
root = Path(memory_dir).expanduser().resolve() if memory_dir is not None else get_project_memory_dir(cwd)
root.mkdir(parents=True, exist_ok=True)
files = sorted(path for path in root.glob("*.md") if path.name != "MEMORY.md")
changed_payloads: list[tuple[Path, str]] = []
failed_files: list[str] = []
seen_ids: set[str] = set()
now = utc_now()
for path in files:
try:
content = path.read_text(encoding="utf-8")
metadata, body, _, _ = split_memory_file(content)
migrated = memory_metadata_from_path(
path,
metadata,
body,
now=now,
source="migration",
default_type=default_type,
default_category=default_category,
seen_ids=seen_ids,
)
rendered = render_memory_file(migrated, body)
if rendered != content:
changed_payloads.append((path, rendered))
except OSError:
failed_files.append(path.name)
backup_dir = ""
if apply and changed_payloads:
backup_path = _create_migration_backup(root)
backup_dir = str(backup_path)
for path, rendered in changed_payloads:
atomic_write_text(path, rendered)
changed_files = tuple(path.name for path, _ in changed_payloads)
return MigrationSummary(
scanned=len(files),
changed=len(changed_payloads),
unchanged=len(files) - len(changed_payloads) - len(failed_files),
failed=len(failed_files),
dry_run=not apply,
backup_dir=backup_dir,
changed_files=changed_files,
failed_files=tuple(failed_files),
)
def main(argv: list[str] | None = None) -> int:
"""Command-line entrypoint for one-off memory migrations."""
parser = argparse.ArgumentParser(description="Backfill OpenHarness memory schema metadata.")
parser.add_argument("--cwd", default=".", help="Project cwd whose memory store should be migrated.")
parser.add_argument("--memory-dir", default=None, help="Explicit memory directory to migrate.")
parser.add_argument("--default-type", default="project", help="Type for legacy files without one.")
parser.add_argument("--default-category", default="knowledge", help="Category for legacy files without one.")
parser.add_argument("--dry-run", action="store_true", help="Report files that would change.")
parser.add_argument("--apply", action="store_true", help="Write migrated files and create a backup.")
args = parser.parse_args(argv)
if args.dry_run == args.apply:
parser.error("pass exactly one of --dry-run or --apply")
summary = migrate_memory(
args.cwd,
memory_dir=args.memory_dir,
default_type=args.default_type,
default_category=args.default_category,
apply=args.apply,
)
print(json.dumps(summary.as_dict(), indent=2, ensure_ascii=False))
return 0
def _create_migration_backup(memory_dir: Path) -> Path:
timestamp = utc_now().strftime("%Y%m%d-%H%M%S")
backup_dir = memory_dir / "backups" / f"migration-{timestamp}"
suffix = 2
while backup_dir.exists():
backup_dir = memory_dir / "backups" / f"migration-{timestamp}-{suffix}"
suffix += 1
backup_dir.mkdir(parents=True, exist_ok=False)
for path in sorted(memory_dir.glob("*.md")):
shutil.copy2(path, backup_dir / path.name)
usage_index = memory_dir / "usage_index.json"
if usage_index.exists():
shutil.copy2(usage_index, backup_dir / usage_index.name)
return backup_dir
if __name__ == "__main__":
raise SystemExit(main())
-145
View File
@@ -1,145 +0,0 @@
"""Relevant memory selection and formatting."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import memory_age_label, memory_freshness_text
from openharness.memory.search import find_relevant_memories
from openharness.memory.types import MemoryHeader
@dataclass(frozen=True)
class RelevantMemory:
"""A memory selected for prompt injection."""
header: MemoryHeader
freshness: str = ""
MemorySelector = Callable[[str, list[MemoryHeader]], list[str]]
def build_memory_manifest(headers: Iterable[MemoryHeader]) -> str:
"""Render a compact manifest for selector prompts and diagnostics."""
lines: list[str] = []
for header in headers:
prefix = f"[{header.memory_type or 'memory'}]"
bits = [
prefix,
header.relative_path or header.path.name,
f"({memory_age_label(header.modified_at)})",
]
if header.description:
bits.append(f"- {header.description}")
lines.append(" ".join(bits))
return "\n".join(lines)
def select_relevant_memories(
query: str,
cwd: str | Path,
*,
max_results: int = 5,
already_surfaced: set[str] | None = None,
selector: MemorySelector | None = None,
) -> list[RelevantMemory]:
"""Return relevant memories with duplicate and freshness handling.
``selector`` is an optional side-query style reranker. It receives the query
and a heuristic shortlist, and returns relative paths in desired order.
"""
surfaced = already_surfaced or set()
heuristic = [
header
for header in find_relevant_memories(query, cwd, max_results=max(10, max_results * 3))
if (header.relative_path or str(header.path)) not in surfaced
]
selected = _apply_selector(query, heuristic, selector=selector, max_results=max_results)
result: list[RelevantMemory] = []
for header in selected[:max_results]:
result.append(RelevantMemory(header=header, freshness=memory_freshness_text(header.modified_at)))
return result
def select_manifest_memories(
query: str,
cwd: str | Path,
*,
max_results: int = 5,
selector: MemorySelector | None = None,
) -> list[RelevantMemory]:
"""Select from the full manifest instead of heuristic matches only."""
headers = scan_memory_files(cwd, max_files=200)
selected = _apply_selector(query, headers, selector=selector, max_results=max_results)
return [
RelevantMemory(header=header, freshness=memory_freshness_text(header.modified_at))
for header in selected[:max_results]
]
def format_relevant_memories(memories: Iterable[RelevantMemory], *, max_chars: int = 8000) -> str:
"""Render selected memories for prompt context."""
lines = ["# Relevant Memories"]
for item in memories:
header = item.header
content = header.path.read_text(encoding="utf-8", errors="replace").strip()
if item.freshness:
lines.extend(["", f"## {header.relative_path or header.path.name}", f"> {item.freshness}"])
else:
lines.extend(["", f"## {header.relative_path or header.path.name}"])
lines.extend(["```md", content[:max_chars], "```"])
return "\n".join(lines)
def json_selector_from_text(text: str) -> list[str]:
"""Parse a selector response as either JSON list or newline paths."""
stripped = text.strip()
if not stripped:
return []
try:
payload = json.loads(stripped)
except json.JSONDecodeError:
return [line.strip("- ").strip() for line in stripped.splitlines() if line.strip()]
if isinstance(payload, list):
return [str(item).strip() for item in payload if str(item).strip()]
if isinstance(payload, dict) and isinstance(payload.get("paths"), list):
return [str(item).strip() for item in payload["paths"] if str(item).strip()]
return []
def _apply_selector(
query: str,
headers: list[MemoryHeader],
*,
selector: MemorySelector | None,
max_results: int,
) -> list[MemoryHeader]:
if not headers or selector is None:
return headers[:max_results]
requested = selector(query, headers)
by_path = {header.relative_path or header.path.name: header for header in headers}
selected: list[MemoryHeader] = []
seen: set[str] = set()
for path in requested:
header = by_path.get(path)
if header is None or path in seen:
continue
selected.append(header)
seen.add(path)
if len(selected) < max_results:
selected.extend(
header
for header in headers
if (header.relative_path or header.path.name) not in seen
)
return selected[:max_results]
+27 -60
View File
@@ -5,28 +5,12 @@ from __future__ import annotations
from pathlib import Path
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.schema import (
coerce_bool,
coerce_int,
coerce_optional_int,
coerce_str_list,
first_content_line,
is_memory_expired,
split_memory_file,
)
from openharness.memory.types import MemoryHeader
def scan_memory_files(
cwd: str | Path,
*,
max_files: int | None = 50,
include_disabled: bool = False,
include_expired: bool = False,
memory_dir: str | Path | None = None,
) -> list[MemoryHeader]:
def scan_memory_files(cwd: str | Path, *, max_files: int = 50) -> list[MemoryHeader]:
"""Return memory headers sorted by newest first."""
memory_dir = Path(memory_dir) if memory_dir is not None else get_project_memory_dir(cwd)
memory_dir = get_project_memory_dir(cwd)
headers: list[MemoryHeader] = []
for path in memory_dir.glob("*.md"):
if path.name == "MEMORY.md":
@@ -36,40 +20,45 @@ def scan_memory_files(
except OSError:
continue
header = _parse_memory_file(path, text)
if header.disabled and not include_disabled:
continue
if is_memory_expired(_metadata_from_header(header)) and not include_expired:
continue
headers.append(header)
headers.sort(key=lambda item: item.modified_at, reverse=True)
if max_files is None:
return headers
return headers[:max_files]
def _parse_memory_file(path: Path, content: str) -> MemoryHeader:
"""Parse a memory file, extracting YAML frontmatter when present."""
metadata, body, _, _ = split_memory_file(content)
lines = body.splitlines()
lines = content.splitlines()
title = path.stem
description = ""
memory_type = ""
if metadata.get("name"):
title = str(metadata["name"])
if metadata.get("description"):
description = str(metadata["description"])
if metadata.get("type"):
memory_type = str(metadata["type"])
body_start = 0
# Parse YAML frontmatter (--- ... ---)
if lines and lines[0].strip() == "---":
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
for fm_line in lines[1:i]:
key, _, value = fm_line.partition(":")
key = key.strip()
value = value.strip().strip("'\"")
if not value:
continue
if key == "name":
title = value
elif key == "description":
description = value
elif key == "type":
memory_type = value
body_start = i + 1
break
# Fallback: first non-empty, non-frontmatter line as description
desc_line_idx: int | None = None
if not description:
fallback = first_content_line("\n".join(lines[:10]))
if fallback:
description = fallback
for idx, line in enumerate(lines[:10]):
for idx, line in enumerate(lines[body_start:body_start + 10], body_start):
stripped = line.strip()
if stripped[:200] == description:
if stripped and stripped != "---" and not stripped.startswith("#"):
description = stripped[:200]
desc_line_idx = idx
break
@@ -77,7 +66,7 @@ def _parse_memory_file(path: Path, content: str) -> MemoryHeader:
# line already used as description so search scoring stays consistent.
body_lines = [
line.strip()
for idx, line in enumerate(lines)
for idx, line in enumerate(lines[body_start:], body_start)
if line.strip()
and not line.strip().startswith("#")
and idx != desc_line_idx
@@ -91,26 +80,4 @@ def _parse_memory_file(path: Path, content: str) -> MemoryHeader:
modified_at=path.stat().st_mtime,
memory_type=memory_type,
body_preview=body_preview,
id=str(metadata.get("id") or ""),
schema_version=coerce_int(metadata.get("schema_version"), default=0),
category=str(metadata.get("category") or ""),
importance=coerce_int(metadata.get("importance"), default=0),
source=str(metadata.get("source") or ""),
signature=str(metadata.get("signature") or ""),
created_at=str(metadata.get("created_at") or ""),
updated_at=str(metadata.get("updated_at") or ""),
ttl_days=coerce_optional_int(metadata.get("ttl_days")),
disabled=coerce_bool(metadata.get("disabled"), default=False),
supersedes=coerce_str_list(metadata.get("supersedes")),
relative_path=path.name,
tags=coerce_str_list(metadata.get("tags")),
frontmatter=metadata,
)
def _metadata_from_header(header: MemoryHeader) -> dict[str, object]:
return {
"created_at": header.created_at,
"updated_at": header.updated_at,
"ttl_days": header.ttl_days,
}
-443
View File
@@ -1,443 +0,0 @@
"""Structured memory metadata helpers."""
from __future__ import annotations
import hashlib
import json
import re
import secrets
import string
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Literal
import yaml
SCHEMA_VERSION = 1
MemoryType = Literal["user", "feedback", "project", "reference"]
MemoryScope = Literal["private", "project", "team"]
MEMORY_TYPES: tuple[MemoryType, ...] = ("user", "feedback", "project", "reference")
MEMORY_SCOPES: tuple[MemoryScope, ...] = ("private", "project", "team")
DEFAULT_MEMORY_TYPE: MemoryType = "project"
DEFAULT_MEMORY_SCOPE: MemoryScope = "project"
MAX_ENTRYPOINT_LINES = 200
MAX_ENTRYPOINT_BYTES = 25_000
MAX_MANIFEST_FILES = 200
FRONTMATTER_FIELDS = (
"schema_version",
"id",
"name",
"description",
"type",
"scope",
"category",
"importance",
"source",
"signature",
"created_at",
"updated_at",
"ttl_days",
"disabled",
"supersedes",
"tags",
)
@dataclass(frozen=True)
class EntrypointView:
"""A bounded view of ``MEMORY.md`` plus truncation diagnostics."""
content: str
was_truncated: bool
reason: str = ""
def utc_now() -> datetime:
"""Return the current UTC time without sub-second noise."""
return datetime.now(timezone.utc).replace(microsecond=0)
def format_datetime(value: datetime) -> str:
"""Format a datetime as an ISO-8601 UTC string."""
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_datetime(value: object) -> datetime | None:
"""Parse an ISO datetime value used in memory frontmatter."""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
if not isinstance(value, str) or not value.strip():
return None
raw = value.strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def normalize_memory_content(text: str) -> str:
"""Normalize memory content for deterministic signatures."""
lowered = text.lower()
collapsed = re.sub(r"\s+", " ", lowered)
punctuation_table = str.maketrans("", "", string.punctuation)
return collapsed.translate(punctuation_table).strip()
def compute_memory_signature(content: str, memory_type: str, category: str) -> str:
"""Compute a deterministic content signature for duplicate detection."""
normalized = normalize_memory_content(content)
payload = f"{normalized}|{memory_type.strip().lower()}|{category.strip().lower()}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def parse_memory_type(raw: Any, *, default: MemoryType | None = None) -> MemoryType | None:
"""Parse a frontmatter ``type`` value into the canonical runtime taxonomy."""
if isinstance(raw, str):
value = raw.strip().lower()
if value in MEMORY_TYPES:
return value # type: ignore[return-value]
if value in {"note", "memory", "core", "knowledge"}:
return default
return default
def parse_memory_scope(raw: Any, *, default: MemoryScope | None = None) -> MemoryScope | None:
"""Parse a frontmatter ``scope`` value into the canonical scope taxonomy."""
if isinstance(raw, str):
value = raw.strip().lower()
if value in MEMORY_SCOPES:
return value # type: ignore[return-value]
if value in {"personal", "user"}:
return "private"
if value in {"shared"}:
return "team"
return default
def truncate_entrypoint_content(
raw: str,
*,
max_lines: int = MAX_ENTRYPOINT_LINES,
max_bytes: int = MAX_ENTRYPOINT_BYTES,
) -> EntrypointView:
"""Bound ``MEMORY.md`` by line count and UTF-8 byte count."""
lines = raw.splitlines()
was_line_truncated = len(lines) > max_lines
text = "\n".join(lines[:max_lines])
encoded = text.encode("utf-8")
was_byte_truncated = len(encoded) > max_bytes
if was_byte_truncated:
encoded = encoded[:max_bytes]
text = encoded.decode("utf-8", errors="ignore")
cut_at = text.rfind("\n")
if cut_at > 0:
text = text[:cut_at]
if raw.endswith("\n") and not text.endswith("\n"):
text += "\n"
if not was_line_truncated and not was_byte_truncated:
return EntrypointView(content=text, was_truncated=False)
reason = (
f"{len(raw.encode('utf-8'))} bytes (limit: {max_bytes})"
if was_byte_truncated
else f"{len(lines)} lines (limit: {max_lines})"
)
warning = (
f"\n\n> WARNING: MEMORY.md is {reason}. Only part of it was loaded. "
"Keep index entries one line and move detail into topic notes.\n"
)
return EntrypointView(content=text.rstrip() + warning, was_truncated=True, reason=reason)
def memory_age_days(mtime: float, *, now: float | None = None) -> int:
"""Return floor-rounded days elapsed since a file modification time."""
import time
current = time.time() if now is None else now
return max(0, int((current - mtime) // 86_400))
def memory_age_label(mtime: float, *, now: float | None = None) -> str:
"""Return a model-friendly age label."""
days = memory_age_days(mtime, now=now)
if days == 0:
return "today"
if days == 1:
return "yesterday"
return f"{days} days ago"
def memory_freshness_text(mtime: float, *, now: float | None = None) -> str:
"""Return a staleness warning for older memories."""
days = memory_age_days(mtime, now=now)
if days <= 1:
return ""
return (
f"This memory is {days} days old. Memories are point-in-time observations; "
"verify claims against the current project state before treating them as facts."
)
def path_is_relative_to(path: str | Path, root: str | Path) -> bool:
"""Compatibility helper for containment checks."""
try:
Path(path).resolve().relative_to(Path(root).resolve())
except ValueError:
return False
return True
MEMORY_POLICY_LINES: tuple[str, ...] = (
"## Durable memory policy",
"- Store durable memory only when the information is not cheaply derivable from current files, docs, git history, or tool output.",
"- Use `type: user|feedback|project|reference` and optional `scope: private|project|team` frontmatter.",
"- `MEMORY.md` is an index, not a memory body. Keep each pointer one line.",
"- Update or remove stale contradictions instead of duplicating notes.",
"- If the user says to ignore memory, proceed as if no memory was loaded and do not cite, apply, or mention memory contents.",
"- Memory can be stale. Verify remembered project/code state against current files before acting on it.",
"- Do not save secrets, credentials, private personal context in team memory, or temporary task chatter.",
)
def generate_memory_id(now: datetime | None = None) -> str:
"""Generate a stable-looking memory id for a new memory file."""
timestamp = format_datetime(now or utc_now()).replace("-", "").replace(":", "")
timestamp = timestamp.replace("T", "-").replace("Z", "")
return f"mem-{timestamp}-{secrets.token_hex(4)}"
def split_memory_file(content: str) -> tuple[dict[str, Any], str, int, bool]:
"""Split a memory file into frontmatter metadata and body text.
Returns ``(metadata, body, body_start_line, has_closed_frontmatter)``.
Unclosed frontmatter is treated as body content after the opening delimiter.
"""
lines = content.splitlines(keepends=True)
if not lines or lines[0].strip() != "---":
return {}, content, 0, False
for idx, line in enumerate(lines[1:], 1):
if line.strip() == "---":
raw_frontmatter = "".join(lines[1:idx])
metadata = _load_frontmatter(raw_frontmatter)
return metadata, "".join(lines[idx + 1 :]), idx + 1, True
return {}, "".join(lines[1:]), 1, False
def render_memory_file(metadata: dict[str, Any], body: str) -> str:
"""Render metadata and body as a memory markdown file."""
frontmatter = render_frontmatter(metadata)
normalized_body = body.lstrip("\n")
if normalized_body and not normalized_body.endswith("\n"):
normalized_body += "\n"
return f"---\n{frontmatter}---\n\n{normalized_body}"
def render_frontmatter(metadata: dict[str, Any]) -> str:
"""Render memory frontmatter in a stable field order."""
ordered: list[tuple[str, Any]] = []
for field in FRONTMATTER_FIELDS:
if field in metadata:
ordered.append((field, metadata[field]))
for key, value in metadata.items():
if key not in FRONTMATTER_FIELDS:
ordered.append((key, value))
return "".join(f"{key}: {_format_yaml_value(value)}\n" for key, value in ordered)
def is_disabled_metadata(metadata: dict[str, Any]) -> bool:
"""Return whether a memory metadata object marks the file disabled."""
return _as_bool(metadata.get("disabled"), default=False)
def is_memory_expired(metadata: dict[str, Any], *, now: datetime | None = None) -> bool:
"""Return whether a memory should be hidden because its TTL has elapsed."""
ttl_days = _as_optional_int(metadata.get("ttl_days"))
if ttl_days is None or ttl_days <= 0:
return False
base_time = parse_datetime(metadata.get("updated_at")) or parse_datetime(metadata.get("created_at"))
if base_time is None:
return False
return (now or utc_now()) >= base_time + timedelta(days=ttl_days)
def coerce_int(value: object, *, default: int = 0) -> int:
"""Coerce a metadata value to int."""
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
def coerce_optional_int(value: object) -> int | None:
"""Coerce a metadata value to optional int."""
return _as_optional_int(value)
def coerce_bool(value: object, *, default: bool = False) -> bool:
"""Coerce a metadata value to bool."""
return _as_bool(value, default=default)
def coerce_str_list(value: object) -> tuple[str, ...]:
"""Coerce a metadata value to a tuple of strings."""
if isinstance(value, str):
return (value,) if value else ()
if isinstance(value, (list, tuple)):
return tuple(str(item) for item in value if str(item))
return ()
def memory_metadata_from_path(
path: Path,
metadata: dict[str, Any],
body: str,
*,
now: datetime | None = None,
source: str = "migration",
default_type: str = "project",
default_category: str = "knowledge",
seen_ids: set[str] | None = None,
) -> dict[str, Any]:
"""Return complete schema-v1 metadata while preserving existing values."""
updated = dict(metadata)
timestamp = _mtime_timestamp(path)
created_at = str(updated.get("created_at") or timestamp)
updated_at = str(updated.get("updated_at") or timestamp)
memory_type = str(updated.get("type") or default_type)
category = str(updated.get("category") or default_category)
memory_id = str(updated.get("id") or "")
if not memory_id or (seen_ids is not None and memory_id in seen_ids):
memory_id = _generate_unique_memory_id(now=now, seen_ids=seen_ids)
if seen_ids is not None:
seen_ids.add(memory_id)
updated["schema_version"] = coerce_int(updated.get("schema_version"), default=SCHEMA_VERSION)
updated["id"] = memory_id
updated["name"] = str(updated.get("name") or path.stem)
updated["description"] = str(updated.get("description") or first_content_line(body) or path.stem)
updated["type"] = memory_type
updated["category"] = category
updated["importance"] = coerce_int(updated.get("importance"), default=0)
updated["source"] = str(updated.get("source") or source)
updated["signature"] = str(
updated.get("signature") or compute_memory_signature(body, memory_type, category)
)
updated["created_at"] = created_at
updated["updated_at"] = updated_at
updated["ttl_days"] = coerce_optional_int(updated.get("ttl_days"))
updated["disabled"] = coerce_bool(updated.get("disabled"), default=False)
updated["supersedes"] = list(coerce_str_list(updated.get("supersedes")))
return updated
def first_content_line(body: str, *, limit: int = 200) -> str:
"""Return the first useful body line for descriptions."""
for line in body.splitlines():
stripped = line.strip()
if stripped and stripped != "---" and not stripped.startswith("#"):
return stripped[:limit]
return ""
def _load_frontmatter(raw_frontmatter: str) -> dict[str, Any]:
try:
loaded = yaml.safe_load(raw_frontmatter) or {}
except yaml.YAMLError:
return {}
if not isinstance(loaded, dict):
return {}
return {str(key): value for key, value in loaded.items()}
def _format_yaml_value(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, (list, tuple)):
return json.dumps(list(value), ensure_ascii=False)
return json.dumps(str(value), ensure_ascii=False)
def _mtime_timestamp(path: Path) -> str:
try:
modified = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
except OSError:
modified = utc_now()
return format_datetime(modified)
def _generate_unique_memory_id(
*,
now: datetime | None = None,
seen_ids: set[str] | None = None,
) -> str:
while True:
memory_id = generate_memory_id(now=now)
if seen_ids is None or memory_id not in seen_ids:
return memory_id
def _as_bool(value: object, *, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
if value is None:
return default
return bool(value)
def _as_optional_int(value: object) -> int | None:
if value is None:
return None
if isinstance(value, str) and not value.strip():
return None
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
+2 -24
View File
@@ -3,13 +3,10 @@
from __future__ import annotations
import re
from datetime import timedelta
from pathlib import Path
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import parse_datetime, utc_now
from openharness.memory.types import MemoryHeader
from openharness.memory.usage import get_memory_usage
def find_relevant_memories(
@@ -35,15 +32,8 @@ def find_relevant_memories(
# Metadata matches are weighted 2x; body matches 1x.
meta_hits = sum(1 for t in tokens if t in meta)
body_hits = sum(1 for t in tokens if t in body)
usage = get_memory_usage(cwd, header.id, memory_dir=header.path.parent)
score = (
meta_hits * 2.0
+ body_hits
+ header.importance * 0.4
+ min(int(usage["use_count"]), 5) * 0.1
+ _recency_boost(header)
)
if meta_hits or body_hits:
score = meta_hits * 2.0 + body_hits
if score > 0:
scored.append((score, header))
scored.sort(key=lambda item: (-item[0], -item[1].modified_at))
@@ -57,15 +47,3 @@ def _tokenize(text: str) -> set[str]:
# Han ideographs (each character carries independent meaning)
han_chars = set(re.findall(r"[\u4e00-\u9fff\u3400-\u4dbf]", text))
return ascii_tokens | han_chars
def _recency_boost(header: MemoryHeader) -> float:
timestamp = parse_datetime(header.updated_at) or parse_datetime(header.created_at)
if timestamp is None:
return 0.0
age = utc_now() - timestamp
if age <= timedelta(days=14):
return 0.3
if age <= timedelta(days=30):
return 0.1
return 0.0
-90
View File
@@ -1,90 +0,0 @@
"""Local team-memory vault helpers and safety guards."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.schema import path_is_relative_to
TEAM_DIR_NAME = "team"
MEMORY_INDEX = "MEMORY.md"
@dataclass(frozen=True)
class SecretMatch:
"""A possible secret found in shared memory content."""
rule_id: str
label: str
SECRET_RULES: tuple[tuple[str, str, re.Pattern[str]], ...] = (
("private-key", "private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("aws-access-key", "AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("github-token", "GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")),
("openai-key", "OpenAI API key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
("anthropic-key", "Anthropic API key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b")),
("generic-secret", "secret assignment", re.compile(r"(?i)\b(secret|token|api[_-]?key|password)\s*[:=]\s*['\"]?[^'\"\s]{12,}")),
)
def get_team_memory_dir(cwd: str | Path) -> Path:
"""Return the project-local shared team memory vault."""
return get_project_memory_dir(cwd) / TEAM_DIR_NAME
def ensure_team_memory_vault(cwd: str | Path) -> Path:
"""Create and return the team memory vault."""
team_dir = get_team_memory_dir(cwd)
team_dir.mkdir(parents=True, exist_ok=True)
entrypoint = team_dir / MEMORY_INDEX
if not entrypoint.exists():
entrypoint.write_text("# Memory Index\n", encoding="utf-8")
return team_dir
def validate_team_memory_write_path(cwd: str | Path, candidate: str | Path) -> tuple[Path | None, str | None]:
"""Validate a write target against traversal and symlink escape."""
team_dir = ensure_team_memory_vault(cwd).resolve()
path = Path(candidate).expanduser()
if not path.is_absolute():
path = team_dir / path
resolved = path.resolve()
if not path_is_relative_to(resolved, team_dir):
return None, f"Path escapes team memory directory: {candidate}"
parent = resolved.parent
deepest = parent
while not deepest.exists() and deepest != deepest.parent:
deepest = deepest.parent
if deepest.exists() and not path_is_relative_to(deepest.resolve(), team_dir):
return None, f"Path escapes team memory directory via symlink: {candidate}"
return resolved, None
def scan_for_secrets(content: str) -> list[SecretMatch]:
"""Return possible secrets in content without exposing matched values."""
matches: list[SecretMatch] = []
for rule_id, label, pattern in SECRET_RULES:
if pattern.search(content):
matches.append(SecretMatch(rule_id=rule_id, label=label))
return matches
def check_team_memory_secrets(content: str) -> str | None:
"""Return an error message when shared memory content appears sensitive."""
matches = scan_for_secrets(content)
if not matches:
return None
labels = ", ".join(match.label for match in matches)
return (
f"Content contains potential secrets ({labels}) and cannot be written to team memory. "
"Team memory is shared with project collaborators."
)
+1 -16
View File
@@ -2,9 +2,8 @@
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
@@ -17,17 +16,3 @@ class MemoryHeader:
modified_at: float
memory_type: str = ""
body_preview: str = ""
id: str = ""
schema_version: int = 0
category: str = ""
importance: int = 0
source: str = ""
signature: str = ""
created_at: str = ""
updated_at: str = ""
ttl_days: int | None = None
disabled: bool = False
supersedes: tuple[str, ...] = ()
relative_path: str = ""
tags: tuple[str, ...] = field(default_factory=tuple)
frontmatter: dict[str, Any] = field(default_factory=dict)
-153
View File
@@ -1,153 +0,0 @@
"""Usage index for recalled memory entries."""
from __future__ import annotations
import json
from datetime import timedelta
from pathlib import Path
from typing import Any
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import format_datetime, parse_datetime, utc_now
from openharness.memory.types import MemoryHeader
from openharness.utils.file_lock import exclusive_file_lock
from openharness.utils.fs import atomic_write_text
USAGE_INDEX_NAME = "usage_index.json"
STALE_UNUSED_DAYS = 60
STALE_MAX_IMPORTANCE = 1
def get_usage_index_path(cwd: str | Path, *, memory_dir: str | Path | None = None) -> Path:
"""Return the usage index path for a memory store."""
root = Path(memory_dir) if memory_dir is not None else get_project_memory_dir(cwd)
root.mkdir(parents=True, exist_ok=True)
return root / USAGE_INDEX_NAME
def load_usage_index(cwd: str | Path, *, memory_dir: str | Path | None = None) -> dict[str, Any]:
"""Load usage index data, returning an empty index for invalid files."""
path = get_usage_index_path(cwd, memory_dir=memory_dir)
if not path.exists():
return _empty_index()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return _empty_index()
if not isinstance(data, dict):
return _empty_index()
memories = data.get("memories")
if not isinstance(memories, dict):
memories = {}
normalized = _empty_index()
normalized["memories"] = {
str(memory_id): _normalize_usage_record(record)
for memory_id, record in memories.items()
if isinstance(record, dict)
}
return normalized
def save_usage_index(
cwd: str | Path,
index: dict[str, Any],
*,
memory_dir: str | Path | None = None,
) -> None:
"""Persist usage index data atomically."""
path = get_usage_index_path(cwd, memory_dir=memory_dir)
payload = json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n"
atomic_write_text(path, payload)
def get_memory_usage(
cwd: str | Path,
memory_id: str,
*,
memory_dir: str | Path | None = None,
) -> dict[str, Any]:
"""Return usage data for a memory id."""
if not memory_id:
return _normalize_usage_record({})
index = load_usage_index(cwd, memory_dir=memory_dir)
record = index["memories"].get(memory_id, {})
return _normalize_usage_record(record)
def mark_memory_used(
cwd: str | Path,
memories: list[MemoryHeader],
*,
memory_dir: str | Path | None = None,
) -> None:
"""Record that memory entries were recalled into a runtime prompt."""
usable = [header for header in memories if header.id]
if not usable:
return
resolved_memory_dir = Path(memory_dir) if memory_dir is not None else usable[0].path.parent
lock_path = resolved_memory_dir / ".usage_index.lock"
with exclusive_file_lock(lock_path):
index = load_usage_index(cwd, memory_dir=resolved_memory_dir)
now = format_datetime(utc_now())
for header in usable:
record = _normalize_usage_record(index["memories"].get(header.id, {}))
record["use_count"] = int(record["use_count"]) + 1
record["last_used_at"] = now
record["path"] = header.path.name
index["memories"][header.id] = record
save_usage_index(cwd, index, memory_dir=resolved_memory_dir)
def find_stale_memory_candidates(
cwd: str | Path,
*,
memory_dir: str | Path | None = None,
) -> list[MemoryHeader]:
"""Return low-value unused memories that auto-dream should review for pruning."""
resolved_memory_dir = Path(memory_dir) if memory_dir is not None else None
headers = scan_memory_files(
cwd,
max_files=None,
include_disabled=False,
include_expired=False,
memory_dir=resolved_memory_dir,
)
now = utc_now()
candidates: list[MemoryHeader] = []
for header in headers:
if header.importance > STALE_MAX_IMPORTANCE:
continue
usage = get_memory_usage(cwd, header.id, memory_dir=resolved_memory_dir or header.path.parent)
if int(usage["use_count"]) > 0:
continue
updated_at = parse_datetime(header.updated_at) or parse_datetime(header.created_at)
if updated_at is None:
continue
if now - updated_at >= timedelta(days=STALE_UNUSED_DAYS):
candidates.append(header)
candidates.sort(key=lambda item: (item.importance, item.updated_at or "", item.path.name))
return candidates
def _empty_index() -> dict[str, Any]:
return {"version": 1, "memories": {}}
def _normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
use_count = record.get("use_count", 0)
try:
use_count = max(0, int(use_count))
except (TypeError, ValueError):
use_count = 0
return {
"last_used_at": str(record.get("last_used_at") or ""),
"use_count": use_count,
"path": str(record.get("path") or ""),
}
+2 -1
View File
@@ -37,7 +37,7 @@ def detect_platform(
if system == "darwin":
return "macos"
if system in {"windows", "win32"}:
if system == "windows":
return "windows"
if system == "linux":
if "microsoft" in kernel_release or env_map.get("WSL_DISTRO_NAME") or env_map.get("WSL_INTEROP"):
@@ -84,3 +84,4 @@ def get_platform_capabilities(platform_name: PlatformName | None = None) -> Plat
supports_sandbox_runtime=False,
supports_docker_sandbox=False,
)
+15 -36
View File
@@ -12,11 +12,8 @@ from openharness.config.paths import (
)
from openharness.config.settings import Settings
from openharness.coordinator.coordinator_mode import get_coordinator_system_prompt, is_coordinator_mode
from openharness.memory import load_memory_prompt
from openharness.memory.relevance import format_relevant_memories, select_relevant_memories
from openharness.memory.usage import mark_memory_used
from openharness.memory import find_relevant_memories, load_memory_prompt
from openharness.personalization.rules import load_local_rules
from openharness.permissions.modes import PermissionMode
from openharness.prompts.claudemd import load_claude_md_prompt
from openharness.prompts.system_prompt import build_system_prompt
from openharness.skills.loader import load_skill_registry
@@ -77,28 +74,6 @@ def _build_delegation_section() -> str:
)
def _build_permission_mode_section(settings: Settings) -> str:
"""Build current permission-mode guidance for the model."""
mode = settings.permission.mode
if mode == PermissionMode.PLAN:
guidance = (
"Plan mode is enabled. Treat this session as read-only planning and analysis. "
"Do not call mutating tools such as file writes, edits, package installs, "
"state-changing shell commands, or task-spawning actions unless the user exits plan mode."
)
elif mode == PermissionMode.FULL_AUTO:
guidance = (
"Full-auto permission mode is enabled. You may use mutating tools when they are necessary "
"for the user's request, while still keeping changes scoped and intentional."
)
else:
guidance = (
"Default permission mode is enabled. Read-only tools can run directly; mutating tools "
"may require explicit user approval."
)
return f"# Current Permission Mode\n{guidance}"
def build_runtime_system_prompt(
settings: Settings,
*,
@@ -117,8 +92,6 @@ def build_runtime_system_prompt(
if not is_coordinator_mode() and settings.system_prompt is None:
sections[0] = build_system_prompt(cwd=str(cwd))
sections.append(_build_permission_mode_section(settings))
if settings.fast_mode:
sections.append(
"# Session Mode\nFast mode is enabled. Prefer concise replies, minimal tool use, and quicker progress over exhaustive exploration."
@@ -165,23 +138,29 @@ def build_runtime_system_prompt(
memory_section = load_memory_prompt(
cwd,
max_entrypoint_lines=settings.memory.max_entrypoint_lines,
max_entrypoint_bytes=settings.memory.max_entrypoint_bytes,
)
if memory_section:
sections.append(memory_section)
if latest_user_prompt:
relevant = select_relevant_memories(
relevant = find_relevant_memories(
latest_user_prompt,
cwd,
max_results=settings.memory.max_files,
)
if relevant:
try:
headers = [item.header for item in relevant]
mark_memory_used(cwd, headers, memory_dir=headers[0].path.parent)
except OSError:
pass
sections.append(format_relevant_memories(relevant))
lines = ["# Relevant Memories"]
for header in relevant:
content = header.path.read_text(encoding="utf-8", errors="replace").strip()
lines.extend(
[
"",
f"## {header.path.name}",
"```md",
content[:8000],
"```",
]
)
sections.append("\n".join(lines))
return "\n\n".join(section for section in sections if section.strip())
@@ -60,9 +60,6 @@ Use these categories:
- Create at most 2 new markdown files in one dream.
- If a topic is transient, prefer `recent_work.md` or an existing status file over a new topic file.
- Do not move personal/business context into project memory; keep sensitive personal context in personal memory only.
- Every top-level memory file must include schema-v1 frontmatter with:
`schema_version`, `id`, `name`, `description`, `type`, `category`, `importance`, `source`,
`signature`, `created_at`, `updated_at`, `ttl_days`, `disabled`, and `supersedes`.
---
@@ -99,8 +96,6 @@ Update `{ENTRYPOINT_NAME}` so it stays under {MAX_ENTRYPOINT_LINES} lines and re
- Each entry should be one concise line: `- [Title](file.md): one-line hook`.
- Remove pointers to memories that are stale, wrong, or superseded.
- For stale, wrong, or superseded memory files, set `disabled: true`; do not delete files.
- Treat usage-based stale candidates as review candidates, not automatic deletion instructions.
- Add pointers to newly important memories.
- Resolve contradictions if multiple files disagree.
@@ -10,7 +10,6 @@ from pathlib import Path
from openharness.config.settings import Settings
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.usage import find_stale_memory_candidates
from openharness.services.autodream.backup import create_memory_backup, diff_memory_dirs
from openharness.services.autodream.lock import (
list_sessions_touched_since,
@@ -142,20 +141,12 @@ async def start_dream_now(
resolved_session_dir.mkdir(parents=True, exist_ok=True)
before = _memory_files_mtime_snapshot(resolved_memory_dir)
backup_dir = create_memory_backup(resolved_memory_dir, app_label=app_label) if not preview else None
stale_candidates = find_stale_memory_candidates(cwd, memory_dir=resolved_memory_dir)
stale_section = "\n".join(
f"- {header.id or header.path.name}: {header.path.name} "
f"(importance={header.importance}, updated_at={header.updated_at or 'unknown'})"
for header in stale_candidates[:20]
) or "- (none)"
extra = (
f"Application context: `{app_label}`.\n"
"Tool constraints for this run: only modify files under the memory directory. "
"Use shell commands only for read-only inspection.\n\n"
f"Sessions since last consolidation ({len(session_ids)}):\n"
+ "\n".join(f"- {session_id}" for session_id in session_ids)
+ "\n\nUsage-based stale candidates:\n"
+ stale_section
)
prompt = build_consolidation_prompt(resolved_memory_dir, resolved_session_dir, extra, preview=preview)
src_root = Path(__file__).resolve().parents[3]
+1 -25
View File
@@ -890,28 +890,6 @@ def _build_session_memory_message(messages: list[ConversationMessage]) -> Conver
)
def _build_file_session_memory_message(metadata: dict[str, Any] | None) -> ConversationMessage | None:
"""Build a compaction message from the persisted session-memory file."""
if not metadata:
return None
path = metadata.get("session_memory_path")
if not path:
return None
try:
from openharness.services.session_memory import (
get_session_memory_content,
session_memory_to_compact_text,
)
text = session_memory_to_compact_text(get_session_memory_content(str(path)))
except Exception:
return None
if not text.strip():
return None
return ConversationMessage.from_user_text(text)
def try_session_memory_compaction(
messages: list[ConversationMessage],
*,
@@ -923,8 +901,7 @@ def try_session_memory_compaction(
if len(messages) <= preserve_recent + 4:
return None
older, newer = _split_preserving_tool_pairs(messages, preserve_recent=preserve_recent)
file_summary_message = _build_file_session_memory_message(metadata)
summary_message = file_summary_message or _build_session_memory_message(older)
summary_message = _build_session_memory_message(older)
if summary_message is None:
return None
provisional = [summary_message, *newer]
@@ -940,7 +917,6 @@ def try_session_memory_compaction(
"pre_compact_token_count": estimate_message_tokens(messages),
"preserve_recent": preserve_recent,
"used_session_memory": True,
"used_file_session_memory": file_summary_message is not None,
"pre_compact_discovered_tools": _extract_discovered_tools(older),
"attachments": _extract_attachment_paths(older),
}
+4 -24
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import json
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from pathlib import Path
from typing import Any
@@ -45,28 +44,9 @@ def validate_cron_expression(expression: str) -> bool:
return croniter.is_valid(expression)
def validate_timezone(tz: str | None) -> bool:
"""Return True if *tz* is a valid IANA timezone or empty."""
if not tz:
return True
try:
ZoneInfo(tz)
except Exception:
return False
return True
def next_run_time(expression: str, base: datetime | None = None, tz: str | None = None) -> datetime:
"""Return the next run time for a cron expression.
The returned datetime is always UTC. If *tz* is provided, the cron expression
is interpreted in that IANA timezone.
"""
def next_run_time(expression: str, base: datetime | None = None) -> datetime:
"""Return the next run time for a cron expression."""
base = base or datetime.now(timezone.utc)
if tz:
local_base = base.astimezone(ZoneInfo(tz))
local_next = croniter(expression, local_base).get_next(datetime)
return local_next.astimezone(timezone.utc)
return croniter(expression, base).get_next(datetime)
@@ -81,7 +61,7 @@ def upsert_cron_job(job: dict[str, Any]) -> None:
schedule = job.get("schedule", "")
if validate_cron_expression(schedule):
job["next_run"] = next_run_time(schedule, tz=job.get("timezone") or job.get("tz")).isoformat()
job["next_run"] = next_run_time(schedule).isoformat()
with exclusive_file_lock(_cron_lock_path()):
jobs = [existing for existing in load_cron_jobs() if existing.get("name") != job.get("name")]
@@ -132,6 +112,6 @@ def mark_job_run(name: str, *, success: bool) -> None:
job["last_status"] = "success" if success else "failed"
schedule = job.get("schedule", "")
if validate_cron_expression(schedule):
job["next_run"] = next_run_time(schedule, now, tz=job.get("timezone") or job.get("tz")).isoformat()
job["next_run"] = next_run_time(schedule, now).isoformat()
save_cron_jobs(jobs)
return
+33 -270
View File
@@ -9,22 +9,17 @@ log.
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import subprocess
import shlex
import signal
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from types import FrameType
from typing import Any, Callable
from typing import Any
from openharness.config.paths import get_data_dir, get_logs_dir
from openharness.platforms import get_platform
from openharness.services.cron import (
load_cron_jobs,
mark_job_run,
@@ -33,14 +28,6 @@ from openharness.services.cron import (
from openharness.sandbox import SandboxUnavailableError
from openharness.utils.shell import create_shell_subprocess
try:
from ohmo.gateway.config import load_gateway_config
except Exception: # pragma: no cover - ohmo is optional for non-ohmo cron users
load_gateway_config = None # type: ignore[assignment]
NOTIFICATION_OUTPUT_LIMIT = 3500
logger = logging.getLogger(__name__)
TICK_INTERVAL_SECONDS = 30
@@ -102,7 +89,10 @@ def read_pid() -> int | None:
pid = int(path.read_text(encoding="utf-8").strip())
except (ValueError, OSError):
return None
if not _pid_exists(pid):
# Check if process is alive
try:
os.kill(pid, 0)
except OSError:
logger.debug("Removed stale scheduler PID file (pid=%d)", pid)
path.unlink(missing_ok=True)
return None
@@ -132,205 +122,37 @@ def stop_scheduler() -> bool:
if pid is None:
return False
try:
_terminate_pid(pid)
os.kill(pid, signal.SIGTERM)
except OSError:
remove_pid()
return False
# Wait briefly for process to exit
for _ in range(10):
if not _pid_exists(pid):
try:
os.kill(pid, 0)
except OSError:
remove_pid()
return True
time.sleep(0.2)
# Force kill
try:
_kill_pid(pid)
os.kill(pid, signal.SIGKILL)
except OSError:
pass
remove_pid()
return True
def _pid_exists(pid: int) -> bool:
"""Return True when *pid* currently refers to a live process."""
if pid <= 0:
return False
if get_platform() == "windows":
return _windows_pid_exists(pid)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _windows_pid_exists(pid: int) -> bool:
"""Windows implementation of ``kill(pid, 0)`` without requiring psutil."""
try:
import ctypes
except Exception:
return _pid_exists_with_kill_zero(pid)
try:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
except AttributeError:
return _pid_exists_with_kill_zero(pid)
synchronize = 0x00100000
process_query_limited_information = 0x1000
wait_timeout = 0x00000102
handle = kernel32.OpenProcess(
synchronize | process_query_limited_information,
False,
pid,
)
if not handle:
return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED: process exists
try:
return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
finally:
kernel32.CloseHandle(handle)
def _pid_exists_with_kill_zero(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _terminate_pid(pid: int) -> None:
os.kill(pid, signal.SIGTERM)
def _kill_pid(pid: int) -> None:
if get_platform() == "windows":
os.kill(pid, signal.SIGTERM)
return
os.kill(pid, signal.SIGKILL)
# ---------------------------------------------------------------------------
# Job execution
# ---------------------------------------------------------------------------
def _format_notification(job: dict[str, Any], entry: dict[str, Any]) -> str:
"""Build a concise notification body for a completed cron job."""
status = entry.get("status", "?")
rc = entry.get("returncode", "?")
lines = [
f"⏰ Cron job finished: {job.get('name', '?')}",
f"Status: {status} (rc={rc})",
f"Started: {entry.get('started_at', '?')}",
f"Ended: {entry.get('ended_at', '?')}",
]
stdout = str(entry.get("stdout") or "").strip()
stderr = str(entry.get("stderr") or "").strip()
if stdout:
lines.extend(["", "Output:", stdout[-NOTIFICATION_OUTPUT_LIMIT:]])
if stderr:
lines.extend(["", "Stderr:", stderr[-NOTIFICATION_OUTPUT_LIMIT:]])
if not stdout and not stderr:
lines.extend(["", "(no output)"])
return "\n".join(lines)
async def _notify_job_result(job: dict[str, Any], entry: dict[str, Any]) -> None:
"""Deliver an optional post-run notification for a cron job."""
notify = job.get("notify")
payload = job.get("payload")
if not isinstance(notify, dict) and isinstance(payload, dict) and payload.get("deliver"):
notify = {"type": payload.get("channel"), "to": payload.get("to")}
if not isinstance(notify, dict):
return
notify_type = str(notify.get("type") or "").strip().lower()
try:
if notify_type in {"feishu_dm", "feishu"}:
from ohmo.gateway.notify import send_feishu_dm
user_open_id = str(
notify.get("user_open_id") or notify.get("open_id") or notify.get("to") or ""
).strip()
if not user_open_id:
raise ValueError("missing notify.user_open_id")
workspace = notify.get("workspace")
await send_feishu_dm(
user_open_id=user_open_id,
content=_format_notification(job, entry),
workspace=str(workspace) if workspace else None,
)
elif notify_type:
raise ValueError(f"unsupported notify.type: {notify_type}")
except Exception as exc:
logger.error("Failed to notify cron job %r result: %s", job.get("name"), exc)
entry["notification_status"] = "failed"
entry["notification_error"] = str(exc)
else:
entry["notification_status"] = "sent"
def _command_for_job(job: dict[str, Any]) -> str:
"""Return the shell command used to execute a job."""
command = job.get("command")
if command:
return str(command)
payload = job.get("payload")
if not isinstance(payload, dict) or payload.get("kind", "agent_turn") != "agent_turn":
raise ValueError("cron job has no command or agent_turn payload")
message = str(payload.get("message") or "").strip()
if not message:
raise ValueError("agent_turn cron job is missing payload.message")
cwd = str(job.get("cwd") or ".")
parts = ["ohmo"]
profile = payload.get("profile") or job.get("provider_profile")
if profile is None and load_gateway_config is not None:
profile = load_gateway_config().provider_profile
if profile:
parts.extend(["--profile", str(profile)])
parts.extend(
[
"--cwd",
cwd,
"--print",
message,
]
)
return " ".join(shlex.quote(part) for part in parts)
async def execute_job(job: dict[str, Any]) -> dict[str, Any]:
"""Run a single cron job and return a history entry."""
name = job["name"]
command = job["command"]
cwd = Path(job.get("cwd") or ".").expanduser()
started_at = datetime.now(timezone.utc)
try:
command = _command_for_job(job)
except Exception as exc:
entry = {
"name": name,
"command": "",
"started_at": started_at.isoformat(),
"ended_at": datetime.now(timezone.utc).isoformat(),
"returncode": -1,
"status": "error",
"stdout": "",
"stderr": str(exc),
}
mark_job_run(name, success=False)
await _notify_job_result(job, entry)
append_history(entry)
return entry
logger.info("Executing cron job %r: %s", name, command)
try:
@@ -361,7 +183,6 @@ async def execute_job(job: dict[str, Any]) -> dict[str, Any]:
"stderr": "Job timed out after 300s",
}
mark_job_run(name, success=False)
await _notify_job_result(job, entry)
append_history(entry)
return entry
except SandboxUnavailableError as exc:
@@ -376,7 +197,6 @@ async def execute_job(job: dict[str, Any]) -> dict[str, Any]:
"stderr": str(exc),
}
mark_job_run(name, success=False)
await _notify_job_result(job, entry)
append_history(entry)
return entry
except Exception as exc:
@@ -391,7 +211,6 @@ async def execute_job(job: dict[str, Any]) -> dict[str, Any]:
"stderr": str(exc),
}
mark_job_run(name, success=False)
await _notify_job_result(job, entry)
append_history(entry)
return entry
@@ -407,7 +226,6 @@ async def execute_job(job: dict[str, Any]) -> dict[str, Any]:
"stderr": (stderr.decode("utf-8", errors="replace")[-2000:] if stderr else ""),
}
mark_job_run(name, success=success)
await _notify_job_result(job, entry)
append_history(entry)
logger.info("Job %r finished: %s (rc=%s)", name, entry["status"], process.returncode)
return entry
@@ -444,8 +262,13 @@ async def run_scheduler_loop(*, once: bool = False) -> None:
"""Main scheduler loop. Runs until SIGTERM or *once* is True (test mode)."""
shutdown = asyncio.Event()
def _on_signal() -> None:
logger.info("Received shutdown signal")
shutdown.set()
loop = asyncio.get_running_loop()
restore_signals = _install_shutdown_signal_handlers(loop, shutdown)
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _on_signal)
write_pid()
logger.info("Cron scheduler started (pid=%d, tick=%ds)", os.getpid(), TICK_INTERVAL_SECONDS)
@@ -474,45 +297,10 @@ async def run_scheduler_loop(*, once: bool = False) -> None:
except asyncio.TimeoutError:
pass
finally:
restore_signals()
remove_pid()
logger.info("Cron scheduler stopped")
def _install_shutdown_signal_handlers(
loop: asyncio.AbstractEventLoop,
shutdown: asyncio.Event,
) -> Callable[[], None]:
"""Install portable signal handlers and return a restore callback."""
previous_handlers: list[tuple[signal.Signals, Any]] = []
def _on_signal(signum: int, frame: FrameType | None) -> None:
del frame
logger.info("Received shutdown signal (%s)", signum)
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(shutdown.set)
signals: list[signal.Signals] = [signal.SIGTERM, signal.SIGINT]
sigbreak = getattr(signal, "SIGBREAK", None)
if sigbreak is not None:
signals.append(sigbreak)
for sig in signals:
try:
previous = signal.getsignal(sig)
signal.signal(sig, _on_signal)
except (OSError, RuntimeError, ValueError):
continue
previous_handlers.append((sig, previous))
def _restore() -> None:
for sig, previous in reversed(previous_handlers):
with contextlib.suppress(OSError, RuntimeError, ValueError):
signal.signal(sig, previous)
return _restore
# ---------------------------------------------------------------------------
# Daemon entry point (spawned by ``oh cron start``)
# ---------------------------------------------------------------------------
@@ -530,49 +318,28 @@ def _run_daemon() -> None:
def start_daemon() -> int:
"""Start the scheduler daemon and return its PID."""
"""Fork and start the scheduler daemon. Returns the child PID."""
existing = read_pid()
if existing is not None:
raise RuntimeError(f"Scheduler already running (pid={existing})")
process = _spawn_scheduler_process()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
pid = read_pid()
if pid is not None:
return pid
if process.poll() is not None:
break
time.sleep(0.1)
pid = os.fork()
if pid > 0:
# Parent — wait a moment for the child to write its PID file
time.sleep(0.3)
return pid
if process.poll() is not None:
log_file = get_logs_dir() / "cron_scheduler.log"
raise RuntimeError(f"Cron scheduler failed to start; see {log_file}")
return process.pid
# Child — detach
os.setsid()
# Redirect stdio
devnull = os.open(os.devnull, os.O_RDWR)
os.dup2(devnull, 0)
os.dup2(devnull, 1)
os.dup2(devnull, 2)
os.close(devnull)
def _spawn_scheduler_process() -> subprocess.Popen[bytes]:
"""Spawn a detached scheduler subprocess on Unix and Windows."""
popen_kwargs: dict[str, Any] = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
}
if get_platform() == "windows":
creationflags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
subprocess,
"CREATE_NEW_PROCESS_GROUP",
0,
)
if creationflags:
popen_kwargs["creationflags"] = creationflags
else:
popen_kwargs["start_new_session"] = True
return subprocess.Popen(
[sys.executable, "-m", "openharness.services.cron_scheduler"],
**popen_kwargs,
)
_run_daemon()
sys.exit(0)
def scheduler_status() -> dict[str, Any]:
@@ -589,7 +356,3 @@ def scheduler_status() -> dict[str, Any]:
"log_file": str(log_path),
"history_file": str(get_history_path()),
}
if __name__ == "__main__":
_run_daemon()
@@ -1,261 +0,0 @@
"""Durable memory extraction from completed turns."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from openharness.api.client import ApiMessageCompleteEvent, ApiMessageRequest, SupportsStreamingMessages
from openharness.engine.messages import ConversationMessage, ToolUseBlock
from openharness.memory.manager import add_memory_entry
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.relevance import build_memory_manifest
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import (
DEFAULT_MEMORY_SCOPE,
DEFAULT_MEMORY_TYPE,
MemoryScope,
MemoryType,
parse_memory_scope,
parse_memory_type,
)
from openharness.memory.team import check_team_memory_secrets, validate_team_memory_write_path
log = logging.getLogger(__name__)
MEMORY_WRITE_TOOLS = {"write_file", "edit_file"}
@dataclass(frozen=True)
class ExtractionRecord:
"""Structured memory record proposed by the extraction pass."""
title: str
body: str
memory_type: MemoryType = DEFAULT_MEMORY_TYPE
scope: MemoryScope = DEFAULT_MEMORY_SCOPE
description: str = ""
tags: tuple[str, ...] = ()
@dataclass(frozen=True)
class ExtractionResult:
"""Outcome of a durable memory extraction run."""
skipped: bool
reason: str = ""
records: tuple[ExtractionRecord, ...] = ()
written_paths: tuple[Path, ...] = ()
def has_memory_writes_since(
messages: list[ConversationMessage],
memory_dir: str | Path,
*,
cwd: str | Path | None = None,
) -> bool:
"""Return whether the visible turn already wrote memory files."""
root = Path(memory_dir).expanduser().resolve()
write_base = Path(cwd).expanduser().resolve() if cwd is not None else root
for message in messages:
for block in message.content:
if not isinstance(block, ToolUseBlock):
continue
if block.name not in MEMORY_WRITE_TOOLS:
continue
raw_path = block.input.get("path") or block.input.get("file_path")
if not raw_path:
continue
path = Path(str(raw_path)).expanduser()
if not path.is_absolute():
path = write_base / path
try:
path.resolve().relative_to(root)
except ValueError:
continue
return True
return False
async def extract_memories_from_turn(
*,
cwd: str | Path,
api_client: SupportsStreamingMessages,
model: str,
messages: list[ConversationMessage],
max_records: int = 3,
) -> ExtractionResult:
"""Ask the model for durable memory candidates and apply them."""
memory_dir = get_project_memory_dir(cwd)
if len(messages) < 2:
return ExtractionResult(skipped=True, reason="not enough messages")
if has_memory_writes_since(messages, memory_dir, cwd=cwd):
return ExtractionResult(skipped=True, reason="main conversation already wrote memory")
prompt = build_extraction_prompt(cwd, messages, max_records=max_records)
final_text = ""
async for event in api_client.stream_message(
ApiMessageRequest(
model=model,
messages=[ConversationMessage.from_user_text(prompt)],
system_prompt=EXTRACTION_SYSTEM_PROMPT,
max_tokens=2048,
tools=[],
)
):
if isinstance(event, ApiMessageCompleteEvent):
final_text = event.message.text
break
records = parse_extraction_records(final_text, max_records=max_records)
if not records:
return ExtractionResult(skipped=True, reason="no durable memories proposed")
return apply_extraction_records(cwd, records)
def build_extraction_prompt(cwd: str | Path, messages: list[ConversationMessage], *, max_records: int) -> str:
"""Build the extraction request from recent messages and manifest."""
manifest = build_memory_manifest(scan_memory_files(cwd, max_files=80))
transcript = "\n".join(_summarize_message(message) for message in messages[-12:])
return (
"Extract only durable memories from the recent conversation.\n"
f"Return JSON with at most {max_records} records. Existing memory manifest:\n"
f"{manifest or '(empty)'}\n\n"
"Recent conversation:\n"
f"{transcript}\n\n"
"JSON schema: {\"memories\":[{\"title\":\"...\",\"type\":\"user|feedback|project|reference\","
"\"scope\":\"private|project|team\",\"description\":\"...\",\"body\":\"...\",\"tags\":[\"...\"]}]}"
)
EXTRACTION_SYSTEM_PROMPT = """You maintain OpenHarness durable memory.
Save only stable, future-useful facts that are not derivable from current files,
git history, or documentation. Prefer updating existing memories conceptually
over duplicating them. Do not save secrets. If nothing is worth saving, return
{"memories": []}.
"""
def parse_extraction_records(text: str, *, max_records: int = 3) -> tuple[ExtractionRecord, ...]:
"""Parse JSON memory extraction output."""
try:
payload = json.loads(_extract_json_object(text))
except json.JSONDecodeError:
return ()
raw_records = payload.get("memories") if isinstance(payload, dict) else None
if not isinstance(raw_records, list):
return ()
records: list[ExtractionRecord] = []
for item in raw_records[:max_records]:
if not isinstance(item, dict):
continue
title = str(item.get("title") or "").strip()
body = str(item.get("body") or "").strip()
if not title or not body:
continue
memory_type = parse_memory_type(item.get("type"), default=DEFAULT_MEMORY_TYPE) or DEFAULT_MEMORY_TYPE
scope = parse_memory_scope(item.get("scope"), default=DEFAULT_MEMORY_SCOPE) or DEFAULT_MEMORY_SCOPE
tags_raw = item.get("tags") or ()
tags = tuple(str(tag).strip() for tag in tags_raw if str(tag).strip()) if isinstance(tags_raw, list) else ()
records.append(
ExtractionRecord(
title=title,
body=body,
memory_type=memory_type,
scope=scope,
description=str(item.get("description") or "").strip(),
tags=tags,
)
)
return tuple(records)
def apply_extraction_records(cwd: str | Path, records: tuple[ExtractionRecord, ...]) -> ExtractionResult:
"""Write accepted records to durable memory."""
written: list[Path] = []
for record in records:
if record.scope == "team":
secret_error = check_team_memory_secrets(record.body)
if secret_error:
log.warning("memory extraction skipped team record %r: %s", record.title, secret_error)
continue
path, error = validate_team_memory_write_path(cwd, f"{record.title}.md")
if error or path is None:
log.warning("memory extraction skipped team record %r: %s", record.title, error)
continue
written.append(
add_memory_entry(
cwd,
record.title,
record.body,
memory_type=record.memory_type,
scope=record.scope,
description=record.description,
tags=record.tags,
)
)
return ExtractionResult(skipped=not bool(written), reason="" if written else "all records rejected", records=records, written_paths=tuple(written))
def validate_extraction_tool_request(tool_name: str, tool_input: dict[str, Any], memory_dir: str | Path) -> tuple[bool, str]:
"""Permission guard for extraction-like agents."""
if tool_name in {"read_file", "grep", "glob"}:
return True, ""
if tool_name == "bash":
command = str(tool_input.get("command") or "")
if _is_read_only_shell(command):
return True, ""
return False, "memory extraction may only run read-only shell commands"
if tool_name in {"write_file", "edit_file"}:
raw_path = str(tool_input.get("path") or tool_input.get("file_path") or "")
if not raw_path:
return False, "memory extraction write requires a path"
root = Path(memory_dir).expanduser().resolve()
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = root / path
try:
path.resolve().relative_to(root)
except ValueError:
return False, f"memory extraction writes must stay within {root}"
return True, ""
return False, f"memory extraction cannot use tool {tool_name}"
def _extract_json_object(text: str) -> str:
stripped = text.strip()
if stripped.startswith("{") and stripped.endswith("}"):
return stripped
start = stripped.find("{")
end = stripped.rfind("}")
if start >= 0 and end > start:
return stripped[start : end + 1]
return stripped
def _summarize_message(message: ConversationMessage) -> str:
text = " ".join(message.text.split())
if text:
return f"{message.role}: {text[:1200]}"
if message.tool_uses:
return f"{message.role}: tool calls -> {', '.join(block.name for block in message.tool_uses)}"
return f"{message.role}: [non-text content]"
def _is_read_only_shell(command: str) -> bool:
lowered = command.strip().lower()
if not lowered:
return False
denied = (" > ", ">>", " rm ", " mv ", " cp ", " sed -i", " tee ", "python -c", "python3 -c")
if any(marker in f" {lowered} " for marker in denied):
return False
first = lowered.split(maxsplit=1)[0]
return first in {"ls", "pwd", "cat", "head", "tail", "rg", "grep", "find", "git", "wc", "sed", "awk", "stat"}
@@ -1,139 +0,0 @@
"""File-backed session memory for compact continuity."""
from __future__ import annotations
from hashlib import sha1
from pathlib import Path
from openharness.config.paths import get_data_dir
from openharness.engine.messages import ConversationMessage, ToolResultBlock
from openharness.services.token_estimation import estimate_tokens
from openharness.utils.fs import atomic_write_text
MAX_SESSION_MEMORY_CHARS = 12_000
MAX_RECENT_LINES = 80
def get_session_memory_dir(cwd: str | Path) -> Path:
"""Return the project session-memory directory."""
root = Path(cwd).resolve()
digest = sha1(str(root).encode("utf-8")).hexdigest()[:12]
path = get_data_dir() / "session-memory" / f"{root.name}-{digest}"
path.mkdir(parents=True, exist_ok=True)
return path
def get_session_memory_path(cwd: str | Path, session_id: str | None = None) -> Path:
"""Return the markdown session-memory path."""
safe_session = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in (session_id or "default"))
return get_session_memory_dir(cwd) / f"{safe_session or 'default'}.md"
def prepare_session_memory_metadata(
cwd: str | Path,
tool_metadata: dict[str, object],
*,
session_id: str | None = None,
) -> Path:
"""Ensure metadata points compaction to the session-memory file."""
sid = session_id or str(tool_metadata.get("session_id") or "default")
path = get_session_memory_path(cwd, sid)
tool_metadata["session_memory_path"] = str(path)
return path
def get_session_memory_content(path: str | Path | None) -> str:
"""Read session memory content if available."""
if not path:
return ""
candidate = Path(path).expanduser()
if not candidate.exists():
return ""
try:
return candidate.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
def update_session_memory_file(
cwd: str | Path,
messages: list[ConversationMessage],
*,
tool_metadata: dict[str, object] | None = None,
session_id: str | None = None,
) -> Path:
"""Update the deterministic session-memory checkpoint."""
path = prepare_session_memory_metadata(cwd, tool_metadata or {}, session_id=session_id)
body = build_session_memory_document(messages, tool_metadata=tool_metadata)
atomic_write_text(path, body)
return path
def build_session_memory_document(
messages: list[ConversationMessage],
*,
tool_metadata: dict[str, object] | None = None,
) -> str:
"""Build a compact markdown checkpoint for the current session."""
state = tool_metadata.get("task_focus_state") if isinstance(tool_metadata, dict) else None
goal = ""
next_step = ""
verified: list[str] = []
artifacts: list[str] = []
if isinstance(state, dict):
goal = str(state.get("goal") or "").strip()
next_step = str(state.get("next_step") or "").strip()
verified = [str(item).strip() for item in state.get("verified_state", []) if str(item).strip()]
artifacts = [str(item).strip() for item in state.get("active_artifacts", []) if str(item).strip()]
lines = ["# Session Memory", ""]
lines.extend(["## Current State", goal or "(no current goal recorded)", ""])
if next_step:
lines.extend(["## Next Step", next_step, ""])
if verified:
lines.extend(["## Verified Work", *[f"- {item}" for item in verified[-10:]], ""])
if artifacts:
lines.extend(["## Active Artifacts", *[f"- {item}" for item in artifacts[-10:]], ""])
lines.extend(["## Recent Conversation", *_recent_message_lines(messages), ""])
text = "\n".join(lines).strip() + "\n"
if len(text) > MAX_SESSION_MEMORY_CHARS:
text = text[:MAX_SESSION_MEMORY_CHARS].rsplit("\n", 1)[0]
text += "\n\n> Session memory was truncated to stay within budget.\n"
return text
def session_memory_to_compact_text(content: str) -> str:
"""Prepare persisted session memory for insertion across compact boundaries."""
stripped = content.strip()
if not stripped:
return ""
if estimate_tokens(stripped) > 4_000:
stripped = stripped[:MAX_SESSION_MEMORY_CHARS].rsplit("\n", 1)[0]
return "Session memory checkpoint from earlier in this conversation:\n" + stripped
def _recent_message_lines(messages: list[ConversationMessage]) -> list[str]:
lines: list[str] = []
for message in messages[-MAX_RECENT_LINES:]:
line = _summarize_message(message)
if line:
lines.append(f"- {line}")
return lines or ["- (no recent messages)"]
def _summarize_message(message: ConversationMessage) -> str:
text = " ".join(message.text.split())
if text:
return f"{message.role}: {text[:220]}"
if message.tool_uses:
return f"{message.role}: tool calls -> {', '.join(block.name for block in message.tool_uses[:6])}"
if any(isinstance(block, ToolResultBlock) for block in message.content):
return f"{message.role}: tool results returned"
return f"{message.role}: [non-text content]"
-13
View File
@@ -60,22 +60,9 @@ _TEAMMATE_ENV_VARS = [
# --- OpenHarness-native provider settings --------------------------------
# These are read by settings._apply_env_overrides() and must survive across
# tmux boundaries so teammates use the same provider as the leader.
"OPENHARNESS_CONFIG_DIR",
"OPENHARNESS_DATA_DIR",
"OPENHARNESS_LOGS_DIR",
"OPENHARNESS_PROFILE",
"OPENHARNESS_API_FORMAT",
"OPENHARNESS_PROVIDER",
"OPENHARNESS_BASE_URL",
"OPENHARNESS_MODEL",
"OPENHARNESS_ANTHROPIC_API_KEY",
"OPENHARNESS_OPENAI_API_KEY",
"OPENHARNESS_DASHSCOPE_API_KEY",
"OPENHARNESS_MOONSHOT_API_KEY",
"OPENHARNESS_GEMINI_API_KEY",
"OPENHARNESS_MINIMAX_API_KEY",
"OPENHARNESS_NVIDIA_API_KEY",
"OPENHARNESS_MODELSCOPE_API_KEY",
"OPENAI_API_KEY",
]
-2
View File
@@ -19,7 +19,6 @@ from openharness.tools.file_read_tool import FileReadTool
from openharness.tools.file_write_tool import FileWriteTool
from openharness.tools.glob_tool import GlobTool
from openharness.tools.grep_tool import GrepTool
from openharness.tools.image_generation_tool import ImageGenerationTool
from openharness.tools.image_to_text_tool import ImageToTextTool
from openharness.tools.list_mcp_resources_tool import ListMcpResourcesTool
from openharness.tools.lsp_tool import LspTool
@@ -60,7 +59,6 @@ def create_default_tool_registry(mcp_manager=None) -> ToolRegistry:
GlobTool(),
GrepTool(),
ImageToTextTool(),
ImageGenerationTool(),
SkillTool(),
ToolSearchTool(),
WebFetchTool(),
+11 -53
View File
@@ -2,11 +2,9 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from openharness.services.cron import upsert_cron_job, validate_cron_expression, validate_timezone
from openharness.services.cron import upsert_cron_job, validate_cron_expression
from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult
@@ -20,25 +18,9 @@ class CronCreateToolInput(BaseModel):
"'0 9 * * 1-5' for weekdays at 9am)"
),
)
command: str | None = Field(default=None, description="Shell command to run when triggered")
message: str | None = Field(default=None, description="Instruction for an agent_turn cron job")
timezone: str | None = Field(default=None, description="IANA timezone for interpreting cron schedule")
command: str = Field(description="Shell command to run when triggered")
cwd: str | None = Field(default=None, description="Optional working directory override")
enabled: bool = Field(default=True, description="Whether the job is active")
payload: dict[str, Any] | None = Field(
default=None,
description=(
"Optional nanobot-style payload. Example: "
"{'kind': 'agent_turn', 'message': 'check GitHub', 'deliver': True, 'channel': 'feishu', 'to': 'ou_xxx'}."
),
)
notify: dict[str, Any] | None = Field(
default=None,
description=(
"Optional notification target. Example: "
"{'type': 'feishu_dm', 'user_open_id': 'ou_xxx'} to send job output to a Feishu private chat."
),
)
class CronCreateTool(BaseTool):
@@ -65,40 +47,16 @@ class CronCreateTool(BaseTool):
),
is_error=True,
)
if not validate_timezone(arguments.timezone):
return ToolResult(output=f"Invalid timezone: {arguments.timezone!r}", is_error=True)
payload = dict(arguments.payload or {})
if arguments.message:
payload.setdefault("kind", "agent_turn")
payload.setdefault("message", arguments.message)
if arguments.notify is not None:
payload.setdefault("deliver", True)
if str(arguments.notify.get("type") or "").strip().lower() == "feishu_dm":
payload.setdefault("channel", "feishu")
payload.setdefault("to", arguments.notify.get("user_open_id") or arguments.notify.get("open_id"))
if payload and not payload.get("message") and not arguments.command:
return ToolResult(output="Cron job requires payload.message, message, or command.", is_error=True)
if not payload and not arguments.command:
return ToolResult(output="Cron job requires command or message.", is_error=True)
job = {
"name": arguments.name,
"schedule": arguments.schedule,
"cwd": arguments.cwd or str(context.cwd),
"enabled": arguments.enabled,
}
if arguments.timezone:
job["timezone"] = arguments.timezone
if arguments.command is not None:
job["command"] = arguments.command
if payload:
payload.setdefault("kind", "agent_turn")
job["payload"] = payload
if arguments.notify is not None:
job["notify"] = arguments.notify
upsert_cron_job(job)
upsert_cron_job(
{
"name": arguments.name,
"schedule": arguments.schedule,
"command": arguments.command,
"cwd": arguments.cwd or str(context.cwd),
"enabled": arguments.enabled,
}
)
status = "enabled" if arguments.enabled else "disabled"
return ToolResult(
output=f"Created cron job '{arguments.name}' [{arguments.schedule}] ({status})"
+2 -16
View File
@@ -47,23 +47,9 @@ class CronListTool(BaseTool):
next_run = next_run[:19]
last_status = job.get("last_status", "")
status_str = f" ({last_status})" if last_status else ""
notify = job.get("notify")
notify_line = ""
if isinstance(notify, dict):
notify_type = notify.get("type", "?")
target = notify.get("user_open_id") or notify.get("open_id") or notify.get("chat_id") or "?"
notify_line = f"\n notify: {notify_type} -> {target}"
timezone = f" ({job['timezone']})" if job.get("timezone") else ""
payload = job.get("payload")
payload_line = ""
if isinstance(payload, dict):
payload_line = f"\n payload: {payload.get('kind', 'agent_turn')} -> {payload.get('channel', '?')}:{payload.get('to', '?')}"
command = job.get("command") or "(agent_turn)"
lines.append(
f"[{enabled}] {job['name']} {job.get('schedule', '?')}{timezone}\n"
f" cmd: {command}"
f"{payload_line}"
f"{notify_line}\n"
f"[{enabled}] {job['name']} {job.get('schedule', '?')}\n"
f" cmd: {job['command']}\n"
f" last: {last_run}{status_str} next: {next_run}"
)
return ToolResult(output="\n".join(lines))
-31
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import difflib
from pathlib import Path
from pydantic import BaseModel, Field
@@ -54,16 +53,6 @@ class FileEditTool(BaseTool):
else:
updated = original.replace(arguments.old_str, arguments.new_str, 1)
approval_prompt = context.metadata.get("edit_approval_prompt") if context.metadata else None
if approval_prompt is not None:
diff_text, added, removed = _compute_diff(str(path), original, updated)
reply = await approval_prompt(str(path), diff_text, added, removed)
if reply == "reject":
return ToolResult(output=f"Edit rejected by user: {path}", is_error=True)
path.write_text(updated, encoding="utf-8")
stats = f" ({_ANSI_GREEN}+{added}{_ANSI_RESET} {_ANSI_RED}-{removed}{_ANSI_RESET})"
return ToolResult(output=f"Updated {path}{stats}")
path.write_text(updated, encoding="utf-8")
return ToolResult(output=f"Updated {path}")
@@ -73,23 +62,3 @@ def _resolve_path(base: Path, candidate: str) -> Path:
if not path.is_absolute():
path = base / path
return path.resolve()
def _compute_diff(filename: str, original: str, updated: str) -> tuple[str, int, int]:
diff_lines = list(
difflib.unified_diff(
original.splitlines(keepends=True),
updated.splitlines(keepends=True),
fromfile=filename,
tofile=filename,
lineterm="",
)
)
added = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++"))
removed = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---"))
return "".join(diff_lines), added, removed
_ANSI_GREEN = "\033[32m"
_ANSI_RED = "\033[31m"
_ANSI_RESET = "\033[0m"
-34
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import difflib
from pathlib import Path
from pydantic import BaseModel, Field
@@ -41,19 +40,6 @@ class FileWriteTool(BaseTool):
if not allowed:
return ToolResult(output=f"Sandbox: {reason}", is_error=True)
approval_prompt = context.metadata.get("edit_approval_prompt") if context.metadata else None
if approval_prompt is not None:
original = path.read_text(encoding="utf-8") if path.exists() else ""
diff_text, added, removed = _compute_diff(str(path), original, arguments.content)
reply = await approval_prompt(str(path), diff_text, added, removed)
if reply == "reject":
return ToolResult(output=f"Write rejected by user: {path}", is_error=True)
if arguments.create_directories:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(arguments.content, encoding="utf-8")
stats = f" ({_ANSI_GREEN}+{added}{_ANSI_RESET} {_ANSI_RED}-{removed}{_ANSI_RESET})"
return ToolResult(output=f"Wrote {path}{stats}")
if arguments.create_directories:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(arguments.content, encoding="utf-8")
@@ -65,23 +51,3 @@ def _resolve_path(base: Path, candidate: str) -> Path:
if not path.is_absolute():
path = base / path
return path.resolve()
def _compute_diff(filename: str, original: str, updated: str) -> tuple[str, int, int]:
diff_lines = list(
difflib.unified_diff(
original.splitlines(keepends=True),
updated.splitlines(keepends=True),
fromfile=filename,
tofile=filename,
lineterm="",
)
)
added = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++"))
removed = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---"))
return "".join(diff_lines), added, removed
_ANSI_GREEN = "\033[32m"
_ANSI_RED = "\033[31m"
_ANSI_RESET = "\033[0m"
+1 -12
View File
@@ -16,10 +16,7 @@ class GrepToolInput(BaseModel):
"""Arguments for the grep tool."""
pattern: str = Field(description="Regular expression to search for")
root: str | None = Field(
default=None,
description="Search root directory or file. For multiple roots, call grep separately per root.",
)
root: str | None = Field(default=None, description="Search root directory")
file_glob: str = Field(default="**/*")
case_sensitive: bool = Field(default=True)
limit: int = Field(default=200, ge=1, le=2000)
@@ -39,14 +36,6 @@ class GrepTool(BaseTool):
async def execute(self, arguments: GrepToolInput, context: ToolExecutionContext) -> ToolResult:
root = _resolve_path(context.cwd, arguments.root) if arguments.root else context.cwd
if not root.exists():
return ToolResult(
output=(
f"Search root does not exist: {root}\n"
"If you intended multiple roots, call grep separately for each root."
),
is_error=True,
)
if root.is_file():
display_base = _display_base(root, context.cwd)
matches = await _rg_grep_file(
@@ -1,356 +0,0 @@
"""Generate or edit raster images with configurable image generation providers."""
from __future__ import annotations
import base64
import json
import logging
from pathlib import Path
from typing import Any, Literal
import httpx
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
from openharness.api.codex_client import _build_codex_headers, _resolve_codex_url
from openharness.api.openai_client import _normalize_openai_base_url
from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult
log = logging.getLogger(__name__)
_DEFAULT_PROMPT = (
"Create a high-quality raster image that satisfies the user's request. "
"Avoid watermarks, unintended text, and unrelated logos."
)
_DEFAULT_MODEL = "gpt-image-2"
_DEFAULT_OUTPUT_DIR = "generated_images"
ImageGenerationProvider = Literal["auto", "openai", "codex"]
class ImageGenerationToolInput(BaseModel):
"""Arguments for image generation or editing."""
prompt: str = Field(default=_DEFAULT_PROMPT, description="Image generation or edit prompt.")
provider: ImageGenerationProvider = Field(
default="auto",
description="Image generation provider: auto, openai, or codex.",
)
image_paths: list[str] = Field(
default_factory=list,
description="Local image paths to edit or use as references. OpenAI provider uses image edit mode. Codex hosted generation currently treats these as visual context.",
)
mask_path: str | None = Field(default=None, description="Optional PNG mask path for OpenAI edit mode.")
output_path: str | None = Field(
default=None,
description="Optional output path. For multiple images, numeric suffixes are added.",
)
output_dir: str = Field(
default=_DEFAULT_OUTPUT_DIR,
description="Output directory used when output_path is not provided.",
)
model: str | None = Field(default=None, description="OpenAI image model override.")
n: int = Field(default=1, ge=1, le=10, description="Number of images to generate.")
size: str = Field(default="auto", description="OpenAI image size, e.g. auto, 1024x1024, 1536x1024.")
quality: str = Field(default="medium", description="OpenAI image quality, e.g. low, medium, high, auto.")
background: Literal["transparent", "opaque", "auto"] | None = Field(
default=None,
description="Optional OpenAI background mode when supported by the provider.",
)
output_format: Literal["png", "jpeg", "webp"] = Field(default="png", description="Output image format.")
output_compression: int | None = Field(
default=None,
ge=0,
le=100,
description="Optional OpenAI compression level for lossy output formats when supported.",
)
input_fidelity: Literal["low", "high"] | None = Field(
default=None,
description="Optional OpenAI edit input fidelity when supported by the provider.",
)
moderation: str | None = Field(default=None, description="Optional OpenAI moderation setting.")
overwrite: bool = Field(default=False, description="Whether to overwrite existing output files.")
class ImageGenerationTool(BaseTool):
"""Generate or edit raster images and save them to local files."""
name = "image_generation"
description = (
"Generate or edit raster images using a configurable image generation provider. "
"Use this for bitmap assets such as photos, illustrations, sprites, mockups, "
"transparent cutouts, or edited local images. Supports provider='codex' for "
"Codex hosted image_generation with Codex subscription auth, and provider='openai' "
"for OpenAI-compatible key/base_url image APIs."
)
input_model = ImageGenerationToolInput
async def execute(self, arguments: ImageGenerationToolInput, context: ToolExecutionContext) -> ToolResult:
config = context.metadata.get("image_generation_config", {})
if not isinstance(config, dict):
config = {}
provider = _resolve_provider(arguments.provider, config)
try:
output_paths = self._resolve_output_paths(arguments, context.cwd)
if provider == "codex":
image_b64, revised_prompt = await self._generate_with_codex(arguments, config)
written = self._write_images(image_b64, output_paths, overwrite=arguments.overwrite)
extra = f"\nRevised prompt: {revised_prompt}" if revised_prompt else ""
return ToolResult(
output=(
"[Image generation via Codex hosted image_generation]\n"
+ "\n".join(f"Wrote {path}" for path in written)
+ extra
),
metadata={
"paths": [str(path) for path in written],
"provider": "codex",
"revised_prompt": revised_prompt,
},
)
image_b64 = await self._generate_with_openai(arguments, config)
written = self._write_images(image_b64, output_paths, overwrite=arguments.overwrite)
except Exception as exc:
log.exception("image_generation failed")
return ToolResult(output=f"image_generation failed: {exc}", is_error=True)
mode = "edit" if arguments.image_paths else "generate"
model = (arguments.model or str(config.get("model") or _DEFAULT_MODEL)).strip()
return ToolResult(
output=(
f"[Image generation via {model} ({mode}, openai)]\n"
+ "\n".join(f"Wrote {path}" for path in written)
),
metadata={"paths": [str(path) for path in written], "model": model, "mode": mode, "provider": "openai"},
)
async def _generate_with_openai(self, arguments: ImageGenerationToolInput, config: dict[str, object]) -> list[str]:
model = (arguments.model or str(config.get("model") or _DEFAULT_MODEL)).strip()
api_key = str(config.get("api_key") or "").strip()
base_url = str(config.get("base_url") or "").strip()
if not api_key:
raise RuntimeError(
"OpenAI image generation API key is not configured. Set image_generation.api_key "
"or OPENHARNESS_IMAGE_GENERATION_API_KEY, or choose provider='codex'."
)
if arguments.image_paths:
return await self._edit_images(arguments, model, api_key, base_url)
return await self._generate_images(arguments, model, api_key, base_url)
async def _generate_with_codex(
self,
arguments: ImageGenerationToolInput,
config: dict[str, object],
) -> tuple[list[str], str | None]:
auth_token = str(config.get("codex_auth_token") or "").strip()
if not auth_token:
raise RuntimeError(
"Codex image generation auth is not configured. Run 'oh auth codex-login' "
"or use provider='openai' with OPENHARNESS_IMAGE_GENERATION_API_KEY."
)
model = str(config.get("codex_model") or "gpt-5.4").strip() or "gpt-5.4"
base_url = str(config.get("codex_base_url") or "").strip()
prompt = _codex_prompt(arguments)
body: dict[str, Any] = {
"model": model,
"store": False,
"stream": True,
"instructions": "Generate the requested image using the hosted image_generation tool.",
"input": [{"role": "user", "content": _codex_user_content(arguments, prompt)}],
"text": {"verbosity": "medium"},
"tools": [{"type": "image_generation", "output_format": arguments.output_format}],
"tool_choice": "auto",
"parallel_tool_calls": False,
}
headers = _build_codex_headers(auth_token)
url = _resolve_codex_url(base_url)
image_results: list[str] = []
revised_prompt: str | None = None
async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code >= 400:
payload = await response.aread()
raise RuntimeError(payload.decode("utf-8", "replace") or f"Codex request failed: {response.status_code}")
async for event in _iter_sse_events(response):
if event.get("type") != "response.output_item.done":
if event.get("type") == "response.failed":
raise RuntimeError(json.dumps(event.get("response") or event, ensure_ascii=False))
if event.get("type") == "error":
raise RuntimeError(json.dumps(event, ensure_ascii=False))
continue
item = event.get("item")
if not isinstance(item, dict) or item.get("type") != "image_generation_call":
continue
result = item.get("result")
if isinstance(result, str) and result:
image_results.append(result)
candidate = item.get("revised_prompt")
if isinstance(candidate, str) and candidate:
revised_prompt = candidate
if not image_results:
raise RuntimeError("Codex hosted image_generation returned no image result")
return image_results, revised_prompt
@staticmethod
async def _generate_images(arguments: ImageGenerationToolInput, model: str, api_key: str, base_url: str) -> list[str]:
client = AsyncOpenAI(
api_key=api_key,
base_url=_normalize_openai_base_url(base_url),
default_headers={"Authorization": f"Bearer {api_key}"},
)
result = await client.images.generate(**_image_payload(arguments, model))
return _extract_b64_images(result)
@staticmethod
async def _edit_images(arguments: ImageGenerationToolInput, model: str, api_key: str, base_url: str) -> list[str]:
client = AsyncOpenAI(
api_key=api_key,
base_url=_normalize_openai_base_url(base_url),
default_headers={"Authorization": f"Bearer {api_key}"},
)
image_handles = [Path(path).expanduser().resolve().open("rb") for path in arguments.image_paths]
mask_handle = Path(arguments.mask_path).expanduser().resolve().open("rb") if arguments.mask_path else None
try:
payload = _image_payload(arguments, model)
payload["image"] = image_handles if len(image_handles) > 1 else image_handles[0]
if mask_handle is not None:
payload["mask"] = mask_handle
result = await client.images.edit(**payload)
finally:
for handle in image_handles:
handle.close()
if mask_handle is not None:
mask_handle.close()
return _extract_b64_images(result)
@staticmethod
def _resolve_output_paths(arguments: ImageGenerationToolInput, cwd: Path) -> list[Path]:
suffix = f".{arguments.output_format}"
if arguments.output_path:
base = Path(arguments.output_path)
if not base.is_absolute():
base = cwd / base
base = base.expanduser().resolve()
else:
out_dir = Path(arguments.output_dir)
if not out_dir.is_absolute():
out_dir = cwd / out_dir
out_dir = out_dir.expanduser().resolve()
base = out_dir / f"image{suffix}"
if base.suffix.lower() != suffix:
base = base.with_suffix(suffix)
if arguments.n == 1:
return [base]
return [base.with_name(f"{base.stem}-{idx}{base.suffix}") for idx in range(1, arguments.n + 1)]
@staticmethod
def _write_images(images: list[str], output_paths: list[Path], *, overwrite: bool) -> list[Path]:
written: list[Path] = []
for image_b64, output_path in zip(images, output_paths, strict=False):
if output_path.exists() and not overwrite:
raise FileExistsError(f"output already exists: {output_path} (set overwrite=true)")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(base64.b64decode(image_b64))
written.append(output_path)
if not written:
raise RuntimeError("provider returned no image data")
return written
def _resolve_provider(requested: str, config: dict[str, object]) -> Literal["openai", "codex"]:
if requested in {"openai", "codex"}:
return requested # type: ignore[return-value]
configured = str(config.get("provider") or "auto").strip().lower()
if configured in {"openai", "codex"}:
return configured # type: ignore[return-value]
if str(config.get("codex_auth_token") or "").strip():
return "codex"
return "openai"
def _image_payload(arguments: ImageGenerationToolInput, model: str) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": model,
"prompt": arguments.prompt,
"n": arguments.n,
"size": arguments.size,
"quality": arguments.quality,
"background": arguments.background,
"output_format": arguments.output_format,
"output_compression": arguments.output_compression,
"input_fidelity": arguments.input_fidelity,
"moderation": arguments.moderation,
}
return {key: value for key, value in payload.items() if value is not None}
def _codex_prompt(arguments: ImageGenerationToolInput) -> str:
lines = [arguments.prompt]
if arguments.n > 1:
lines.append(f"Generate {arguments.n} distinct variants.")
if arguments.image_paths:
lines.append("Use the attached image(s) as visual context/reference for the generation or edit.")
return "\n".join(line for line in lines if line.strip())
def _codex_user_content(arguments: ImageGenerationToolInput, prompt: str) -> list[dict[str, str]]:
content = [{"type": "input_text", "text": prompt}]
for path_str in arguments.image_paths:
path = Path(path_str).expanduser().resolve()
media_type = _media_type_for_path(path)
data = base64.b64encode(path.read_bytes()).decode("ascii")
content.append({"type": "input_image", "image_url": f"data:{media_type};base64,{data}"})
return content
def _media_type_for_path(path: Path) -> str:
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}.get(path.suffix.lower(), "image/png")
async def _iter_sse_events(response: httpx.Response):
data_lines: list[str] = []
async for line in response.aiter_lines():
if line == "":
if data_lines:
payload = "\n".join(data_lines).strip()
data_lines = []
if payload and payload != "[DONE]":
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(event, dict):
yield event
continue
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if data_lines:
payload = "\n".join(data_lines).strip()
if payload and payload != "[DONE]":
try:
event = json.loads(payload)
except json.JSONDecodeError:
return
if isinstance(event, dict):
yield event
def _extract_b64_images(result: Any) -> list[str]:
images: list[str] = []
for item in getattr(result, "data", []) or []:
b64 = getattr(item, "b64_json", None)
if isinstance(b64, str) and b64:
images.append(b64)
continue
url = getattr(item, "url", None)
if isinstance(url, str) and url.startswith("data:image/") and ";base64," in url:
images.append(url.split(";base64,", 1)[1])
return images
+1 -3
View File
@@ -8,7 +8,6 @@ from pathlib import Path
from pydantic import BaseModel, Field
from openharness.services.cron import get_cron_job
from openharness.services.cron_scheduler import _command_for_job
from openharness.sandbox import SandboxUnavailableError
from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult
from openharness.utils.shell import create_shell_subprocess
@@ -39,9 +38,8 @@ class RemoteTriggerTool(BaseTool):
cwd = Path(job.get("cwd") or context.cwd).expanduser()
try:
command = _command_for_job(job)
process = await create_shell_subprocess(
command,
str(job["command"]),
cwd=cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
+1 -2
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import html
import os
import re
from urllib.parse import parse_qs, unquote, urlparse
@@ -42,7 +41,7 @@ class WebSearchTool(BaseTool):
context: ToolExecutionContext,
) -> ToolResult:
del context
endpoint = arguments.search_url or os.environ.get("OPENHARNESS_WEB_SEARCH_URL") or "https://html.duckduckgo.com/html/"
endpoint = arguments.search_url or "https://html.duckduckgo.com/html/"
try:
response = await fetch_public_http_response(
endpoint,
-7
View File
@@ -43,7 +43,6 @@ async def run_repl(
cwd: str | None = None,
model: str | None = None,
max_turns: int | None = None,
effort: str | None = None,
base_url: str | None = None,
system_prompt: str | None = None,
api_key: str | None = None,
@@ -60,7 +59,6 @@ async def run_repl(
cwd=cwd,
model=model,
max_turns=max_turns,
effort=effort,
base_url=base_url,
system_prompt=system_prompt,
api_key=api_key,
@@ -78,7 +76,6 @@ async def run_repl(
cwd=cwd,
model=model,
max_turns=max_turns,
effort=effort,
base_url=base_url,
system_prompt=system_prompt,
api_key=api_key,
@@ -94,7 +91,6 @@ async def run_task_worker(
cwd: str | None = None,
model: str | None = None,
max_turns: int | None = None,
effort: str | None = None,
base_url: str | None = None,
system_prompt: str | None = None,
api_key: str | None = None,
@@ -139,7 +135,6 @@ async def run_task_worker(
cwd=cwd,
model=model,
max_turns=max_turns,
effort=effort,
base_url=base_url,
system_prompt=system_prompt,
api_key=api_key,
@@ -181,7 +176,6 @@ async def run_print_mode(
cwd: str | None = None,
model: str | None = None,
base_url: str | None = None,
effort: str | None = None,
system_prompt: str | None = None,
append_system_prompt: str | None = None,
api_key: str | None = None,
@@ -212,7 +206,6 @@ async def run_print_mode(
cwd=cwd,
model=model,
max_turns=max_turns,
effort=effort,
base_url=base_url,
system_prompt=system_prompt,
api_key=api_key,
+12 -114
View File
@@ -19,7 +19,6 @@ from openharness.commands import MemoryCommandBackend
from openharness.config.settings import CLAUDE_MODEL_ALIAS_OPTIONS, resolve_model_setting
from openharness.bridge import get_bridge_manager
from openharness.coordinator.coordinator_mode import is_coordinator_mode
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock
from openharness.themes import list_themes
from openharness.engine.stream_events import (
AssistantTextDelta,
@@ -34,7 +33,7 @@ from openharness.engine.stream_events import (
from openharness.output_styles import load_output_styles
from openharness.tasks import get_task_manager
from openharness.ui.coordinator_drain import drain_coordinator_async_agents
from openharness.ui.protocol import BackendEvent, FrontendImageAttachment, FrontendRequest, TranscriptItem
from openharness.ui.protocol import BackendEvent, FrontendRequest, TranscriptItem
from openharness.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime
from openharness.services.session_backend import SessionBackend
@@ -51,7 +50,6 @@ class BackendHostConfig:
model: str | None = None
max_turns: int | None = None
effort: str | None = None
base_url: str | None = None
system_prompt: str | None = None
api_key: str | None = None
@@ -79,7 +77,6 @@ class ReactBackendHost:
self._write_lock = asyncio.Lock()
self._request_queue: asyncio.Queue[FrontendRequest] = asyncio.Queue()
self._permission_requests: dict[str, asyncio.Future[bool]] = {}
self._edit_approval_requests: dict[str, asyncio.Future[str]] = {}
self._question_requests: dict[str, asyncio.Future[str]] = {}
self._permission_lock = asyncio.Lock()
self._busy = False
@@ -87,13 +84,11 @@ class ReactBackendHost:
self._active_request_task: asyncio.Task[bool] | None = None
# Track last tool input per name for rich event emission
self._last_tool_inputs: dict[str, dict] = {}
self._edit_always_approved = False
async def run(self) -> int:
self._bundle = await build_runtime(
model=self._config.model,
max_turns=self._config.max_turns,
effort=self._config.effort,
base_url=self._config.base_url,
system_prompt=self._config.system_prompt,
api_key=self._config.api_key,
@@ -105,7 +100,6 @@ class ReactBackendHost:
restore_tool_metadata=self._config.restore_tool_metadata,
permission_prompt=self._ask_permission,
ask_user_prompt=self._ask_question,
edit_approval_prompt=self._ask_edit_approval,
enforce_max_turns=self._config.enforce_max_turns,
permission_mode=self._config.permission_mode,
session_backend=self._config.session_backend,
@@ -167,13 +161,11 @@ class ReactBackendHost:
await self._emit(BackendEvent(type="error", message="Session is busy"))
continue
line = (request.line or "").strip()
if not line and not request.images:
if not line:
continue
self._busy = True
try:
should_continue = await self._run_active_request(
self._process_line(line, images=request.images)
)
should_continue = await self._run_active_request(self._process_line(line))
finally:
self._busy = False
if not should_continue:
@@ -201,11 +193,6 @@ class ReactBackendHost:
except Exception as exc: # pragma: no cover - defensive protocol handling
await self._emit(BackendEvent(type="error", message=f"Invalid request: {exc}"))
continue
if request.type == "permission_response" and request.request_id in self._edit_approval_requests:
future = self._edit_approval_requests[request.request_id]
if not future.done():
future.set_result(_edit_approval_reply_from_request(request))
continue
if request.type == "permission_response" and request.request_id in self._permission_requests:
future = self._permission_requests[request.request_id]
if not future.done():
@@ -247,23 +234,10 @@ class ReactBackendHost:
return
task.cancel()
async def _process_line(
self,
line: str,
*,
transcript_line: str | None = None,
images: list[FrontendImageAttachment] | None = None,
) -> bool:
async def _process_line(self, line: str, *, transcript_line: str | None = None) -> bool:
assert self._bundle is not None
user_message = _build_user_message_with_images(line, images or [])
await self._emit(
BackendEvent(
type="transcript_item",
item=TranscriptItem(
role="user",
text=transcript_line or _format_transcript_line(line, images or []),
),
)
BackendEvent(type="transcript_item", item=TranscriptItem(role="user", text=transcript_line or line))
)
async def _print_system(message: str) -> None:
@@ -368,14 +342,13 @@ class ReactBackendHost:
async def _clear_output() -> None:
await self._emit(BackendEvent(type="clear_transcript"))
handle_line_kwargs: dict[str, Any] = {
"print_system": _print_system,
"render_event": _render_event,
"clear_output": _clear_output,
}
if user_message is not None:
handle_line_kwargs["user_message"] = user_message
should_continue = await handle_line(self._bundle, line, **handle_line_kwargs)
should_continue = await handle_line(
self._bundle,
line,
print_system=_print_system,
render_event=_render_event,
clear_output=_clear_output,
)
if is_coordinator_mode():
await drain_coordinator_async_agents(
self._bundle,
@@ -576,7 +549,6 @@ class ReactBackendHost:
{"value": "low", "label": "Low", "description": "Fastest responses", "active": settings.effort == "low"},
{"value": "medium", "label": "Medium", "description": "Balanced reasoning", "active": settings.effort == "medium"},
{"value": "high", "label": "High", "description": "Deepest reasoning", "active": settings.effort == "high"},
{"value": "xhigh", "label": "XHigh", "description": "Extra high reasoning", "active": settings.effort == "xhigh"},
]
await self._emit(
BackendEvent(
@@ -783,45 +755,6 @@ class ReactBackendHost:
finally:
self._permission_requests.pop(request_id, None)
def _current_permission_mode(self) -> str:
if self._bundle is None:
return str(self._config.permission_mode or "")
return str(self._bundle.app_state.get().permission_mode or "")
async def _ask_edit_approval(self, path: str, diff: str, added: int, removed: int) -> str:
if self._edit_always_approved or self._current_permission_mode() == "full_auto":
return "always"
async with self._permission_lock:
request_id = uuid4().hex
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
self._edit_approval_requests[request_id] = future
await self._emit(
BackendEvent(
type="modal_request",
modal={
"kind": "edit_diff",
"request_id": request_id,
"path": path,
"diff": diff,
"added": added,
"removed": removed,
},
)
)
try:
reply = await asyncio.wait_for(future, timeout=300)
except asyncio.TimeoutError:
log.warning("Edit approval request %s timed out after 300s, denying", request_id)
reply = "reject"
finally:
self._edit_approval_requests.pop(request_id, None)
await self._emit(BackendEvent(type="modal_request", modal=None))
if reply == "always":
self._edit_always_approved = True
return reply
async def _ask_question(self, question: str) -> str:
request_id = uuid4().hex
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
@@ -854,37 +787,10 @@ class ReactBackendHost:
sys.stdout.flush()
def _build_user_message_with_images(
line: str,
images: list[FrontendImageAttachment],
) -> ConversationMessage | None:
if not images:
return None
content = [TextBlock(text=line or "Please analyze the attached image.")]
content.extend(
ImageBlock(
media_type=image.media_type,
data=image.data,
source_path=image.source_path or "",
)
for image in images
)
return ConversationMessage.from_user_content(content)
def _format_transcript_line(line: str, images: list[FrontendImageAttachment]) -> str:
if not images:
return line
noun = "image" if len(images) == 1 else "images"
attachment_line = f"[{len(images)} {noun} attached]"
return f"{line}\n{attachment_line}" if line else attachment_line
async def run_backend_host(
*,
model: str | None = None,
max_turns: int | None = None,
effort: str | None = None,
base_url: str | None = None,
system_prompt: str | None = None,
api_key: str | None = None,
@@ -909,7 +815,6 @@ async def run_backend_host(
BackendHostConfig(
model=model,
max_turns=max_turns,
effort=effort,
base_url=base_url,
system_prompt=system_prompt,
api_key=api_key,
@@ -932,10 +837,3 @@ async def run_backend_host(
__all__ = ["run_backend_host", "ReactBackendHost", "BackendHostConfig"]
def _edit_approval_reply_from_request(request: FrontendRequest) -> str:
reply = (request.permission_reply or "").strip().lower()
if reply in {"once", "always", "reject"}:
return reply
return "once" if bool(request.allowed) else "reject"
+1 -26
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from openharness.state.app_state import AppState
from openharness.bridge.manager import BridgeSessionRecord
@@ -12,28 +12,6 @@ from openharness.mcp.types import McpConnectionStatus
from openharness.tasks.types import TaskRecord
class FrontendImageAttachment(BaseModel):
"""Base64 image payload submitted from the React TUI."""
media_type: str
data: str
source_path: str | None = None
@field_validator("media_type")
@classmethod
def _validate_media_type(cls, value: str) -> str:
if not value.startswith("image/"):
raise ValueError("image attachment media_type must start with image/")
return value
@field_validator("data")
@classmethod
def _validate_data(cls, value: str) -> str:
if not value.strip():
raise ValueError("image attachment data is required")
return value
class FrontendRequest(BaseModel):
"""One request sent from the React frontend to the Python backend."""
@@ -52,9 +30,7 @@ class FrontendRequest(BaseModel):
value: str | None = None
request_id: str | None = None
allowed: bool | None = None
permission_reply: str | None = None
answer: str | None = None
images: list[FrontendImageAttachment] = Field(default_factory=list)
class TranscriptItem(BaseModel):
@@ -240,7 +216,6 @@ def _format_permission_mode(raw: str) -> str:
__all__ = [
"BackendEvent",
"FrontendImageAttachment",
"FrontendRequest",
"TaskSnapshot",
"TranscriptItem",
-5
View File
@@ -83,7 +83,6 @@ def build_backend_command(
cwd: str | None = None,
model: str | None = None,
max_turns: int | None = None,
effort: str | None = None,
base_url: str | None = None,
system_prompt: str | None = None,
api_key: str | None = None,
@@ -98,8 +97,6 @@ def build_backend_command(
command.extend(["--model", model])
if max_turns is not None:
command.extend(["--max-turns", str(max_turns)])
if effort:
command.extend(["--effort", effort])
if base_url:
command.extend(["--base-url", base_url])
if system_prompt:
@@ -119,7 +116,6 @@ async def launch_react_tui(
cwd: str | None = None,
model: str | None = None,
max_turns: int | None = None,
effort: str | None = None,
base_url: str | None = None,
system_prompt: str | None = None,
api_key: str | None = None,
@@ -152,7 +148,6 @@ async def launch_react_tui(
cwd=cwd or str(Path.cwd()),
model=model,
max_turns=max_turns,
effort=effort,
base_url=base_url,
system_prompt=system_prompt,
api_key=api_key,
+10 -101
View File
@@ -45,51 +45,11 @@ from openharness.keybindings import load_keybindings
PermissionPrompt = Callable[[str, str], Awaitable[bool]]
AskUserPrompt = Callable[[str], Awaitable[str]]
EditApprovalPrompt = Callable[[str, str, int, int], Awaitable[str]]
SystemPrinter = Callable[[str], Awaitable[None]]
StreamRenderer = Callable[[StreamEvent], Awaitable[None]]
ClearHandler = Callable[[], Awaitable[None]]
def _resolve_image_generation_config(settings) -> dict[str, str]:
"""Resolve image generation configuration from settings, environment, and Codex auth."""
from openharness.config.settings import ImageGenerationConfig, ProviderProfile
cfg = settings.image_generation
env_cfg = ImageGenerationConfig.from_env()
resolved = {
"provider": cfg.provider or env_cfg.provider,
"model": cfg.model or env_cfg.model,
"api_key": cfg.api_key or env_cfg.api_key,
"base_url": cfg.base_url or env_cfg.base_url,
"codex_model": cfg.codex_model or env_cfg.codex_model,
"codex_base_url": cfg.codex_base_url or env_cfg.codex_base_url,
}
try:
codex_profile = settings.merged_profiles().get("codex") or ProviderProfile(
label="Codex Subscription",
provider="openai_codex",
api_format="openai",
auth_source="codex_subscription",
default_model="gpt-5.4",
)
codex_settings = settings.model_copy(
update={
"active_profile": "codex",
"profiles": {**settings.profiles, "codex": codex_profile},
}
).materialize_active_profile()
codex_auth = codex_settings.resolve_auth()
resolved["codex_auth_token"] = codex_auth.value
resolved["codex_base_url"] = resolved["codex_base_url"] or (codex_settings.base_url or "")
resolved["codex_model"] = resolved["codex_model"] or codex_settings.model
except Exception:
pass
return resolved
def _resolve_vision_config(settings) -> dict[str, str]:
"""Resolve the vision model configuration from settings or environment.
@@ -198,8 +158,13 @@ def _resolve_api_client_from_settings(settings) -> SupportsStreamingMessages:
def _safe_resolve_auth():
try:
return settings.resolve_auth()
except Exception as exc:
_print_auth_resolution_error(settings, exc)
except (ValueError, Exception):
print(
"Error: No API key configured.\n"
" Run `oh auth login` to set up authentication, or set the\n"
" ANTHROPIC_API_KEY (or OPENAI_API_KEY) environment variable.",
file=sys.stderr,
)
raise SystemExit(1)
if settings.api_format == "copilot":
@@ -238,46 +203,12 @@ def _resolve_api_client_from_settings(settings) -> SupportsStreamingMessages:
)
def _print_auth_resolution_error(settings, exc: Exception) -> None:
"""Render auth failures without collapsing subscription errors into API-key advice."""
try:
profile_name, profile = settings.resolve_profile()
auth_source = (getattr(profile, "auth_source", "") or "").strip()
except Exception:
profile_name = ""
auth_source = ""
message = str(exc).strip() or exc.__class__.__name__
if auth_source in {"claude_subscription", "codex_subscription"}:
login_command = "claude-login" if auth_source == "claude_subscription" else "codex-login"
provider_name = profile_name or (
"claude-subscription" if auth_source == "claude_subscription" else "codex"
)
print(
f"Error: {message}\n"
f" This profile uses subscription auth, not an API key.\n"
f" Run `oh auth {login_command}` to bind the local CLI session, then\n"
f" run `oh provider use {provider_name}` to activate it.",
file=sys.stderr,
)
return
print(
"Error: No API key configured.\n"
f" {message}\n"
" Run `oh auth login` to set up authentication, or set the\n"
" ANTHROPIC_API_KEY (or OPENAI_API_KEY) environment variable.",
file=sys.stderr,
)
async def build_runtime(
*,
prompt: str | None = None,
cwd: str | None = None,
model: str | None = None,
max_turns: int | None = None,
effort: str | None = None,
base_url: str | None = None,
system_prompt: str | None = None,
api_key: str | None = None,
@@ -286,7 +217,6 @@ async def build_runtime(
api_client: SupportsStreamingMessages | None = None,
permission_prompt: PermissionPrompt | None = None,
ask_user_prompt: AskUserPrompt | None = None,
edit_approval_prompt: EditApprovalPrompt | None = None,
restore_messages: list[dict] | None = None,
restore_tool_metadata: dict[str, object] | None = None,
enforce_max_turns: bool = True,
@@ -302,7 +232,6 @@ async def build_runtime(
settings_overrides: dict[str, Any] = {
"model": model,
"max_turns": max_turns,
"effort": effort,
"base_url": base_url,
"system_prompt": system_prompt,
"api_key": api_key,
@@ -421,9 +350,7 @@ async def build_runtime(
"extra_skill_dirs": normalized_skill_dirs,
"extra_plugin_roots": normalized_plugin_roots,
"session_id": session_id,
"edit_approval_prompt": edit_approval_prompt,
"vision_model_config": _resolve_vision_config(settings),
"image_generation_config": _resolve_image_generation_config(settings),
**restored_metadata,
},
)
@@ -496,9 +423,6 @@ async def close_runtime(bundle: RuntimeBundle) -> None:
HookEvent.SESSION_END,
{"cwd": bundle.cwd, "event": HookEvent.SESSION_END.value},
)
close_api_client = getattr(bundle.api_client, "close", None)
if close_api_client is not None:
await close_api_client()
def _last_user_text(messages: list[ConversationMessage]) -> str:
@@ -604,17 +528,6 @@ def refresh_runtime_client(bundle: RuntimeBundle) -> None:
default_model=settings.model,
)
bundle.engine.set_model(settings.model)
bundle.engine.set_effort(settings.effort)
bundle.engine.set_permission_checker(PermissionChecker(settings.permission))
system_prompt = build_runtime_system_prompt(
settings,
cwd=bundle.cwd,
latest_user_prompt=_last_user_text(bundle.engine.messages),
extra_skill_dirs=bundle.extra_skill_dirs,
extra_plugin_roots=bundle.extra_plugin_roots,
include_project_memory=bundle.include_project_memory,
)
bundle.engine.set_system_prompt(system_prompt)
sync_app_state(bundle)
@@ -625,7 +538,6 @@ async def handle_line(
print_system: SystemPrinter,
render_event: StreamRenderer,
clear_output: ClearHandler,
user_message: ConversationMessage | None = None,
) -> bool:
"""Handle one submitted line for either headless or TUI rendering."""
if not bundle.external_api_client:
@@ -648,9 +560,7 @@ async def handle_line(
memory_backend=bundle.memory_backend,
include_project_memory=bundle.include_project_memory,
)
parsed = None if user_message is not None else (
bundle.commands.lookup(line) or lookup_skill_slash_command(line, command_context)
)
parsed = bundle.commands.lookup(line) or lookup_skill_slash_command(line, command_context)
if parsed is not None:
command, args = parsed
result = await command.handler(
@@ -732,18 +642,17 @@ async def handle_line(
settings = bundle.current_settings()
if bundle.enforce_max_turns:
bundle.engine.set_max_turns(settings.max_turns)
latest_user_prompt = line or (user_message.text if user_message is not None else "")
system_prompt = build_runtime_system_prompt(
settings,
cwd=bundle.cwd,
latest_user_prompt=latest_user_prompt,
latest_user_prompt=line,
extra_skill_dirs=bundle.extra_skill_dirs,
extra_plugin_roots=bundle.extra_plugin_roots,
include_project_memory=bundle.include_project_memory,
)
bundle.engine.set_system_prompt(system_prompt)
try:
async for event in bundle.engine.submit_message(user_message or line):
async for event in bundle.engine.submit_message(line):
await render_event(event)
except MaxTurnsExceeded as exc:
await print_system(f"Stopped after {exc.max_turns} turns (max_turns).")
+2 -8
View File
@@ -46,10 +46,7 @@ def exclusive_file_lock(
@contextmanager
def _exclusive_posix_lock(lock_path: Path) -> Iterator[None]:
try:
import fcntl
except ImportError as exc:
raise SwarmLockUnavailableError(f"fcntl not available: {exc}") from exc
import fcntl
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.touch(exist_ok=True)
@@ -63,10 +60,7 @@ def _exclusive_posix_lock(lock_path: Path) -> Iterator[None]:
@contextmanager
def _exclusive_windows_lock(lock_path: Path) -> Iterator[None]:
try:
import msvcrt
except ImportError as exc:
raise SwarmLockUnavailableError(f"msvcrt not available: {exc}") from exc
import msvcrt
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+b") as lock_file:
+11 -224
View File
@@ -5,8 +5,7 @@ from __future__ import annotations
import asyncio
import ipaddress
import socket
from enum import Enum
from urllib.parse import ParseResult, urljoin, urlparse
from urllib.parse import urljoin, urlparse
import httpx
@@ -16,31 +15,6 @@ _DEFAULT_PORTS = {
"https": 443,
}
_IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
_IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
_SYNTHETIC_DNS_CIDRS_SETTING = "web.synthetic_dns_cidrs"
_RESOLUTION_MODE_SETTING = "web.resolution_mode"
_PROXY_SETTING = "web.proxy"
_LOCAL_HOSTNAMES = {
"localhost",
"localhost.localdomain",
"metadata.google.internal",
}
_LOCAL_HOST_SUFFIXES = (
".localhost",
".local",
".localdomain",
".internal",
".cluster.local",
)
class ResolutionMode(str, Enum):
"""How outbound web tools should interpret target DNS resolution."""
AUTO = "auto"
DIRECT = "direct"
PROXY = "proxy"
SYNTHETIC_DNS = "synthetic_dns"
class NetworkGuardError(ValueError):
@@ -58,73 +32,22 @@ def validate_http_url(url: str) -> None:
raise NetworkGuardError("URLs with embedded credentials are not allowed")
def get_web_resolution_mode(
proxy: str | None = None,
*,
configured_mode: str | None = None,
) -> ResolutionMode:
"""Resolve the configured web target validation mode."""
raw_mode = (configured_mode or "").strip().lower().replace("-", "_")
if not raw_mode or raw_mode == ResolutionMode.AUTO.value:
return ResolutionMode.PROXY if proxy else ResolutionMode.DIRECT
try:
mode = ResolutionMode(raw_mode)
except ValueError as exc:
allowed = ", ".join(mode.value for mode in ResolutionMode)
raise NetworkGuardError(f"{_RESOLUTION_MODE_SETTING} must be one of: {allowed}") from exc
if mode is ResolutionMode.AUTO:
return ResolutionMode.PROXY if proxy else ResolutionMode.DIRECT
if mode is ResolutionMode.PROXY and not proxy:
raise NetworkGuardError(f"{_RESOLUTION_MODE_SETTING}=proxy requires {_PROXY_SETTING}")
return mode
def parse_synthetic_dns_cidrs(value: str | None = None) -> tuple[_IPNetwork, ...]:
"""Parse user-declared synthetic DNS CIDRs."""
raw_value = "" if value is None else value
entries = [entry.strip() for entry in raw_value.split(",") if entry.strip()]
networks: list[_IPNetwork] = []
for entry in entries:
try:
networks.append(ipaddress.ip_network(entry, strict=False))
except ValueError as exc:
raise NetworkGuardError(f"invalid {_SYNTHETIC_DNS_CIDRS_SETTING} entry: {entry}") from exc
return tuple(networks)
async def ensure_public_http_url(url: str) -> None:
"""Reject loopback, private-network, and other non-public HTTP targets."""
parsed = _validated_parsed_http_url(url)
hostname = _normalized_hostname(parsed.hostname)
literal = _parse_ip_literal(hostname)
if literal is not None:
_ensure_global_literal_ip(literal)
return
_ensure_not_local_hostname(hostname)
validate_http_url(url)
parsed = urlparse(url)
assert parsed.hostname is not None # covered by validate_http_url
port = parsed.port or _DEFAULT_PORTS[parsed.scheme]
addresses = await _resolve_host_addresses(hostname, port)
addresses = await _resolve_host_addresses(parsed.hostname, port)
if not addresses:
raise NetworkGuardError(f"target host did not resolve: {hostname}")
raise NetworkGuardError(f"target host did not resolve: {parsed.hostname}")
blocked = sorted({str(address) for address in addresses if not address.is_global})
if blocked:
raise NetworkGuardError(_format_blocked_addresses(blocked, include_synthetic_dns_hint=True))
async def ensure_http_url_allowed(
url: str,
*,
mode: ResolutionMode,
synthetic_cidrs: tuple[_IPNetwork, ...] = (),
) -> None:
"""Validate one outbound URL according to the configured resolution mode."""
if mode is ResolutionMode.DIRECT:
await ensure_public_http_url(url)
return
if mode is ResolutionMode.PROXY:
_ensure_proxy_safe_http_url(url)
return
await _ensure_synthetic_dns_safe_http_url(url, synthetic_cidrs=synthetic_cidrs)
rendered = ", ".join(blocked[:3])
if len(blocked) > 3:
rendered += ", ..."
raise NetworkGuardError(f"target resolves to non-public address(es): {rendered}")
async def fetch_public_http_response(
@@ -134,38 +57,18 @@ async def fetch_public_http_response(
params: dict[str, str] | None = None,
timeout: float = 15.0,
max_redirects: int = 5,
proxy: str | None = None,
) -> httpx.Response:
"""Fetch one HTTP resource while validating every redirect hop."""
current_url = url
current_params = params
web_settings = _load_configured_web_settings()
resolved_proxy = proxy if proxy is not None else web_settings.proxy
if resolved_proxy:
validate_http_url(resolved_proxy)
mode = get_web_resolution_mode(
resolved_proxy,
configured_mode=web_settings.resolution_mode,
)
synthetic_cidrs = (
parse_synthetic_dns_cidrs(",".join(web_settings.synthetic_dns_cidrs))
if mode is ResolutionMode.SYNTHETIC_DNS
else ()
)
async with httpx.AsyncClient(
follow_redirects=False,
timeout=timeout,
trust_env=False,
proxy=resolved_proxy,
) as client:
for redirect_count in range(max_redirects + 1):
await ensure_http_url_allowed(
current_url,
mode=mode,
synthetic_cidrs=synthetic_cidrs,
)
await ensure_public_http_url(current_url)
response = await client.get(
current_url,
params=current_params,
@@ -186,75 +89,6 @@ async def fetch_public_http_response(
raise NetworkGuardError("request failed before receiving a response")
class _ConfiguredWebSettings:
def __init__(
self,
*,
proxy: str | None,
resolution_mode: str,
synthetic_dns_cidrs: list[str],
) -> None:
self.proxy = proxy
self.resolution_mode = resolution_mode
self.synthetic_dns_cidrs = synthetic_dns_cidrs
def _load_configured_web_settings() -> _ConfiguredWebSettings:
"""Load persisted web settings, including environment overrides."""
from openharness.config import load_settings
web = load_settings().web
return _ConfiguredWebSettings(
proxy=web.proxy,
resolution_mode=web.resolution_mode,
synthetic_dns_cidrs=list(web.synthetic_dns_cidrs),
)
def _ensure_proxy_safe_http_url(url: str) -> None:
"""Validate a URL whose hostname will be resolved by an explicit proxy."""
parsed = _validated_parsed_http_url(url)
hostname = _normalized_hostname(parsed.hostname)
literal = _parse_ip_literal(hostname)
if literal is not None:
_ensure_global_literal_ip(literal)
return
_ensure_not_local_hostname(hostname)
async def _ensure_synthetic_dns_safe_http_url(
url: str,
*,
synthetic_cidrs: tuple[_IPNetwork, ...],
) -> None:
"""Validate a URL in a user-declared synthetic DNS environment."""
if not synthetic_cidrs:
raise NetworkGuardError(
f"{ResolutionMode.SYNTHETIC_DNS.value} mode requires {_SYNTHETIC_DNS_CIDRS_SETTING}"
)
parsed = _validated_parsed_http_url(url)
hostname = _normalized_hostname(parsed.hostname)
literal = _parse_ip_literal(hostname)
if literal is not None:
_ensure_global_literal_ip(literal)
return
_ensure_not_local_hostname(hostname)
port = parsed.port or _DEFAULT_PORTS[parsed.scheme]
addresses = await _resolve_host_addresses(hostname, port)
if not addresses:
raise NetworkGuardError(f"target host did not resolve: {hostname}")
blocked = sorted(
{
str(address)
for address in addresses
if not address.is_global and not _address_in_networks(address, synthetic_cidrs)
}
)
if blocked:
raise NetworkGuardError(_format_blocked_addresses(blocked))
async def _resolve_host_addresses(host: str, port: int) -> set[_IPAddress]:
"""Resolve a host into concrete IP addresses."""
literal = _parse_ip_literal(host)
@@ -280,8 +114,6 @@ async def _resolve_host_addresses(host: str, port: int) -> set[_IPAddress]:
candidate = sockaddr[0]
else:
continue
if not isinstance(candidate, str):
continue
parsed = _parse_ip_literal(candidate)
if parsed is not None:
addresses.add(parsed)
@@ -293,48 +125,3 @@ def _parse_ip_literal(value: str) -> _IPAddress | None:
return ipaddress.ip_address(value)
except ValueError:
return None
def _validated_parsed_http_url(url: str) -> ParseResult:
validate_http_url(url)
parsed = urlparse(url)
assert parsed.hostname is not None # covered by validate_http_url
return parsed
def _normalized_hostname(hostname: str | None) -> str:
assert hostname is not None # covered by validate_http_url
return hostname.rstrip(".").lower()
def _ensure_global_literal_ip(address: _IPAddress) -> None:
if not address.is_global:
raise NetworkGuardError(f"target resolves to non-public address(es): {address}")
def _ensure_not_local_hostname(hostname: str) -> None:
if hostname in _LOCAL_HOSTNAMES or any(hostname.endswith(suffix) for suffix in _LOCAL_HOST_SUFFIXES):
raise NetworkGuardError(f"local hostnames are not allowed: {hostname}")
if "." not in hostname:
raise NetworkGuardError(f"single-label hostnames are not allowed: {hostname}")
def _address_in_networks(address: _IPAddress, networks: tuple[_IPNetwork, ...]) -> bool:
return any(address.version == network.version and address in network for network in networks)
def _format_blocked_addresses(
blocked: list[str],
*,
include_synthetic_dns_hint: bool = False,
) -> str:
rendered = ", ".join(blocked[:3])
if len(blocked) > 3:
rendered += ", ..."
message = f"target resolves to non-public address(es): {rendered}"
if include_synthetic_dns_hint:
message += (
"; if this domain intentionally resolves through synthetic DNS, configure "
"web.resolution_mode=synthetic_dns and web.synthetic_dns_cidrs=<cidr>"
)
return message
+1 -24
View File
@@ -100,26 +100,6 @@ def test_convert_messages_to_codex():
}
def test_convert_user_message_with_tool_result_before_text_to_codex():
messages = [
ConversationMessage(
role="user",
content=[
ToolResultBlock(tool_use_id="call_123", content="done", is_error=False),
TextBlock(text="next request"),
],
)
]
converted = _convert_messages_to_codex(messages)
assert converted == [
{"type": "function_call_output", "call_id": "call_123", "output": "done"},
{"role": "user", "content": [{"type": "input_text", "text": "next request"}]},
]
def test_convert_multimodal_user_message_to_codex():
messages = [
ConversationMessage(
@@ -188,10 +168,9 @@ async def test_codex_client_streams_text(monkeypatch):
client = CodexApiClient(_fake_codex_token())
request = ApiMessageRequest(
model="gpt-5.5",
model="gpt-5.4",
messages=[ConversationMessage.from_user_text("hi")],
system_prompt="Be helpful.",
effort="xhigh",
)
events = [event async for event in client.stream_message(request)]
@@ -202,8 +181,6 @@ async def test_codex_client_streams_text(monkeypatch):
assert complete.usage.output_tokens == 3
assert sink["url"].endswith("/codex/responses")
assert sink["json"]["instructions"] == "Be helpful."
assert sink["json"]["model"] == "gpt-5.5"
assert sink["json"]["reasoning"] == {"effort": "xhigh"}
assert sink["headers"]["OpenAI-Beta"] == "responses=experimental"
-62
View File
@@ -11,7 +11,6 @@ import pytest
from openharness.api.client import ApiMessageRequest
from openharness.api.openai_client import (
OpenAICompatibleClient,
_convert_assistant_message,
_convert_messages_to_openai,
_convert_tools_to_openai,
_normalize_openai_base_url,
@@ -436,64 +435,3 @@ class TestStripThinkBlocks:
visible, buf = _strip_think_blocks(buf)
assert visible == "answer"
assert buf == ""
class TestReasoningContentEmission:
"""``reasoning_content`` is a non-standard field. It must round-trip
when the streaming parser captured non-empty reasoning, but the
legacy "emit empty string when there are tool calls" behaviour now
requires opt-in via ``OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT=1``.
Strict-OpenAI providers (Cerebras, NVIDIA NIM, OpenAI direct) reject
requests carrying the field with a ``wrong_api_format`` 400, so the
default-off behaviour fixes them out-of-the-box; Kimi-on-Anthropic
users opt in via env var.
"""
def _msg_with_tool_use(self, *, reasoning: str | None = None) -> ConversationMessage:
msg = ConversationMessage(
role="assistant",
content=[
TextBlock(text="ok"),
ToolUseBlock(id="tool_1", name="read_file", input={"path": "x"}),
],
)
if reasoning is not None:
msg._reasoning = reasoning # type: ignore[attr-defined]
return msg
def test_omits_reasoning_when_no_captured_text(self, monkeypatch):
monkeypatch.delenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", raising=False)
out = _convert_assistant_message(self._msg_with_tool_use())
assert "reasoning_content" not in out
def test_replays_captured_reasoning(self, monkeypatch):
monkeypatch.delenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", raising=False)
out = _convert_assistant_message(self._msg_with_tool_use(reasoning="thinking…"))
assert out["reasoning_content"] == "thinking…"
def test_emits_empty_when_opted_in(self, monkeypatch):
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", "1")
out = _convert_assistant_message(self._msg_with_tool_use())
assert out["reasoning_content"] == ""
def test_opt_in_truthy_values(self, monkeypatch):
for v in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", v)
out = _convert_assistant_message(self._msg_with_tool_use())
assert out.get("reasoning_content") == "", f"value={v!r}"
def test_opt_in_falsy_values(self, monkeypatch):
for v in ("0", "false", "no", "off", ""):
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", v)
out = _convert_assistant_message(self._msg_with_tool_use())
assert "reasoning_content" not in out, f"value={v!r} should not opt in"
def test_no_tool_calls_never_emits_empty(self, monkeypatch):
# Pure-text assistant messages have always omitted the field; the
# opt-in is scoped to tool-use messages where Kimi specifically
# demands the placeholder.
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", "1")
msg = ConversationMessage(role="assistant", content=[TextBlock(text="hi")])
out = _convert_assistant_message(msg)
assert "reasoning_content" not in out
@@ -1,64 +0,0 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from openharness.channels.bus.queue import MessageBus
from openharness.channels.impl.manager import ChannelManager
from openharness.channels.impl.telegram import TelegramChannel, silence_telegram_token_url_loggers
from openharness.config.schema import Config, TelegramConfig
def test_silence_telegram_token_url_loggers_raises_dependency_log_levels():
for name in ("httpx", "httpcore", "telegram.ext"):
logging.getLogger(name).setLevel(logging.INFO)
silence_telegram_token_url_loggers()
for name in ("httpx", "httpcore", "telegram.ext"):
assert logging.getLogger(name).level == logging.WARNING
@pytest.mark.asyncio
async def test_telegram_start_and_help_use_configured_bot_name():
channel = TelegramChannel(TelegramConfig(token="token", bot_name="ohmo", allow_from=["*"]), MessageBus())
message = SimpleNamespace(chat_id=1, reply_text=AsyncMock())
user = SimpleNamespace(first_name="Jabin")
update = SimpleNamespace(message=message, effective_user=user)
await channel._on_start(update, SimpleNamespace())
await channel._on_help(update, SimpleNamespace())
start_text = message.reply_text.await_args_list[0].args[0]
help_text = message.reply_text.await_args_list[1].args[0]
assert "I'm ohmo" in start_text
assert "ohmo commands" in help_text
assert "nanobot" not in start_text
assert "nanobot" not in help_text
@pytest.mark.asyncio
async def test_telegram_error_handler_records_last_error():
channel = TelegramChannel(TelegramConfig(token="token", allow_from=["*"]), MessageBus())
await channel._on_error(None, SimpleNamespace(error=RuntimeError("poll failed")))
assert channel.last_error == "poll failed"
@pytest.mark.asyncio
async def test_channel_manager_records_start_failure_on_channel():
bus = MessageBus()
manager = ChannelManager(Config(), bus)
class BrokenChannel:
async def start(self):
raise RuntimeError("boom")
channel = BrokenChannel()
await manager._start_channel("telegram", channel) # type: ignore[arg-type]
assert getattr(channel, "last_error") == "boom"
+1 -161
View File
@@ -8,18 +8,12 @@ from pathlib import Path
import pytest
import openharness.commands.registry as registry_module
from openharness.commands.registry import (
CommandContext,
MemoryCommandBackend,
create_default_command_registry,
lookup_skill_slash_command,
)
from openharness.commands.registry import CommandContext, create_default_command_registry, lookup_skill_slash_command
from openharness.autopilot import RepoVerificationStep
from openharness.config.paths import get_feedback_log_path, get_project_issue_file, get_project_pr_comments_file
from openharness.config.settings import load_settings, save_settings, Settings
from openharness.engine.messages import ConversationMessage, TextBlock
from openharness.engine.query_engine import QueryEngine
from openharness.memory.paths import get_project_memory_dir
from openharness.mcp.types import McpHttpServerConfig, McpStdioServerConfig
from openharness.permissions import PermissionChecker
from openharness.plugins.types import PluginCommandDefinition
@@ -167,87 +161,6 @@ async def test_bridge_command_supports_explicit_remote_admin_opt_in(tmp_path: Pa
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_autopilot_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/autopilot run-next")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_autopilot_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/autopilot run-next")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_diff_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/diff full")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_diff_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/diff full")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_project_context_commands_are_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
for payload in (
"/issue set Remote supplied issue :: marker",
"/pr_comments add src/app.py:1 :: marker",
):
command, _ = registry.lookup(payload)
assert command is not None
assert command.remote_invocable is False, payload
@pytest.mark.asyncio
async def test_project_context_commands_support_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
for payload in (
"/issue set Remote supplied issue :: marker",
"/pr_comments add src/app.py:1 :: marker",
):
command, _ = registry.lookup(payload)
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True, payload
@pytest.mark.asyncio
async def test_tasks_command_is_marked_local_only(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/tasks run id")
assert command is not None
assert command.remote_invocable is False
@pytest.mark.asyncio
async def test_tasks_command_supports_explicit_remote_admin_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, _ = registry.lookup("/tasks run id")
assert command is not None
assert getattr(command, "remote_admin_opt_in", False) is True
@pytest.mark.asyncio
async def test_sensitive_control_plane_commands_are_local_only(tmp_path: Path, monkeypatch):
@@ -261,11 +174,7 @@ async def test_sensitive_control_plane_commands_are_local_only(tmp_path: Path, m
"/mcp",
"/provider",
"/model show",
"/commit remote requested commit",
"/ship",
"/resume",
"/resume session-from-another-sender",
"/summary 10",
):
command, _ = registry.lookup(payload)
assert command is not None
@@ -936,67 +845,6 @@ async def test_memory_command_manages_entries(tmp_path: Path, monkeypatch):
remove_result = await remove_command.handler(remove_args, context)
assert "Removed memory entry" in remove_result.message
list_after_remove = await list_command.handler(list_args, context)
assert "pytest_tips.md" not in list_after_remove.message
show_after_remove = await show_command.handler(show_args, context)
assert show_after_remove.message == "Memory entry not found: pytest_tips"
@pytest.mark.asyncio
async def test_memory_command_migrates_entries(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
memory_dir = get_project_memory_dir(tmp_path)
legacy = memory_dir / "legacy.md"
legacy.write_text("legacy command note\n", encoding="utf-8")
registry = create_default_command_registry()
context = _make_context(tmp_path)
dry_command, dry_args = registry.lookup("/memory migrate --dry-run")
dry_result = await dry_command.handler(dry_args, context)
assert "Memory migration dry run." in dry_result.message
assert "Changed: 1" in dry_result.message
assert "schema_version" not in legacy.read_text(encoding="utf-8")
apply_command, apply_args = registry.lookup("/memory migrate --apply")
apply_result = await apply_command.handler(apply_args, context)
assert "Memory migration applied." in apply_result.message
assert "Backup:" in apply_result.message
assert "schema_version: 1" in legacy.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_memory_migrate_uses_backend_defaults_not_label(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
memory_dir = tmp_path / "custom-memory"
memory_dir.mkdir()
legacy = memory_dir / "legacy.md"
legacy.write_text("personal preference note\n", encoding="utf-8")
registry = create_default_command_registry()
context = _make_context(tmp_path)
context.memory_backend = MemoryCommandBackend(
label="renamed user notes",
default_type="personal",
default_category="preference",
get_memory_dir=lambda: memory_dir,
get_entrypoint=lambda: memory_dir / "MEMORY.md",
list_files=lambda: [],
add_entry=lambda title, content: memory_dir / f"{title}.md",
remove_entry=lambda name: False,
)
apply_command, apply_args = registry.lookup("/memory migrate --apply")
result = await apply_command.handler(apply_args, context)
migrated = legacy.read_text(encoding="utf-8")
assert "Memory migration applied." in result.message
assert 'type: "personal"' in migrated
assert 'category: "preference"' in migrated
@pytest.mark.asyncio
async def test_compact_summary_and_usage_commands(tmp_path: Path, monkeypatch):
@@ -1076,12 +924,6 @@ async def test_ui_mode_commands_persist_and_update_state(tmp_path: Path, monkeyp
assert load_settings().effort == "high"
assert context.app_state.get().effort == "high"
effort_command, effort_args = registry.lookup("/effort xhigh")
effort_result = await effort_command.handler(effort_args, context)
assert "xhigh" in effort_result.message
assert load_settings().effort == "xhigh"
assert context.app_state.get().effort == "xhigh"
passes_command, passes_args = registry.lookup("/passes 3")
passes_result = await passes_command.handler(passes_args, context)
assert "3" in passes_result.message
@@ -1155,9 +997,7 @@ async def test_agents_session_files_and_reload_plugins_commands(tmp_path: Path,
(tmp_path / "src" / "app.py").write_text("print('hi')\n", encoding="utf-8")
session_command, session_args = registry.lookup("/session")
context.session_id = "session-smoke"
session_result = await session_command.handler(session_args, context)
assert "Session ID: session-smoke" in session_result.message
assert "Session directory:" in session_result.message
session_path_command, session_path_args = registry.lookup("/session path")
-42
View File
@@ -1,42 +0,0 @@
"""Schema regressions for openharness.config.schema channel configs."""
from __future__ import annotations
from openharness.config.schema import TelegramConfig
class TestTelegramConfig:
def test_reply_to_message_field_is_declared_with_true_default(self):
"""Regression for #243: every outbound send raises AttributeError when
``reply_to_message`` is not in the parsed config because the field
existed only as an interactive ``ohmo init`` prompt, not on the
pydantic model. The CLI default is ``True``; the schema default
mirrors that so non-interactive and hand-written configs behave the
same as interactive configs accepting the default.
"""
config = TelegramConfig()
assert config.reply_to_message is True
def test_reply_to_message_accessible_when_legacy_config_omits_field(self):
"""``ohmo init --no-interactive`` and pre-0.1.9 hand-written
``gateway.json`` files don't include ``reply_to_message``. Attribute
access on the parsed instance must not raise that AttributeError
was the root cause of #243 (every outbound Telegram send crashed).
"""
config = TelegramConfig.model_validate(
{"token": "test-token", "chat_id": "12345", "allow_from": ["12345"]}
)
assert config.reply_to_message is True
def test_reply_to_message_explicit_false_is_honored(self):
config = TelegramConfig.model_validate(
{"token": "t", "chat_id": "1", "reply_to_message": False}
)
assert config.reply_to_message is False
def test_bot_name_defaults_to_ohmo(self):
config = TelegramConfig()
assert config.bot_name == "ohmo"
-114
View File
@@ -32,34 +32,22 @@ class TestSettings:
assert s.permission.mode == "default"
assert s.sandbox.enabled is False
assert s.sandbox.filesystem.allow_write == ["."]
assert s.web.resolution_mode == "auto"
assert s.web.synthetic_dns_cidrs == []
def test_resolve_api_key_from_instance(self):
s = Settings(api_key="sk-test-123")
assert s.resolve_api_key() == "sk-test-123"
def test_resolve_api_key_from_env(self, monkeypatch):
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
s = Settings()
assert s.resolve_api_key() == "sk-env-456"
def test_resolve_api_key_prefers_openharness_env(self, monkeypatch):
monkeypatch.setenv("OPENHARNESS_ANTHROPIC_API_KEY", "sk-oh-456")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
s = Settings()
assert s.resolve_api_key() == "sk-oh-456"
def test_resolve_api_key_instance_takes_precedence(self, monkeypatch):
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
s = Settings(api_key="sk-instance-789")
assert s.resolve_api_key() == "sk-instance-789"
def test_resolve_api_key_missing_raises(self, monkeypatch):
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
s = Settings()
@@ -74,23 +62,6 @@ class TestSettings:
# api_key=None should not override the default
assert updated.api_key == ""
def test_merge_cli_overrides_applies_permission_mode(self):
s = Settings()
updated = s.merge_cli_overrides(permission_mode="full_auto")
assert updated.permission.mode == "full_auto"
assert s.permission.mode == "default"
def test_web_settings_env_overrides(self, monkeypatch):
monkeypatch.setenv("OPENHARNESS_WEB_PROXY", "http://proxy.example.com:7890")
monkeypatch.setenv("OPENHARNESS_WEB_RESOLUTION_MODE", "synthetic_dns")
monkeypatch.setenv("OPENHARNESS_WEB_SYNTHETIC_DNS_CIDRS", "100.64.0.0/10,203.0.113.0/24")
updated = _apply_env_overrides(Settings())
assert updated.web.proxy == "http://proxy.example.com:7890"
assert updated.web.resolution_mode == "synthetic_dns"
assert updated.web.synthetic_dns_cidrs == ["100.64.0.0/10", "203.0.113.0/24"]
def test_merge_cli_overrides_returns_new_instance(self):
s = Settings()
updated = s.merge_cli_overrides(model="claude-opus-4-20250514")
@@ -101,7 +72,6 @@ class TestSettings:
"""When api_format=openai, resolve_auth() should use OPENAI_API_KEY
from the environment rather than the flat api_key field which may
contain an Anthropic key from settings.json."""
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-correct")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
s = Settings(api_key="sk-ant-wrong-provider", api_format="openai")
@@ -110,21 +80,9 @@ class TestSettings:
assert auth.value == "sk-openai-correct"
assert "OPENAI" in auth.source
def test_resolve_auth_prefers_openharness_env_for_openai(self, monkeypatch):
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-correct")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
s = Settings(api_key="sk-ant-wrong-provider", api_format="openai")
s = s.sync_active_profile_from_flat_fields()
auth = s.resolve_auth()
assert auth.value == "sk-oh-openai"
assert auth.source == "env:OPENHARNESS_OPENAI_API_KEY"
def test_resolve_auth_falls_back_to_flat_api_key(self, monkeypatch):
"""When no provider-specific env var is set, resolve_auth() should
still fall back to the flat api_key field."""
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
s = Settings(api_key="sk-fallback-key")
@@ -145,32 +103,6 @@ class TestSettings:
s = load_settings(path)
assert s.base_url == "https://relay.example.com/v1"
def test_load_settings_uses_profile_specific_openharness_env_key(self, tmp_path: Path, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-wrong")
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
path = tmp_path / "settings.json"
path.write_text(
Settings(active_profile="openai-compatible").model_dump_json(),
encoding="utf-8",
)
s = load_settings(path)
assert s.active_profile == "openai-compatible"
assert s.api_key == "sk-oh-openai"
def test_load_settings_ignores_wrong_provider_native_env_key(self, tmp_path: Path, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-wrong")
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
path = tmp_path / "settings.json"
path.write_text(
Settings(active_profile="openai-compatible").model_dump_json(),
encoding="utf-8",
)
s = load_settings(path)
assert s.active_profile == "openai-compatible"
assert s.api_key == ""
def test_env_overrides_pick_up_compact_threshold_settings(self, tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONTEXT_WINDOW_TOKENS", "123456")
monkeypatch.setenv("OPENHARNESS_AUTO_COMPACT_THRESHOLD_TOKENS", "120000")
@@ -193,8 +125,6 @@ class TestSettings:
class TestLoadSaveSettings:
def test_load_missing_file_returns_defaults(self, tmp_path: Path, monkeypatch):
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
@@ -207,8 +137,6 @@ class TestLoadSaveSettings:
assert s == Settings().materialize_active_profile()
def test_load_existing_file(self, tmp_path: Path, monkeypatch):
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
@@ -224,8 +152,6 @@ class TestLoadSaveSettings:
assert s.api_key == "" # default preserved
def test_save_and_load_roundtrip(self, tmp_path: Path, monkeypatch):
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
@@ -343,46 +269,6 @@ class TestLoadSaveSettings:
assert profile.provider == "openai_codex"
assert profile.auth_source == "codex_subscription"
def test_merge_cli_active_profile_with_model_keeps_target_profile_auth(self):
settings = Settings(
active_profile="moonshot",
provider="moonshot",
api_format="openai",
base_url="https://api.moonshot.cn/v1",
model="kimi-k2.5",
profiles={
"moonshot": ProviderProfile(
label="Moonshot",
provider="moonshot",
api_format="openai",
auth_source="moonshot_api_key",
default_model="kimi-k2.5",
last_model="kimi-k2.5",
base_url="https://api.moonshot.cn/v1",
),
"codex": ProviderProfile(
label="Codex Subscription",
provider="openai_codex",
api_format="openai",
auth_source="codex_subscription",
default_model="gpt-5.4",
last_model="gpt-5.4",
),
},
)
updated = settings.merge_cli_overrides(active_profile="codex", model="gpt-5.5")
profile_name, profile = updated.resolve_profile()
assert profile_name == "codex"
assert updated.provider == "openai_codex"
assert updated.api_format == "openai"
assert updated.base_url is None
assert updated.model == "gpt-5.5"
assert profile.provider == "openai_codex"
assert profile.auth_source == "codex_subscription"
assert profile.last_model == "gpt-5.5"
def test_merge_cli_active_profile_keeps_profile_compact_threshold_settings(self):
settings = Settings(
active_profile="moonshot",
+1 -118
View File
@@ -1182,7 +1182,7 @@ class _OkTool(BaseTool):
async def execute(self, arguments: BaseModel, context: ToolExecutionContext) -> ToolResult:
del arguments, context
return ToolResult(output="ok", metadata={"sentinel": "metadata"})
return ToolResult(output="ok")
class _BoomTool(BaseTool):
@@ -1198,64 +1198,6 @@ class _BoomTool(BaseTool):
raise RuntimeError("boom")
@pytest.mark.asyncio
async def test_query_engine_synthesizes_tool_result_when_single_tool_raises(tmp_path: Path):
registry = ToolRegistry()
registry.register(_BoomTool())
engine = QueryEngine(
api_client=FakeApiClient(
[
_FakeResponse(
message=ConversationMessage(
role="assistant",
content=[
TextBlock(text="Running one tool."),
ToolUseBlock(id="toolu_boom", name="boom_tool", input={}),
],
),
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
),
_FakeResponse(
message=ConversationMessage(
role="assistant",
content=[TextBlock(text="Recovered from the failure.")],
),
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
),
]
),
tool_registry=registry,
permission_checker=PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)),
cwd=tmp_path,
model="claude-test",
system_prompt="system",
)
events = [event async for event in engine.submit_message("run one tool")]
completed = [event for event in events if isinstance(event, ToolExecutionCompleted)]
assert len(completed) == 1
assert completed[0].tool_name == "boom_tool"
assert completed[0].is_error is True
assert "RuntimeError" in completed[0].output
assert "boom" in completed[0].output
user_tool_messages = [
msg
for msg in engine.messages
if msg.role == "user" and any(isinstance(block, ToolResultBlock) for block in msg.content)
]
assert len(user_tool_messages) == 1
result_blocks = [
block for block in user_tool_messages[0].content if isinstance(block, ToolResultBlock)
]
assert result_blocks[0].tool_use_id == "toolu_boom"
assert isinstance(events[-1], AssistantTurnComplete)
assert events[-1].message.text == "Recovered from the failure."
class _LargeOutputTool(BaseTool):
name = "mcp__playwright__browser_snapshot"
description = "Returns a large browser snapshot."
@@ -1398,7 +1340,6 @@ async def test_query_engine_synthesizes_tool_result_when_parallel_tool_raises(tm
assert set(completed_by_name) == {"ok_tool", "boom_tool"}
assert completed_by_name["ok_tool"].is_error is False
assert completed_by_name["ok_tool"].output == "ok"
assert completed_by_name["ok_tool"].metadata == {"sentinel": "metadata"}
assert completed_by_name["boom_tool"].is_error is True
assert "RuntimeError" in completed_by_name["boom_tool"].output
assert "boom" in completed_by_name["boom_tool"].output
@@ -1414,64 +1355,6 @@ async def test_query_engine_synthesizes_tool_result_when_parallel_tool_raises(tm
assert events[-1].message.text == "Recovered from the failure."
@pytest.mark.asyncio
async def test_query_engine_sanitizes_dangling_tool_use_before_new_prompt(tmp_path: Path):
engine = QueryEngine(
api_client=StaticApiClient("fresh reply"),
tool_registry=ToolRegistry(),
permission_checker=PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)),
cwd=tmp_path,
model="claude-test",
system_prompt="system",
)
engine.load_messages([
ConversationMessage.from_user_text("previous request"),
ConversationMessage(
role="assistant",
content=[ToolUseBlock(id="call_missing_output", name="ok_tool", input={})],
),
])
events = [event async for event in engine.submit_message("new prompt")]
assert isinstance(events[-1], AssistantTurnComplete)
assert events[-1].message.text == "fresh reply"
assert not any(
isinstance(block, ToolUseBlock) and block.id == "call_missing_output"
for message in engine.messages
for block in message.content
)
@pytest.mark.asyncio
async def test_query_engine_continue_pending_sanitizes_dangling_tool_use(tmp_path: Path):
engine = QueryEngine(
api_client=StaticApiClient("continued reply"),
tool_registry=ToolRegistry(),
permission_checker=PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)),
cwd=tmp_path,
model="claude-test",
system_prompt="system",
)
engine.load_messages([
ConversationMessage.from_user_text("previous request"),
ConversationMessage(
role="assistant",
content=[ToolUseBlock(id="call_missing_output", name="ok_tool", input={})],
),
])
events = [event async for event in engine.continue_pending()]
assert isinstance(events[-1], AssistantTurnComplete)
assert events[-1].message.text == "continued reply"
assert not any(
isinstance(block, ToolUseBlock) and block.id == "call_missing_output"
for message in engine.messages
for block in message.content
)
@pytest.mark.asyncio
async def test_query_engine_offloads_large_tool_result_outputs(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
-30
View File
@@ -1,30 +0,0 @@
"""Tests for the top-level config CLI."""
from __future__ import annotations
from pathlib import Path
from typer.testing import CliRunner
from openharness.cli import app
from openharness.config.settings import load_settings
def test_cli_config_set_persists_nested_web_settings(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
runner = CliRunner()
mode_result = runner.invoke(app, ["config", "set", "web.resolution_mode", "synthetic_dns"])
assert mode_result.exit_code == 0
assert "Updated web.resolution_mode" in mode_result.output
cidrs_result = runner.invoke(
app,
["config", "set", "web.synthetic_dns_cidrs", "100.64.0.0/10,203.0.113.0/24"],
)
assert cidrs_result.exit_code == 0
assert "Updated web.synthetic_dns_cidrs" in cidrs_result.output
settings = load_settings()
assert settings.web.resolution_mode == "synthetic_dns"
assert settings.web.synthetic_dns_cidrs == ["100.64.0.0/10", "203.0.113.0/24"]
-99
View File
@@ -1,99 +0,0 @@
"""Tests for hook priority ordering."""
from __future__ import annotations
from pathlib import Path
import pytest
from openharness.api.client import ApiMessageCompleteEvent
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, TextBlock
from openharness.hooks import HookEvent, HookExecutionContext, HookExecutor
from openharness.hooks.loader import HookRegistry
from openharness.hooks.schemas import CommandHookDefinition, HttpHookDefinition
class FakeApiClient:
"""Minimal fake streaming client."""
def __init__(self, text: str) -> None:
self._text = text
async def stream_message(self, request):
del request
yield ApiMessageCompleteEvent(
message=ConversationMessage(role="assistant", content=[TextBlock(text=self._text)]),
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
stop_reason=None,
)
def test_priority_defaults_to_zero():
assert CommandHookDefinition(command="true").priority == 0
assert HttpHookDefinition(url="https://example.invalid").priority == 0
def test_registry_orders_by_priority_descending():
registry = HookRegistry()
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="low", priority=1))
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="high", priority=10))
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="mid", priority=5))
commands = [hook.command for hook in registry.get(HookEvent.PRE_TOOL_USE)]
assert commands == ["high", "mid", "low"]
def test_registry_ties_keep_registration_order():
"""sorted() is stable, so equal priorities preserve insertion order."""
registry = HookRegistry()
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="first", priority=5))
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="second", priority=5))
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="third", priority=5))
commands = [hook.command for hook in registry.get(HookEvent.PRE_TOOL_USE)]
assert commands == ["first", "second", "third"]
def test_negative_priority_runs_last():
registry = HookRegistry()
registry.register(HookEvent.SESSION_START, CommandHookDefinition(command="cleanup", priority=-10))
registry.register(HookEvent.SESSION_START, CommandHookDefinition(command="default"))
commands = [hook.command for hook in registry.get(HookEvent.SESSION_START)]
assert commands == ["default", "cleanup"]
def test_summary_includes_non_default_priority():
registry = HookRegistry()
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="guard", priority=10))
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="logger"))
summary = registry.summary()
assert "priority=10" in summary
# A default (zero) priority is not noisily printed.
assert "priority=0" not in summary
@pytest.mark.asyncio
async def test_executor_runs_hooks_in_priority_order(tmp_path: Path):
"""End-to-end: execute() honours the priority-sorted registry order."""
registry = HookRegistry()
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="printf 'low'", priority=1))
registry.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(command="printf 'high'", priority=10))
executor = HookExecutor(
registry,
HookExecutionContext(
cwd=tmp_path,
api_client=FakeApiClient('{"ok": true}'),
default_model="claude-test",
),
)
result = await executor.execute(HookEvent.PRE_TOOL_USE, {"tool_name": "bash"})
assert [hook.output for hook in result.results] == ["high", "low"]
@@ -1,223 +0,0 @@
"""Tests for Claude Code-inspired memory runtime behavior."""
from __future__ import annotations
import os
import time
from pathlib import Path
import pytest
from openharness.api.client import ApiMessageCompleteEvent
from openharness.api.usage import UsageSnapshot
from openharness.commands.registry import CommandContext, create_default_command_registry
from openharness.config.settings import Settings
from openharness.engine.messages import ConversationMessage, ToolUseBlock
from openharness.engine.query_engine import QueryEngine
from openharness.memory import add_memory_entry, get_project_memory_dir
from openharness.memory.agent import (
ensure_agent_memory_vault,
get_agent_memory_entrypoint,
initialize_agent_memory_from_snapshot,
)
from openharness.memory.relevance import format_relevant_memories, select_relevant_memories
from openharness.memory.schema import (
memory_freshness_text,
parse_memory_scope,
parse_memory_type,
truncate_entrypoint_content,
)
from openharness.memory.team import (
check_team_memory_secrets,
ensure_team_memory_vault,
validate_team_memory_write_path,
)
from openharness.permissions import PermissionChecker
from openharness.services.memory_extract import (
extract_memories_from_turn,
has_memory_writes_since,
parse_extraction_records,
validate_extraction_tool_request,
)
from openharness.services.session_memory import (
get_session_memory_content,
get_session_memory_path,
update_session_memory_file,
)
from openharness.tools import create_default_tool_registry
class _FakeApiClient:
def __init__(self, text: str = "done") -> None:
self.text = text
self.requests = []
async def stream_message(self, request):
self.requests.append(request)
yield ApiMessageCompleteEvent(
message=ConversationMessage.from_user_text(self.text).model_copy(update={"role": "assistant"}),
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
stop_reason="end_turn",
)
def test_schema_truncation_and_freshness() -> None:
raw = "\n".join(f"- item {idx}" for idx in range(240))
view = truncate_entrypoint_content(raw, max_lines=10, max_bytes=1_000)
assert view.was_truncated is True
assert "WARNING" in view.content
assert parse_memory_type("note", default="project") == "project"
assert parse_memory_scope("personal") == "private"
assert "2 days old" in memory_freshness_text(time.time() - 2 * 86_400)
def test_relevance_formats_staleness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
path = add_memory_entry(project, "Redis Rule", "Redis cache decisions matter.", memory_type="project")
old = time.time() - 3 * 86_400
os.utime(path, (old, old))
selected = select_relevant_memories("redis cache", project)
rendered = format_relevant_memories(selected)
assert selected
assert "3 days old" in rendered
assert "Redis cache decisions" in rendered
@pytest.mark.asyncio
async def test_memory_extraction_writes_typed_note(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
api = _FakeApiClient(
'{"memories":[{"title":"Testing Policy","type":"feedback","scope":"project",'
'"description":"real database tests","body":"Use real database tests for migrations.",'
'"tags":["testing"]}]}'
)
messages = [
ConversationMessage.from_user_text("do not mock database migrations"),
ConversationMessage.from_user_text("noted").model_copy(update={"role": "assistant"}),
]
result = await extract_memories_from_turn(cwd=project, api_client=api, model="claude-test", messages=messages)
assert result.skipped is False
assert len(result.written_paths) == 1
text = result.written_paths[0].read_text(encoding="utf-8")
assert 'type: "feedback"' in text
assert 'scope: "project"' in text
assert "Use real database tests" in text
def test_memory_extraction_parser_and_tool_guard(tmp_path: Path) -> None:
records = parse_extraction_records(
'{"memories":[{"title":"User Role","type":"user","scope":"private","body":"User knows Go."}]}'
)
assert records[0].memory_type == "user"
assert records[0].scope == "private"
ok, _ = validate_extraction_tool_request("bash", {"command": "rg memory src"}, tmp_path)
denied, reason = validate_extraction_tool_request("write_file", {"path": "../x", "content": "x"}, tmp_path)
assert ok is True
assert denied is False
assert "within" in reason
def test_memory_write_detection_resolves_relative_paths_from_cwd(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
memory_dir = get_project_memory_dir(project)
normal_relative_write = ConversationMessage(
role="assistant",
content=[ToolUseBlock(name="write_file", input={"path": "REPORT.md", "content": "ok"})],
)
memory_absolute_write = ConversationMessage(
role="assistant",
content=[ToolUseBlock(name="write_file", input={"path": str(memory_dir / "report.md")})],
)
assert has_memory_writes_since([normal_relative_write], memory_dir, cwd=project) is False
assert has_memory_writes_since([memory_absolute_write], memory_dir, cwd=project) is True
def test_session_memory_file_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
messages = [ConversationMessage.from_user_text("current task: finish memory runtime")]
path = update_session_memory_file(project, messages, session_id="abc")
assert path == get_session_memory_path(project, "abc")
assert "finish memory runtime" in get_session_memory_content(path)
def test_team_memory_guards_and_agent_memory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
team_dir = ensure_team_memory_vault(project)
valid_path, error = validate_team_memory_write_path(project, "notes/shared.md")
escaped_path, escaped_error = validate_team_memory_write_path(project, "../outside.md")
agent_dir = ensure_agent_memory_vault(project, "reviewer/bot", "project")
assert team_dir.exists()
assert valid_path is not None and error is None
assert escaped_path is None and escaped_error
assert check_team_memory_secrets("OPENAI_API_KEY=sk-12345678901234567890")
assert agent_dir.exists()
assert get_agent_memory_entrypoint(project, "reviewer/bot", "project").exists()
def test_agent_memory_snapshot_initializes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
snapshot = project / ".openharness" / "agent-memory-snapshots" / "reviewer"
snapshot.mkdir(parents=True)
(snapshot / "MEMORY.md").write_text("# Snapshot\n", encoding="utf-8")
target = initialize_agent_memory_from_snapshot(project, "reviewer", "project")
assert target is not None
assert (target / "MEMORY.md").read_text(encoding="utf-8") == "# Snapshot\n"
@pytest.mark.asyncio
async def test_memory_commands_expose_session_team_and_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
engine = QueryEngine(
api_client=_FakeApiClient(),
tool_registry=create_default_tool_registry(),
permission_checker=PermissionChecker(Settings().permission),
cwd=project,
model="claude-test",
system_prompt="system",
)
context = CommandContext(engine=engine, cwd=str(project), session_id="s1")
registry = create_default_command_registry()
for raw in ("/memory session", "/memory team", "/memory agent status reviewer project"):
command, args = registry.lookup(raw)
assert command is not None
result = await command.handler(args, context)
assert result.message
command, args = registry.lookup("/memory add --type feedback --scope private Style :: Keep answers concise.")
assert command is not None
result = await command.handler(args, context)
assert "Added memory entry" in (result.message or "")
assert 'type: "feedback"' in (get_project_memory_dir(project) / "style.md").read_text(encoding="utf-8")
-101
View File
@@ -5,16 +5,12 @@ from __future__ import annotations
from pathlib import Path
from openharness.memory import (
add_memory_entry,
find_relevant_memories,
get_memory_entrypoint,
get_project_memory_dir,
load_memory_prompt,
migrate_memory,
remove_memory_entry,
)
from openharness.memory.scan import _parse_memory_file, scan_memory_files
from openharness.memory.usage import find_stale_memory_candidates, load_usage_index, mark_memory_used
def test_memory_paths_are_stable(tmp_path: Path, monkeypatch):
@@ -57,103 +53,6 @@ def test_find_relevant_memories(tmp_path: Path, monkeypatch):
assert matches[0].path.name == "pytest_tips.md"
def test_add_memory_entry_writes_schema_and_dedupes(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project_dir = tmp_path / "repo"
project_dir.mkdir()
first = add_memory_entry(project_dir, "Pytest Tips", "Use fixtures for setup.")
second = add_memory_entry(project_dir, "Pytest Tips Copy", "Use fixtures for setup.")
assert second == first
headers = scan_memory_files(project_dir)
assert len(headers) == 1
assert headers[0].id.startswith("mem-")
assert headers[0].schema_version == 1
assert headers[0].source == "manual"
assert headers[0].importance == 1
assert headers[0].signature
def test_remove_memory_entry_soft_deletes_and_hides_entry(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project_dir = tmp_path / "repo"
project_dir.mkdir()
path = add_memory_entry(project_dir, "Remove Me", "temporary note")
assert remove_memory_entry(project_dir, "remove_me") is True
assert path.exists()
assert scan_memory_files(project_dir) == []
headers = scan_memory_files(project_dir, include_disabled=True)
assert len(headers) == 1
assert headers[0].disabled is True
def test_migrate_memory_backfills_schema(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project_dir = tmp_path / "repo"
project_dir.mkdir()
memory_dir = get_project_memory_dir(project_dir)
legacy = memory_dir / "legacy.md"
legacy.write_text("Legacy deployment note.\n", encoding="utf-8")
dry_run = migrate_memory(project_dir, apply=False)
assert dry_run.dry_run is True
assert dry_run.changed == 1
assert "schema_version" not in legacy.read_text(encoding="utf-8")
applied = migrate_memory(project_dir, apply=True)
assert applied.dry_run is False
assert applied.changed == 1
assert applied.backup_dir
migrated = legacy.read_text(encoding="utf-8")
assert "schema_version: 1" in migrated
assert "source: \"migration\"" in migrated
assert "Legacy deployment note." in migrated
rerun = migrate_memory(project_dir, apply=False)
assert rerun.changed == 0
def test_usage_index_marks_recalled_memories_and_stale_candidates(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project_dir = tmp_path / "repo"
project_dir.mkdir()
memory_dir = get_project_memory_dir(project_dir)
(memory_dir / "old.md").write_text(
"---\n"
"schema_version: 1\n"
"id: \"mem-old\"\n"
"name: \"old\"\n"
"description: \"old note\"\n"
"type: \"project\"\n"
"category: \"knowledge\"\n"
"importance: 1\n"
"source: \"test\"\n"
"signature: \"sig-old\"\n"
"created_at: \"2020-01-01T00:00:00Z\"\n"
"updated_at: \"2020-01-01T00:00:00Z\"\n"
"ttl_days: null\n"
"disabled: false\n"
"supersedes: []\n"
"---\n\n"
"Old content.\n",
encoding="utf-8",
)
header = scan_memory_files(project_dir)[0]
assert [item.id for item in find_stale_memory_candidates(project_dir)] == ["mem-old"]
mark_memory_used(project_dir, [header])
index = load_usage_index(project_dir)
assert index["memories"]["mem-old"]["use_count"] == 1
assert find_stale_memory_candidates(project_dir) == []
# --- Frontmatter parsing tests ---
+1 -42
View File
@@ -1,10 +1,9 @@
import json
import logging
from pathlib import Path
from typer.testing import CliRunner
from ohmo.cli import _build_gateway_logging_handlers, app
from ohmo.cli import app
def test_ohmo_help():
@@ -49,42 +48,6 @@ def test_ohmo_init_noninteractive_defaults_to_deny_all_remote_access(tmp_path: P
assert config["channel_configs"] == {}
def test_gateway_logging_handlers_write_gateway_log_file(tmp_path: Path):
workspace = tmp_path / ".ohmo-home"
runner = CliRunner()
result = runner.invoke(app, ["init", "--workspace", str(workspace), "--no-interactive"])
assert result.exit_code == 0
handlers = _build_gateway_logging_handlers(workspace, console=True, log_file=True)
try:
file_handlers = [handler for handler in handlers if isinstance(handler, logging.FileHandler)]
console_handlers = [
handler
for handler in handlers
if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler)
]
assert len(file_handlers) == 1
assert len(console_handlers) == 1
record = logging.LogRecord(
name="ohmo.gateway.test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="GATEWAY_LOG_OK",
args=(),
exc_info=None,
)
file_handlers[0].emit(record)
file_handlers[0].flush()
log_path = workspace / "logs" / "gateway.log"
assert "GATEWAY_LOG_OK" in log_path.read_text(encoding="utf-8")
finally:
for handler in handlers:
handler.close()
def test_ohmo_init_interactive_writes_gateway_config(tmp_path: Path, monkeypatch):
runner = CliRunner()
workspace = tmp_path / ".ohmo-home"
@@ -153,7 +116,6 @@ def test_ohmo_init_interactive_writes_feishu_gateway_config(tmp_path: Path, monk
"n", # discord
"y", # feishu
"feishu-user-1", # allow_from
"1", # domain -> Feishu (China)
"cli_app", # app_id
"cli_secret",# app_secret
"enc_key", # encrypt_key
@@ -171,7 +133,6 @@ def test_ohmo_init_interactive_writes_feishu_gateway_config(tmp_path: Path, monk
assert result.exit_code == 0
config = json.loads((workspace / "gateway.json").read_text(encoding="utf-8"))
assert config["enabled_channels"] == ["feishu"]
assert config["channel_configs"]["feishu"]["domain"] == "https://open.feishu.cn"
assert config["channel_configs"]["feishu"]["app_id"] == "cli_app"
assert config["channel_configs"]["feishu"]["app_secret"] == "cli_secret"
assert config["channel_configs"]["feishu"]["encrypt_key"] == "enc_key"
@@ -198,7 +159,6 @@ def test_ohmo_config_interactive_can_restart_gateway(tmp_path: Path, monkeypatch
"n", # discord
"y", # feishu
"feishu-user-1", # allow_from
"2", # domain -> Lark (International)
"cli_app", # app_id
"cli_secret", # app_secret
"", # encrypt_key
@@ -219,7 +179,6 @@ def test_ohmo_config_interactive_can_restart_gateway(tmp_path: Path, monkeypatch
config = json.loads((workspace / "gateway.json").read_text(encoding="utf-8"))
assert config["provider_profile"] == "codex"
assert config["enabled_channels"] == ["feishu"]
assert config["channel_configs"]["feishu"]["domain"] == "https://open.larksuite.com"
def test_ohmo_config_keeps_existing_channel_when_not_reconfigured(tmp_path: Path, monkeypatch):
File diff suppressed because it is too large Load Diff
-18
View File
@@ -5,8 +5,6 @@ from openharness.memory import add_memory_entry as add_project_memory_entry
from openharness.prompts import build_runtime_system_prompt
from ohmo.memory import add_memory_entry as add_ohmo_memory_entry
from ohmo.memory import list_memory_files as list_ohmo_memory_files
from ohmo.memory import remove_memory_entry as remove_ohmo_memory_entry
from ohmo.prompts import build_ohmo_system_prompt
from ohmo.workspace import (
get_bootstrap_path,
@@ -55,19 +53,3 @@ def test_ohmo_runtime_prompt_can_exclude_project_memory(tmp_path: Path, monkeypa
assert "ohmo-only personal fact" in runtime_prompt
assert "project memory should not leak" not in runtime_prompt
def test_ohmo_memory_uses_schema_and_soft_delete(tmp_path: Path):
workspace = tmp_path / ".ohmo-home"
initialize_workspace(workspace)
path = add_ohmo_memory_entry(workspace, "timezone", "The user prefers UTC timestamps.")
text = path.read_text(encoding="utf-8")
assert "schema_version: 1" in text
assert "type: \"personal\"" in text
assert remove_ohmo_memory_entry(workspace, "timezone") is True
assert path.exists()
assert list_ohmo_memory_files(workspace) == []
assert "disabled: true" in path.read_text(encoding="utf-8")
-4
View File
@@ -18,10 +18,6 @@ def test_detect_platform_recognizes_windows():
assert detect_platform(system_name="Windows", release="10", env={}) == "windows"
def test_detect_platform_recognizes_win32_alias():
assert detect_platform(system_name="win32", release="10", env={}) == "windows"
def test_windows_capabilities_disable_swarm_mailbox_and_sandbox():
capabilities = get_platform_capabilities("windows")
assert capabilities.supports_native_windows_shell is True
-17
View File
@@ -58,23 +58,6 @@ def test_build_runtime_system_prompt_combines_sections(tmp_path: Path, monkeypat
assert "Memory" in prompt
def test_build_runtime_system_prompt_includes_plan_mode_guidance(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
repo = tmp_path / "repo"
repo.mkdir()
prompt = build_runtime_system_prompt(
Settings(permission={"mode": "plan"}),
cwd=repo,
latest_user_prompt="inspect only",
)
assert "Current Permission Mode" in prompt
assert "Plan mode is enabled" in prompt
assert "read-only planning and analysis" in prompt
assert "Do not call mutating tools" in prompt
def test_build_runtime_system_prompt_includes_project_context_and_fast_mode(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
repo = tmp_path / "repo"
-22
View File
@@ -128,26 +128,6 @@ async def test_start_dream_now_uses_overrides(tmp_path: Path, monkeypatch) -> No
memory_dir.mkdir(parents=True)
session_dir.mkdir(parents=True)
(session_dir / "session-one.json").write_text(json.dumps({"session_id": "one"}), encoding="utf-8")
(memory_dir / "old.md").write_text(
"---\n"
"schema_version: 1\n"
"id: \"mem-old\"\n"
"name: \"old\"\n"
"description: \"old note\"\n"
"type: \"project\"\n"
"category: \"knowledge\"\n"
"importance: 0\n"
"source: \"test\"\n"
"signature: \"sig-old\"\n"
"created_at: \"2020-01-01T00:00:00Z\"\n"
"updated_at: \"2020-01-01T00:00:00Z\"\n"
"ttl_days: null\n"
"disabled: false\n"
"supersedes: []\n"
"---\n\n"
"Old content.\n",
encoding="utf-8",
)
captured = {}
@@ -190,5 +170,3 @@ async def test_start_dream_now_uses_overrides(tmp_path: Path, monkeypatch) -> No
assert captured["argv"][:3][-1] == "ohmo"
assert "--workspace" in captured["argv"]
assert "--dangerously-skip-permissions" not in captured["argv"]
assert "Usage-based stale candidates:" in task.prompt
assert "old.md" in task.prompt
-25
View File
@@ -17,7 +17,6 @@ from openharness.services.cron import (
set_job_enabled,
upsert_cron_job,
validate_cron_expression,
validate_timezone,
)
@@ -50,15 +49,6 @@ class TestValidation:
nxt = next_run_time("0 * * * *", base)
assert nxt == datetime(2026, 1, 1, 1, 0, 0, tzinfo=timezone.utc)
def test_next_run_time_with_timezone_returns_utc(self) -> None:
base = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
nxt = next_run_time("0 18 * * *", base, tz="Asia/Hong_Kong")
assert nxt == datetime(2026, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
def test_validate_timezone(self) -> None:
assert validate_timezone("Asia/Hong_Kong")
assert not validate_timezone("Asia/HongKong")
class TestCRUD:
def test_empty_load(self) -> None:
@@ -73,21 +63,6 @@ class TestCRUD:
assert "next_run" in jobs[0]
assert "created_at" in jobs[0]
def test_upsert_preserves_notify_target(self) -> None:
notify = {"type": "feishu_dm", "user_open_id": "ou_test"}
upsert_cron_job({"name": "test-job", "schedule": "*/5 * * * *", "command": "echo hi", "notify": notify})
job = get_cron_job("test-job")
assert job is not None
assert job["notify"] == notify
def test_upsert_preserves_agent_turn_payload(self) -> None:
payload = {"kind": "agent_turn", "message": "check GitHub", "deliver": True, "channel": "feishu", "to": "ou_test"}
upsert_cron_job({"name": "test-job", "schedule": "0 18 * * *", "timezone": "Asia/Hong_Kong", "payload": payload})
job = get_cron_job("test-job")
assert job is not None
assert job["payload"] == payload
assert job["timezone"] == "Asia/Hong_Kong"
def test_upsert_replaces(self) -> None:
upsert_cron_job({"name": "j1", "schedule": "* * * * *", "command": "echo 1"})
upsert_cron_job({"name": "j1", "schedule": "0 * * * *", "command": "echo 2"})
@@ -4,8 +4,6 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
import subprocess
import sys
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -15,7 +13,6 @@ from openharness.services.cron_scheduler import (
append_history,
execute_job,
load_history,
start_daemon,
run_scheduler_loop,
)
@@ -172,42 +169,3 @@ class TestSchedulerLoop:
entries = load_history(job_name="test-once")
assert len(entries) == 1
assert entries[0]["status"] == "success"
@pytest.mark.asyncio
async def test_once_mode_handles_signal_registration_failures(self) -> None:
"""Signal handler setup should not block the scheduler loop on unsupported platforms."""
with patch("openharness.services.cron_scheduler.signal.signal", side_effect=ValueError()):
await run_scheduler_loop(once=True)
class TestDaemonStartup:
def test_start_daemon_uses_portable_process_spawn(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Windows startup should use subprocess spawning instead of fork."""
monkeypatch.setattr("openharness.services.cron_scheduler.get_platform", lambda: "windows")
monkeypatch.setattr(subprocess, "DETACHED_PROCESS", 0x00000008, raising=False)
monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False)
fake_process = Mock()
fake_process.pid = 4321
fake_process.poll.side_effect = [None]
spawned: dict[str, object] = {}
def _fake_popen(argv, **kwargs):
spawned["argv"] = argv
spawned["kwargs"] = kwargs
return fake_process
with patch("openharness.services.cron_scheduler.subprocess.Popen", side_effect=_fake_popen), patch(
"openharness.services.cron_scheduler.read_pid",
side_effect=[None, 4321],
):
pid = start_daemon()
assert pid == 4321
assert spawned["argv"] == [sys.executable, "-m", "openharness.services.cron_scheduler"]
assert spawned["kwargs"]["creationflags"] == 0x00000208
assert spawned["kwargs"]["stdin"] is subprocess.DEVNULL
assert spawned["kwargs"]["stdout"] is subprocess.DEVNULL
assert spawned["kwargs"]["stderr"] is subprocess.DEVNULL
assert spawned["kwargs"]["close_fds"] is True
-31
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import builtins
from contextlib import contextmanager
from pathlib import Path
@@ -46,36 +45,6 @@ def test_exclusive_file_lock_rejects_unknown_platform(tmp_path: Path):
pass
def test_posix_lock_reports_unavailable_when_fcntl_missing(monkeypatch, tmp_path: Path):
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "fcntl":
raise ImportError("missing fcntl")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(lockfile.SwarmLockUnavailableError, match="fcntl not available"):
with file_lock._exclusive_posix_lock(tmp_path / "posix.lock"):
pass
def test_windows_lock_reports_unavailable_when_msvcrt_missing(monkeypatch, tmp_path: Path):
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "msvcrt":
raise ImportError("missing msvcrt")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(lockfile.SwarmLockUnavailableError, match="msvcrt not available"):
with file_lock._exclusive_windows_lock(tmp_path / "windows.lock"):
pass
def test_swarm_lockfile_shim_re_exports_public_api():
"""Existing callers importing from ``swarm.lockfile`` must keep working."""
assert lockfile.exclusive_file_lock is file_lock.exclusive_file_lock
-23
View File
@@ -29,29 +29,6 @@ def test_build_inherited_env_vars_disables_coordinator_mode(monkeypatch):
assert env["CLAUDE_CODE_COORDINATOR_MODE"] == "0"
def test_build_inherited_env_vars_forwards_openharness_config_dir(monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", "/opt/data/.openharness")
env = build_inherited_env_vars()
assert env["OPENHARNESS_CONFIG_DIR"] == "/opt/data/.openharness"
def test_build_inherited_env_vars_includes_openharness_auth_vars(monkeypatch):
monkeypatch.setenv("OPENHARNESS_PROVIDER", "openai")
monkeypatch.setenv("OPENHARNESS_BASE_URL", "https://relay.example.com/v1")
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
monkeypatch.setenv("OPENHARNESS_ANTHROPIC_API_KEY", "sk-oh-anthropic")
env = build_inherited_env_vars()
assert env["OPENHARNESS_AGENT_TEAMS"] == "1"
assert env["OPENHARNESS_PROVIDER"] == "openai"
assert env["OPENHARNESS_BASE_URL"] == "https://relay.example.com/v1"
assert env["OPENHARNESS_OPENAI_API_KEY"] == "sk-oh-openai"
assert env["OPENHARNESS_ANTHROPIC_API_KEY"] == "sk-oh-anthropic"
# ---------------------------------------------------------------------------
# build_inherited_cli_flags model handling
# ---------------------------------------------------------------------------
+1 -100
View File
@@ -56,76 +56,6 @@ async def test_file_write_read_and_edit(tmp_path: Path):
assert "TWO" in (tmp_path / "notes.txt").read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_file_write_requests_edit_approval_and_reports_diff_stats(tmp_path: Path):
approvals: list[tuple[str, str, int, int]] = []
async def _approve(path: str, diff: str, added: int, removed: int) -> str:
approvals.append((path, diff, added, removed))
return "once"
result = await FileWriteTool().execute(
FileWriteToolInput(path="notes.txt", content="one\ntwo\n"),
ToolExecutionContext(cwd=tmp_path, metadata={"edit_approval_prompt": _approve}),
)
assert result.is_error is False
assert (tmp_path / "notes.txt").read_text(encoding="utf-8") == "one\ntwo\n"
assert len(approvals) == 1
path, diff, added, removed = approvals[0]
assert path == str(tmp_path / "notes.txt")
assert added == 2
assert removed == 0
assert "@@" in diff
assert "+one" in diff
assert "+two" in diff
assert "\033[32m+2\033[0m" in result.output
assert "\033[31m-0\033[0m" in result.output
@pytest.mark.asyncio
async def test_file_write_rejection_does_not_create_parent_directories(tmp_path: Path):
async def _reject(path: str, diff: str, added: int, removed: int) -> str:
del path, diff, added, removed
return "reject"
result = await FileWriteTool().execute(
FileWriteToolInput(path="nested/notes.txt", content="draft\n"),
ToolExecutionContext(cwd=tmp_path, metadata={"edit_approval_prompt": _reject}),
)
assert result.is_error is True
assert "Write rejected by user" in result.output
assert not (tmp_path / "nested").exists()
@pytest.mark.asyncio
async def test_file_edit_rejects_when_edit_approval_denied(tmp_path: Path):
target = tmp_path / "notes.txt"
target.write_text("one\ntwo\n", encoding="utf-8")
approvals: list[tuple[str, str, int, int]] = []
async def _reject(path: str, diff: str, added: int, removed: int) -> str:
approvals.append((path, diff, added, removed))
return "reject"
result = await FileEditTool().execute(
FileEditToolInput(path="notes.txt", old_str="two", new_str="TWO"),
ToolExecutionContext(cwd=tmp_path, metadata={"edit_approval_prompt": _reject}),
)
assert result.is_error is True
assert "Edit rejected by user" in result.output
assert target.read_text(encoding="utf-8") == "one\ntwo\n"
assert len(approvals) == 1
path, diff, added, removed = approvals[0]
assert path == str(target)
assert added == 1
assert removed == 1
assert "-two" in diff
assert "+TWO" in diff
@pytest.mark.asyncio
async def test_glob_and_grep(tmp_path: Path):
context = ToolExecutionContext(cwd=tmp_path)
@@ -374,19 +304,13 @@ async def test_cron_and_remote_trigger_tools(tmp_path: Path, monkeypatch):
context = ToolExecutionContext(cwd=tmp_path)
create_result = await CronCreateTool().execute(
CronCreateToolInput(
name="nightly",
schedule="0 0 * * *",
command="printf 'CRON_OK'",
notify={"type": "feishu_dm", "user_open_id": "ou_test"},
),
CronCreateToolInput(name="nightly", schedule="0 0 * * *", command="printf 'CRON_OK'"),
context,
)
assert create_result.is_error is False
list_result = await CronListTool().execute(CronListToolInput(), context)
assert "nightly" in list_result.output
assert "feishu_dm" in list_result.output
trigger_result = await RemoteTriggerTool().execute(
RemoteTriggerToolInput(name="nightly"),
@@ -400,26 +324,3 @@ async def test_cron_and_remote_trigger_tools(tmp_path: Path, monkeypatch):
context,
)
assert delete_result.is_error is False
@pytest.mark.asyncio
async def test_cron_create_agent_turn_payload(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
context = ToolExecutionContext(cwd=tmp_path)
create_result = await CronCreateTool().execute(
CronCreateToolInput(
name="daily-summary",
schedule="0 18 * * *",
timezone="Asia/Hong_Kong",
message="check GitHub",
payload={"deliver": True, "channel": "feishu", "to": "ou_test"},
),
context,
)
assert create_result.is_error is False
list_result = await CronListTool().execute(CronListToolInput(), context)
assert "daily-summary" in list_result.output
assert "Asia/Hong_Kong" in list_result.output
assert "payload: agent_turn -> feishu:ou_test" in list_result.output
-28
View File
@@ -163,31 +163,3 @@ async def test_grep_tool_python_fallback_reports_invalid_regex(monkeypatch, tmp_
assert result.is_error is False
assert "invalid regex pattern 'hello('" in result.output
assert "unterminated subpattern" in result.output
@pytest.mark.asyncio
async def test_grep_tool_reports_missing_root_before_spawning_rg(monkeypatch, tmp_path: Path):
tool = GrepTool()
src_root = tmp_path / "src"
tests_root = tmp_path / "tests"
src_root.mkdir()
tests_root.mkdir()
monkeypatch.setattr("openharness.tools.grep_tool.shutil.which", lambda _: "/usr/bin/rg")
async def fail_create_subprocess_exec(*args, **kwargs):
del args, kwargs
raise AssertionError("rg should not be spawned for a missing root")
monkeypatch.setattr(
"openharness.tools.grep_tool.asyncio.create_subprocess_exec",
fail_create_subprocess_exec,
)
result = await tool.execute(
GrepToolInput(pattern="continue", root=f"{src_root} {tests_root}"),
type("Ctx", (), {"cwd": tmp_path})(),
)
assert result.is_error is True
assert "Search root does not exist" in result.output
assert "call grep separately for each root" in result.output
@@ -1,215 +0,0 @@
"""Tests for the image_generation tool."""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any
import pytest
from openharness.config.settings import ImageGenerationConfig
from openharness.tools.base import ToolExecutionContext
from openharness.tools.image_generation_tool import ImageGenerationTool, ImageGenerationToolInput
class _FakeStreamResponse:
def __init__(self, *, status_code: int = 200, lines: list[str] | None = None, body: str = "") -> None:
self.status_code = status_code
self._lines = lines or []
self._body = body.encode("utf-8")
async def __aenter__(self) -> "_FakeStreamResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def aread(self) -> bytes:
return self._body
async def aiter_lines(self):
for line in self._lines:
yield line
class _FakeAsyncClient:
def __init__(self, response: _FakeStreamResponse, sink: dict[str, Any]) -> None:
self._response = response
self._sink = sink
async def __aenter__(self) -> "_FakeAsyncClient":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def stream(self, method: str, url: str, *, headers: dict[str, str], json: dict[str, Any]):
self._sink["method"] = method
self._sink["url"] = url
self._sink["headers"] = headers
self._sink["json"] = json
return self._response
def _b64url(data: dict[str, object]) -> str:
raw = json.dumps(data, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _fake_codex_token() -> str:
payload = {"https://api.openai.com/auth": {"chatgpt_account_id": "acct_test"}}
return f"{_b64url({'alg': 'none', 'typ': 'JWT'})}.{_b64url(payload)}.sig"
@pytest.mark.asyncio
async def test_execute_requires_api_key_for_openai(tmp_path: Path) -> None:
tool = ImageGenerationTool()
result = await tool.execute(
ImageGenerationToolInput(prompt="a cat", provider="openai"),
ToolExecutionContext(cwd=tmp_path, metadata={"image_generation_config": {}}),
)
assert result.is_error
assert "API key is not configured" in result.output
@pytest.mark.asyncio
async def test_execute_generate_writes_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
image_bytes = b"fake-png"
image_b64 = base64.b64encode(image_bytes).decode("ascii")
async def fake_generate_images(arguments, model, api_key, base_url):
assert arguments.prompt == "a cat"
assert model == "gpt-image-2"
assert api_key == "test-key"
assert base_url == ""
return [image_b64]
monkeypatch.setattr(ImageGenerationTool, "_generate_images", staticmethod(fake_generate_images))
tool = ImageGenerationTool()
result = await tool.execute(
ImageGenerationToolInput(prompt="a cat", output_path="assets/cat.png", provider="openai"),
ToolExecutionContext(
cwd=tmp_path,
metadata={"image_generation_config": {"api_key": "test-key", "model": "gpt-image-2"}},
),
)
out = tmp_path / "assets" / "cat.png"
assert not result.is_error
assert out.read_bytes() == image_bytes
assert result.metadata["paths"] == [str(out)]
assert result.metadata["provider"] == "openai"
@pytest.mark.asyncio
async def test_execute_codex_hosted_generation(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
image_bytes = b"codex-png"
image_b64 = base64.b64encode(image_bytes).decode("ascii")
sink: dict[str, Any] = {}
response = _FakeStreamResponse(
lines=[
'data: {"type":"response.output_item.done","item":{"id":"ig_1","type":"image_generation_call","status":"completed","revised_prompt":"blue icon","result":"%s"}}' % image_b64,
"",
'data: {"type":"response.completed","response":{"status":"completed"}}',
"",
]
)
monkeypatch.setattr(
"openharness.tools.image_generation_tool.httpx.AsyncClient",
lambda *args, **kwargs: _FakeAsyncClient(response, sink),
)
tool = ImageGenerationTool()
result = await tool.execute(
ImageGenerationToolInput(prompt="a blue icon", output_path="icon.png", provider="codex"),
ToolExecutionContext(
cwd=tmp_path,
metadata={
"image_generation_config": {
"codex_auth_token": _fake_codex_token(),
"codex_model": "gpt-5.4",
}
},
),
)
out = tmp_path / "icon.png"
assert not result.is_error
assert out.read_bytes() == image_bytes
assert result.metadata["provider"] == "codex"
assert result.metadata["revised_prompt"] == "blue icon"
assert sink["url"].endswith("/codex/responses")
assert sink["json"]["tools"] == [{"type": "image_generation", "output_format": "png"}]
assert sink["json"]["tool_choice"] == "auto"
@pytest.mark.asyncio
async def test_auto_provider_prefers_codex_when_token_available(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
image_b64 = base64.b64encode(b"codex").decode("ascii")
async def fake_codex(self, arguments, config):
return [image_b64], None
monkeypatch.setattr(ImageGenerationTool, "_generate_with_codex", fake_codex)
tool = ImageGenerationTool()
result = await tool.execute(
ImageGenerationToolInput(prompt="a cat", output_path="cat.png"),
ToolExecutionContext(
cwd=tmp_path,
metadata={"image_generation_config": {"codex_auth_token": _fake_codex_token()}},
),
)
assert not result.is_error
assert result.metadata["provider"] == "codex"
@pytest.mark.asyncio
async def test_execute_refuses_overwrite_by_default(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
out = tmp_path / "image.png"
out.write_bytes(b"existing")
image_b64 = base64.b64encode(b"new").decode("ascii")
async def fake_generate_images(arguments, model, api_key, base_url):
return [image_b64]
monkeypatch.setattr(ImageGenerationTool, "_generate_images", staticmethod(fake_generate_images))
tool = ImageGenerationTool()
result = await tool.execute(
ImageGenerationToolInput(prompt="a cat", output_path=str(out), provider="openai"),
ToolExecutionContext(cwd=tmp_path, metadata={"image_generation_config": {"api_key": "test"}}),
)
assert result.is_error
assert "output already exists" in result.output
assert out.read_bytes() == b"existing"
def test_resolve_output_paths_multiple(tmp_path: Path) -> None:
paths = ImageGenerationTool._resolve_output_paths(
ImageGenerationToolInput(output_path="hero.png", n=2),
tmp_path,
)
assert paths == [tmp_path / "hero-1.png", tmp_path / "hero-2.png"]
def test_image_generation_config_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_IMAGE_GENERATION_PROVIDER", "openai")
monkeypatch.setenv("OPENHARNESS_IMAGE_GENERATION_MODEL", "gpt-image-1")
monkeypatch.setenv("OPENHARNESS_IMAGE_GENERATION_API_KEY", "sk-test")
monkeypatch.setenv("OPENHARNESS_IMAGE_GENERATION_BASE_URL", "https://example.test/v1")
monkeypatch.setenv("OPENHARNESS_IMAGE_GENERATION_CODEX_MODEL", "gpt-5.4")
cfg = ImageGenerationConfig.from_env()
assert cfg.provider == "openai"
assert cfg.model == "gpt-image-1"
assert cfg.api_key == "sk-test"
assert cfg.base_url == "https://example.test/v1"
assert cfg.codex_model == "gpt-5.4"
assert cfg.is_configured
-68
View File
@@ -10,7 +10,6 @@ import pytest
from openharness.tools.base import ToolExecutionContext
from openharness.tools.web_fetch_tool import WebFetchTool, WebFetchToolInput, _html_to_text
from openharness.tools.web_search_tool import WebSearchTool, WebSearchToolInput
from openharness.utils.network_guard import fetch_public_http_response
@pytest.mark.asyncio
@@ -111,73 +110,6 @@ async def test_web_fetch_tool_rejects_non_public_targets(tmp_path):
assert "non-public" in result.output
@pytest.mark.asyncio
async def test_web_search_tool_uses_env_search_url(tmp_path, monkeypatch):
calls = []
async def fake_fetch(url: str, **kwargs: object) -> httpx.Response:
calls.append((url, kwargs))
request = httpx.Request("GET", url, params=kwargs.get("params"))
body = (
"<html><body>"
'<a class="result__a" href="https://example.com/docs">OpenHarness Docs</a>'
'<div class="result__snippet">Found through configured search.</div>'
"</body></html>"
)
return httpx.Response(200, text=body, request=request)
monkeypatch.setenv("OPENHARNESS_WEB_SEARCH_URL", "https://search.example.com/html")
monkeypatch.setitem(WebSearchTool.execute.__globals__, "fetch_public_http_response", fake_fetch)
tool = WebSearchTool()
result = await tool.execute(WebSearchToolInput(query="openharness docs"), ToolExecutionContext(cwd=tmp_path))
assert result.is_error is False
assert calls[0][0] == "https://search.example.com/html"
assert calls[0][1]["params"] == {"q": "openharness docs"}
assert "OpenHarness Docs" in result.output
@pytest.mark.asyncio
async def test_fetch_public_http_response_uses_openharness_web_proxy(monkeypatch):
seen = {}
class FakeClient:
def __init__(self, **kwargs: object) -> None:
seen.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def get(self, url: str, **kwargs: object) -> httpx.Response:
request = httpx.Request("GET", url, params=kwargs.get("params"))
return httpx.Response(200, text="ok", request=request)
monkeypatch.setenv("OPENHARNESS_WEB_PROXY", "http://proxy.example.com:7890")
monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
async def fake_ensure_public_http_url(url: str) -> None:
return None
monkeypatch.setattr("openharness.utils.network_guard.ensure_public_http_url", fake_ensure_public_http_url)
response = await fetch_public_http_response("https://example.com/")
assert response.status_code == 200
assert seen["trust_env"] is False
assert seen["proxy"] == "http://proxy.example.com:7890"
@pytest.mark.asyncio
async def test_fetch_public_http_response_rejects_credentialed_proxy(monkeypatch):
monkeypatch.setenv("OPENHARNESS_WEB_PROXY", "http://user:pass@proxy.example.com:7890")
with pytest.raises(ValueError, match="embedded credentials"):
await fetch_public_http_response("https://example.com/")
@pytest.mark.asyncio
async def test_web_search_tool_rejects_non_public_search_backends(tmp_path):
tool = WebSearchTool()
+5 -228
View File
@@ -5,23 +5,16 @@ from __future__ import annotations
import asyncio
import io
import json
from types import SimpleNamespace
import pytest
from openharness.api.client import ApiMessageCompleteEvent
from openharness.api.usage import UsageSnapshot
from openharness.engine.stream_events import CompactProgressEvent
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock
from openharness.engine.messages import ConversationMessage, TextBlock
from openharness.config.settings import Settings, save_settings
from openharness.ui.backend_host import (
BackendHostConfig,
ReactBackendHost,
_build_user_message_with_images,
_format_transcript_line,
run_backend_host,
)
from openharness.ui.protocol import BackendEvent, FrontendImageAttachment, FrontendRequest
from openharness.ui.backend_host import BackendHostConfig, ReactBackendHost, run_backend_host
from openharness.ui.protocol import BackendEvent
from openharness.ui.runtime import build_runtime, close_runtime, start_runtime
@@ -40,19 +33,6 @@ class StaticApiClient:
)
class CapturingApiClient(StaticApiClient):
"""Fake streaming client that records model requests."""
def __init__(self, text: str) -> None:
super().__init__(text)
self.requests = []
async def stream_message(self, request):
self.requests.append(request)
async for event in super().stream_message(request):
yield event
class FailingApiClient:
"""Fake client that triggers the query-loop ErrorEvent path."""
@@ -76,71 +56,6 @@ class FakeBinaryStdout:
return None
def test_frontend_request_accepts_image_attachments():
request = FrontendRequest.model_validate(
{
"type": "submit_line",
"line": "describe this",
"images": [
{
"media_type": "image/png",
"data": "aGVsbG8=",
"source_path": "clipboard:clipboard.png",
}
],
}
)
assert request.images[0].media_type == "image/png"
assert request.images[0].data == "aGVsbG8="
def test_frontend_request_rejects_non_image_attachments():
with pytest.raises(ValueError):
FrontendRequest.model_validate(
{
"type": "submit_line",
"images": [
{
"media_type": "text/plain",
"data": "aGVsbG8=",
}
],
}
)
def test_build_user_message_with_images():
message = _build_user_message_with_images(
"What is in this screenshot?",
[
FrontendImageAttachment(
media_type="image/png",
data="aGVsbG8=",
source_path="clipboard:clipboard.png",
)
],
)
assert message is not None
assert message.text == "What is in this screenshot?"
assert isinstance(message.content[1], ImageBlock)
assert message.content[1].media_type == "image/png"
assert message.content[1].source_path == "clipboard:clipboard.png"
def test_format_transcript_line_mentions_attached_images():
assert _format_transcript_line("inspect", []) == "inspect"
assert _format_transcript_line("", [FrontendImageAttachment(media_type="image/png", data="x")]) == "[1 image attached]"
assert _format_transcript_line(
"inspect",
[
FrontendImageAttachment(media_type="image/png", data="x"),
FrontendImageAttachment(media_type="image/jpeg", data="y"),
],
) == "inspect\n[2 images attached]"
@pytest.mark.asyncio
async def test_run_backend_host_accepts_permission_mode(monkeypatch):
captured: dict[str, str | None] = {}
@@ -192,38 +107,6 @@ async def test_read_requests_resolves_permission_response_without_queueing(monke
assert host._request_queue.empty()
@pytest.mark.asyncio
async def test_read_requests_resolves_edit_approval_response_without_queueing(monkeypatch):
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
fut = asyncio.get_running_loop().create_future()
host._edit_approval_requests["req-1"] = fut
payload = b'{"type":"permission_response","request_id":"req-1","allowed":true,"permission_reply":"always"}\n'
class _FakeBuffer:
def __init__(self):
self._reads = 0
def readline(self):
self._reads += 1
if self._reads == 1:
return payload
return b""
class _FakeStdin:
buffer = _FakeBuffer()
monkeypatch.setattr("openharness.ui.backend_host.sys.stdin", _FakeStdin())
await host._read_requests()
assert fut.done()
assert fut.result() == "always"
queued = await host._request_queue.get()
assert queued.type == "shutdown"
assert host._request_queue.empty()
@pytest.mark.asyncio
async def test_read_requests_interrupt_cancels_active_request(monkeypatch):
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
@@ -362,57 +245,6 @@ async def test_backend_host_processes_model_turn(tmp_path, monkeypatch):
)
@pytest.mark.asyncio
async def test_backend_host_processes_image_turn(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
client = CapturingApiClient("image received")
host = ReactBackendHost(BackendHostConfig(api_client=client))
host._bundle = await build_runtime(api_client=client)
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
should_continue = await host._process_line(
"",
images=[
FrontendImageAttachment(
media_type="image/png",
data="aGVsbG8=",
source_path="clipboard:clipboard.png",
)
],
)
finally:
await close_runtime(host._bundle)
assert should_continue is True
assert len(client.requests) == 1
image_messages = [
message
for message in client.requests[0].messages
if message.role == "user" and any(isinstance(block, ImageBlock) for block in message.content)
]
assert len(image_messages) == 1
message = image_messages[0]
assert message.text == "Please analyze the attached image."
image = next(block for block in message.content if isinstance(block, ImageBlock))
assert image.data == "aGVsbG8="
assert any(
event.type == "transcript_item"
and event.item
and event.item.role == "user"
and event.item.text == "[1 image attached]"
for event in events
)
@pytest.mark.asyncio
async def test_backend_host_emits_compact_progress_event(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
@@ -426,10 +258,8 @@ async def test_backend_host_emits_compact_progress_event(tmp_path, monkeypatch):
async def _emit(event):
events.append(event)
async def _fake_handle_line(
bundle, line, print_system, render_event, clear_output, user_message=None
):
del bundle, line, print_system, clear_output, user_message
async def _fake_handle_line(bundle, line, print_system, render_event, clear_output):
del bundle, line, print_system, clear_output
await render_event(
CompactProgressEvent(
phase="compact_start",
@@ -836,56 +666,3 @@ async def test_concurrent_ask_permission_are_serialised():
# distinct request IDs must have been emitted.
assert len(emitted_order) == 2
assert emitted_order[0] != emitted_order[1]
@pytest.mark.asyncio
async def test_ask_edit_approval_remembers_always_choice():
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
async def _resolver():
while not host._edit_approval_requests:
await asyncio.sleep(0)
next(iter(host._edit_approval_requests.values())).set_result("always")
asyncio.create_task(_resolver())
reply = await host._ask_edit_approval("notes.txt", "@@ -1 +1 @@\n-old\n+new", 1, 1)
assert reply == "always"
assert host._edit_always_approved is True
assert any(
event.type == "modal_request"
and event.modal
and event.modal.get("kind") == "edit_diff"
for event in events
)
events.clear()
second_reply = await host._ask_edit_approval("notes.txt", "@@ -1 +1 @@\n-old\n+new", 1, 1)
assert second_reply == "always"
assert events == []
@pytest.mark.asyncio
async def test_ask_edit_approval_skips_when_session_mode_is_full_auto():
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
host._bundle = SimpleNamespace(
app_state=SimpleNamespace(get=lambda: SimpleNamespace(permission_mode="full_auto"))
)
reply = await host._ask_edit_approval("notes.txt", "@@ -1 +1 @@\n-old\n+new", 1, 1)
assert reply == "always"
assert events == []
-16
View File
@@ -31,19 +31,3 @@ async def test_build_runtime_exits_cleanly_for_openai_format(monkeypatch):
with pytest.raises(SystemExit, match="1"):
await build_runtime(active_profile="openai-compatible", api_format="openai")
@pytest.mark.asyncio
async def test_build_runtime_reports_subscription_auth_setup(monkeypatch, tmp_path, capsys):
"""Subscription profiles should not be reported as missing API keys."""
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
with pytest.raises(SystemExit, match="1"):
await build_runtime(active_profile="claude-subscription")
captured = capsys.readouterr()
assert "subscription auth, not an API key" in captured.err
assert "oh auth claude-login" in captured.err
assert "oh provider use claude-subscription" in captured.err
assert "No API key configured" not in captured.err

Some files were not shown because too many files have changed in this diff Show More